mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-09-02 02:14:08 +00:00
improve media upload pipeline
This commit is contained in:
parent
6f597a08a1
commit
08801b2583
67 changed files with 4867 additions and 1064 deletions
|
|
@ -56,9 +56,12 @@ android {
|
||||||
applicationIdSuffix ".testing"
|
applicationIdSuffix ".testing"
|
||||||
manifestPlaceholders = [appName: "twonly [dev]"]
|
manifestPlaceholders = [appName: "twonly [dev]"]
|
||||||
}
|
}
|
||||||
// profile {
|
profile {
|
||||||
// applicationIdSuffix ".STOP"
|
// Profile builds are installed while profiling production-like
|
||||||
// }
|
// performance. They must never replace the store app.
|
||||||
|
applicationIdSuffix ".testing"
|
||||||
|
manifestPlaceholders = [appName: "twonly [profile]"]
|
||||||
|
}
|
||||||
release {
|
release {
|
||||||
shrinkResources false
|
shrinkResources false
|
||||||
minifyEnabled false
|
minifyEnabled false
|
||||||
|
|
|
||||||
|
|
@ -94,12 +94,25 @@
|
||||||
<receiver
|
<receiver
|
||||||
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingReceiver"
|
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingReceiver"
|
||||||
tools:node="remove" />
|
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
|
<meta-data
|
||||||
android:name="eu.twonly.service.TWONLY_LOGO"
|
android:name="eu.twonly.service.TWONLY_LOGO"
|
||||||
android:resource="@drawable/ic_launcher_foreground" />
|
android:resource="@drawable/ic_launcher_foreground" />
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<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.POST_NOTIFICATIONS"/>
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package eu.twonly
|
package eu.twonly
|
||||||
|
|
||||||
|
import eu.twonly.directmedia.DirectMediaPrepare
|
||||||
import io.flutter.app.FlutterApplication
|
import io.flutter.app.FlutterApplication
|
||||||
import io.crates.keyring.Keyring
|
import io.crates.keyring.Keyring
|
||||||
|
|
||||||
|
|
@ -13,5 +14,9 @@ class MyApplication : FlutterApplication() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
instance = this
|
instance = this
|
||||||
Keyring.initializeNdkContext(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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -13,7 +13,12 @@ import java.util.concurrent.TimeUnit
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
|
||||||
/** Called directly from Rust/JNI. It persists the descriptor in app-private
|
/** 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 {
|
object DirectMediaTransfer {
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun schedule(descriptorJson: String): Boolean = try {
|
fun schedule(descriptorJson: String): Boolean = try {
|
||||||
|
|
@ -28,13 +33,15 @@ object DirectMediaTransfer {
|
||||||
|
|
||||||
val manager = WorkManager.getInstance(MyApplication.instance)
|
val manager = WorkManager.getInstance(MyApplication.instance)
|
||||||
val media = request(attachmentId, "media", descriptorFile, expiresAt)
|
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-media", ExistingWorkPolicy.KEEP, media)
|
||||||
|
if (descriptor.has("manifest")) {
|
||||||
|
val manifest = request(attachmentId, "manifest", descriptorFile, expiresAt)
|
||||||
manager.enqueueUniqueWork(
|
manager.enqueueUniqueWork(
|
||||||
"direct-media-$attachmentId-manifest",
|
"direct-media-$attachmentId-manifest",
|
||||||
ExistingWorkPolicy.KEEP,
|
ExistingWorkPolicy.KEEP,
|
||||||
manifest,
|
manifest,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
true
|
true
|
||||||
} catch (_: Throwable) {
|
} catch (_: Throwable) {
|
||||||
false
|
false
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,11 @@ class DirectMediaUploadWorker(
|
||||||
val request = descriptor.getJSONObject(role)
|
val request = descriptor.getJSONObject(role)
|
||||||
val status = upload(request)
|
val status = upload(request)
|
||||||
if (status in 200..299) {
|
if (status in 200..299) {
|
||||||
if (role == "media") {
|
|
||||||
// Best effort only. A 202 is success because server reconciliation
|
// Best effort only. A 202 is success because server reconciliation
|
||||||
// owns completion when the manifest has not arrived yet.
|
// 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"))
|
upload(descriptor.getJSONObject("complete"))
|
||||||
}
|
}
|
||||||
Result.success()
|
Result.success()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -89,6 +89,8 @@ object NativeVideoCodec {
|
||||||
overlayPath: String?,
|
overlayPath: String?,
|
||||||
outputPath: String,
|
outputPath: String,
|
||||||
removeAudio: Boolean,
|
removeAudio: Boolean,
|
||||||
|
trimStartMs: Long,
|
||||||
|
trimEndMs: Long,
|
||||||
mediaId: String,
|
mediaId: String,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val context = MyApplication.instance
|
val context = MyApplication.instance
|
||||||
|
|
@ -110,7 +112,14 @@ object NativeVideoCodec {
|
||||||
handler.post {
|
handler.post {
|
||||||
try {
|
try {
|
||||||
val effects = buildEffects(overlayPath, source)
|
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)
|
.setRemoveAudio(removeAudio)
|
||||||
.setEffects(effects)
|
.setEffects(effects)
|
||||||
.build()
|
.build()
|
||||||
|
|
@ -232,6 +241,28 @@ object NativeVideoCodec {
|
||||||
* settings, so a source that refuses to be probed still renders — it just
|
* settings, so a source that refuses to be probed still renders — it just
|
||||||
* falls back to the 1080p30 defaults.
|
* 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? {
|
private fun probe(inputPath: String): SourceVideo? {
|
||||||
val retriever = MediaMetadataRetriever()
|
val retriever = MediaMetadataRetriever()
|
||||||
return try {
|
return try {
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
D25D4D1E2EF626E30029F805 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D25D4D1D2EF626E30029F805 /* StoreKit.framework */; };
|
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, ); }; };
|
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 */; };
|
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 */; };
|
D3A100032F70000100D1A002 /* NativeImageCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A100042F70000100D1A002 /* NativeImageCodec.swift */; };
|
||||||
D3A100052F70000100D1A003 /* NativeVideoCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A100062F70000100D1A003 /* NativeVideoCodec.swift */; };
|
D3A100052F70000100D1A003 /* NativeVideoCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A100062F70000100D1A003 /* NativeVideoCodec.swift */; };
|
||||||
D3A100072F70000100D1A004 /* NativeGallery.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A100082F70000100D1A004 /* NativeGallery.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; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
D3A100082F70000100D1A004 /* NativeGallery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeGallery.swift; sourceTree = "<group>"; };
|
||||||
|
|
@ -248,6 +250,7 @@
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
D3A100012F70000100D1A001 /* DirectMediaTransfer.swift */,
|
D3A100012F70000100D1A001 /* DirectMediaTransfer.swift */,
|
||||||
|
D3A100052F70000100D1A005 /* BackgroundWork.swift */,
|
||||||
D3A100042F70000100D1A002 /* NativeImageCodec.swift */,
|
D3A100042F70000100D1A002 /* NativeImageCodec.swift */,
|
||||||
D3A100062F70000100D1A003 /* NativeVideoCodec.swift */,
|
D3A100062F70000100D1A003 /* NativeVideoCodec.swift */,
|
||||||
D3A100082F70000100D1A004 /* NativeGallery.swift */,
|
D3A100082F70000100D1A004 /* NativeGallery.swift */,
|
||||||
|
|
@ -647,6 +650,7 @@
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
D3A100022F70000100D1A001 /* DirectMediaTransfer.swift in Sources */,
|
D3A100022F70000100D1A001 /* DirectMediaTransfer.swift in Sources */,
|
||||||
|
D3A100062F70000100D1A006 /* BackgroundWork.swift in Sources */,
|
||||||
D3A100032F70000100D1A002 /* NativeImageCodec.swift in Sources */,
|
D3A100032F70000100D1A002 /* NativeImageCodec.swift in Sources */,
|
||||||
D3A100052F70000100D1A003 /* NativeVideoCodec.swift in Sources */,
|
D3A100052F70000100D1A003 /* NativeVideoCodec.swift in Sources */,
|
||||||
D3A100072F70000100D1A004 /* NativeGallery.swift in Sources */,
|
D3A100072F70000100D1A004 /* NativeGallery.swift in Sources */,
|
||||||
|
|
@ -776,7 +780,7 @@
|
||||||
DEVELOPMENT_TEAM = CN332ZUGRP;
|
DEVELOPMENT_TEAM = CN332ZUGRP;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = twonly;
|
INFOPLIST_KEY_CFBundleDisplayName = "twonly [profile]";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
|
@ -784,7 +788,7 @@
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.0.5;
|
MARKETING_VERSION = 0.0.5;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = eu.twonly;
|
PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.testing;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTS_MACCATALYST = NO;
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
|
@ -975,7 +979,7 @@
|
||||||
DEVELOPMENT_TEAM = CN332ZUGRP;
|
DEVELOPMENT_TEAM = CN332ZUGRP;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = twonly;
|
INFOPLIST_KEY_CFBundleDisplayName = "twonly [dev]";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
|
@ -983,7 +987,7 @@
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.0.5;
|
MARKETING_VERSION = 0.0.5;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = eu.twonly;
|
PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.testing;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTS_MACCATALYST = NO;
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
|
@ -1061,7 +1065,7 @@
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.NotificationService;
|
PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.testing.NotificationService;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||||
|
|
@ -1139,7 +1143,7 @@
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.NotificationService;
|
PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.testing.NotificationService;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
|
@ -1179,7 +1183,7 @@
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.ShareExtension;
|
PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.testing.ShareExtension;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
|
@ -1265,7 +1269,7 @@
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.ShareExtension;
|
PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.testing.ShareExtension;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,20 @@ import flutter_sharing_intent
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
UNUserNotificationCenter.current().delegate = self
|
UNUserNotificationCenter.current().delegate = self
|
||||||
|
// Must happen before launching finishes: BGTaskScheduler refuses an
|
||||||
|
// identifier registered any later.
|
||||||
|
BackgroundWork.register()
|
||||||
|
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
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(
|
override func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
handleEventsForBackgroundURLSession identifier: String,
|
handleEventsForBackgroundURLSession identifier: String,
|
||||||
|
|
|
||||||
158
ios/Runner/BackgroundWork.swift
Normal file
158
ios/Runner/BackgroundWork.swift
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,12 +8,14 @@ private struct DirectMediaRequest: Codable {
|
||||||
let bodyPath: String
|
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 {
|
private struct DirectMediaDescriptor: Codable {
|
||||||
let attachmentId: String
|
let attachmentId: String
|
||||||
let expiresAt: Int64
|
let expiresAt: Int64
|
||||||
let media: DirectMediaRequest
|
let media: DirectMediaRequest
|
||||||
let manifest: DirectMediaRequest
|
let manifest: DirectMediaRequest?
|
||||||
let complete: DirectMediaRequest
|
let complete: DirectMediaRequest?
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Thin transport-only adapter. Rust supplies immutable request files and all
|
/// 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 data = json.data(using: .utf8),
|
||||||
let descriptor = try? decoder.decode(DirectMediaDescriptor.self, from: data),
|
let descriptor = try? decoder.decode(DirectMediaDescriptor.self, from: data),
|
||||||
descriptor.expiresAt > Int64(Date().timeIntervalSince1970),
|
descriptor.expiresAt > Int64(Date().timeIntervalSince1970),
|
||||||
FileManager.default.fileExists(atPath: descriptor.media.bodyPath),
|
FileManager.default.fileExists(atPath: descriptor.media.bodyPath)
|
||||||
FileManager.default.fileExists(atPath: descriptor.manifest.bodyPath)
|
|
||||||
else { return false }
|
else { return false }
|
||||||
|
if let manifest = descriptor.manifest,
|
||||||
|
!FileManager.default.fileExists(atPath: manifest.bodyPath)
|
||||||
|
{
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
defaults.set(data, forKey: descriptorPrefix + descriptor.attachmentId)
|
defaults.set(data, forKey: descriptorPrefix + descriptor.attachmentId)
|
||||||
// Both durable transfers are created before either is resumed, so process
|
// Every durable transfer is created before any of them is resumed, so
|
||||||
// death cannot leave a media object with no corresponding manifest task.
|
// process death cannot leave a media object with no corresponding manifest
|
||||||
guard
|
// task.
|
||||||
let mediaTask = makeTask(descriptor.media, attachmentId: descriptor.attachmentId),
|
guard let mediaTask = makeTask(descriptor.media, attachmentId: descriptor.attachmentId)
|
||||||
let manifestTask = makeTask(descriptor.manifest, attachmentId: descriptor.attachmentId)
|
|
||||||
else { return false }
|
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()
|
mediaTask.resume()
|
||||||
manifestTask.resume()
|
manifestTask?.resume()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -121,7 +133,11 @@ final class DirectMediaTransfer: NSObject, URLSessionTaskDelegate, URLSessionDel
|
||||||
let success = error == nil && (200...299).contains(status)
|
let success = error == nil && (200...299).contains(status)
|
||||||
|
|
||||||
if success && role == "media" {
|
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
|
return
|
||||||
}
|
}
|
||||||
if success {
|
if success {
|
||||||
|
|
@ -138,6 +154,10 @@ final class DirectMediaTransfer: NSObject, URLSessionTaskDelegate, URLSessionDel
|
||||||
}
|
}
|
||||||
|
|
||||||
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
|
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
|
DispatchQueue.main.async { [weak self] in
|
||||||
let completion = self?.backgroundCompletion
|
let completion = self?.backgroundCompletion
|
||||||
self?.backgroundCompletion = nil
|
self?.backgroundCompletion = nil
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,10 @@
|
||||||
</dict>
|
</dict>
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>eu.twonly.outbox-flush</string>
|
||||||
|
</array>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>fetch</string>
|
<string>fetch</string>
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,8 @@ enum NativeVideoCodec {
|
||||||
overlayPath: String?,
|
overlayPath: String?,
|
||||||
outputPath: String,
|
outputPath: String,
|
||||||
removeAudio: Bool,
|
removeAudio: Bool,
|
||||||
|
trimStartMs: Int64,
|
||||||
|
trimEndMs: Int64,
|
||||||
onProgress: @escaping (Int) -> Void
|
onProgress: @escaping (Int) -> Void
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
let asset = AVURLAsset(url: URL(fileURLWithPath: inputPath))
|
let asset = AVURLAsset(url: URL(fileURLWithPath: inputPath))
|
||||||
|
|
@ -79,7 +81,11 @@ enum NativeVideoCodec {
|
||||||
)
|
)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
|
let trim = trimRange(for: asset, startMs: trimStartMs, endMs: trimEndMs)
|
||||||
let reader = try AVAssetReader(asset: asset)
|
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)
|
let writer = try AVAssetWriter(outputURL: outputURL, fileType: .mp4)
|
||||||
writer.shouldOptimizeForNetworkUse = true
|
writer.shouldOptimizeForNetworkUse = true
|
||||||
|
|
||||||
|
|
@ -150,15 +156,20 @@ enum NativeVideoCodec {
|
||||||
}
|
}
|
||||||
|
|
||||||
guard reader.startReading(), writer.startWriting() else { return false }
|
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()
|
let group = DispatchGroup()
|
||||||
pump(
|
pump(
|
||||||
input: videoInput,
|
input: videoInput,
|
||||||
output: videoOutput,
|
output: videoOutput,
|
||||||
label: "video",
|
label: "video",
|
||||||
group: group,
|
group: group,
|
||||||
|
startSeconds: startSeconds,
|
||||||
duration: duration,
|
duration: duration,
|
||||||
onProgress: onProgress
|
onProgress: onProgress
|
||||||
)
|
)
|
||||||
|
|
@ -168,6 +179,7 @@ enum NativeVideoCodec {
|
||||||
output: audioOutput,
|
output: audioOutput,
|
||||||
label: "audio",
|
label: "audio",
|
||||||
group: group,
|
group: group,
|
||||||
|
startSeconds: 0,
|
||||||
duration: nil,
|
duration: nil,
|
||||||
onProgress: 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(
|
private static func pump(
|
||||||
input: AVAssetWriterInput,
|
input: AVAssetWriterInput,
|
||||||
output: AVAssetReaderOutput,
|
output: AVAssetReaderOutput,
|
||||||
label: String,
|
label: String,
|
||||||
group: DispatchGroup,
|
group: DispatchGroup,
|
||||||
|
startSeconds: Double,
|
||||||
duration: Double?,
|
duration: Double?,
|
||||||
onProgress: ((Int) -> Void)?
|
onProgress: ((Int) -> Void)?
|
||||||
) {
|
) {
|
||||||
|
|
@ -285,8 +322,11 @@ enum NativeVideoCodec {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if let duration, duration > 0, let onProgress {
|
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))
|
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)
|
input.append(sample)
|
||||||
}
|
}
|
||||||
|
|
@ -301,6 +341,8 @@ func twonlyRenderVideo(
|
||||||
_ overlay: UnsafePointer<CChar>?,
|
_ overlay: UnsafePointer<CChar>?,
|
||||||
_ output: UnsafePointer<CChar>?,
|
_ output: UnsafePointer<CChar>?,
|
||||||
_ removeAudio: Bool,
|
_ removeAudio: Bool,
|
||||||
|
_ trimStartMs: Int64,
|
||||||
|
_ trimEndMs: Int64,
|
||||||
_ mediaId: UnsafePointer<CChar>?,
|
_ mediaId: UnsafePointer<CChar>?,
|
||||||
_ progress: @convention(c) (UnsafePointer<CChar>?, Int32) -> Void
|
_ progress: @convention(c) (UnsafePointer<CChar>?, Int32) -> Void
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
|
|
@ -311,6 +353,8 @@ func twonlyRenderVideo(
|
||||||
overlayPath: overlay.map { String(cString: $0) },
|
overlayPath: overlay.map { String(cString: $0) },
|
||||||
outputPath: String(cString: output),
|
outputPath: String(cString: output),
|
||||||
removeAudio: removeAudio,
|
removeAudio: removeAudio,
|
||||||
|
trimStartMs: trimStartMs,
|
||||||
|
trimEndMs: trimEndMs,
|
||||||
onProgress: { percent in
|
onProgress: { percent in
|
||||||
mediaIdString.withCString { progress($0, Int32(percent)) }
|
mediaIdString.withCString { progress($0, Int32(percent)) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
#import "GeneratedPluginRegistrant.h"
|
#import "GeneratedPluginRegistrant.h"
|
||||||
|
#import <rust_lib_twonly/TwonlyNotifications.h>
|
||||||
|
|
|
||||||
16
lib/app.dart
16
lib/app.dart
|
|
@ -45,8 +45,6 @@ class App extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppState extends State<App> with WidgetsBindingObserver {
|
class _AppState extends State<App> with WidgetsBindingObserver {
|
||||||
bool _wasPaused = false;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
@ -58,9 +56,11 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
super.didChangeAppLifecycleState(state);
|
super.didChangeAppLifecycleState(state);
|
||||||
if (state == AppLifecycleState.resumed) {
|
if (state == AppLifecycleState.resumed) {
|
||||||
if (_wasPaused) {
|
|
||||||
AppState.isAppInBackground = false;
|
AppState.isAppInBackground = false;
|
||||||
twonlyDB.markUpdated();
|
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(
|
unawaited(
|
||||||
rust_api.RustApi.setBackground(inBackground: false),
|
rust_api.RustApi.setBackground(inBackground: false),
|
||||||
);
|
);
|
||||||
|
|
@ -68,13 +68,19 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
||||||
// was suspended, and its Rust change broadcast never reached this
|
// was suspended, and its Rust change broadcast never reached this
|
||||||
// process, so the badge has to be re-read on the way back in.
|
// process, so the badge has to be re-read on the way back in.
|
||||||
unawaited(NativeNotificationService.refreshBadgeCount());
|
unawaited(NativeNotificationService.refreshBadgeCount());
|
||||||
}
|
|
||||||
} else if (state == AppLifecycleState.paused) {
|
} else if (state == AppLifecycleState.paused) {
|
||||||
_wasPaused = true;
|
|
||||||
AppState.isAppInBackground = true;
|
AppState.isAppInBackground = true;
|
||||||
unawaited(
|
unawaited(
|
||||||
rust_api.RustApi.setBackground(inBackground: true),
|
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(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -443,6 +443,11 @@ class RustApi {
|
||||||
username: username,
|
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
|
/// Creates the media row and its content-encryption material and returns
|
||||||
/// the media id the UI addresses every later step by.
|
/// the media id the UI addresses every later step by.
|
||||||
static Future<String> initializeMediaUpload({
|
static Future<String> initializeMediaUpload({
|
||||||
|
|
@ -541,6 +546,13 @@ class RustApi {
|
||||||
.api
|
.api
|
||||||
.crateBridgeApiRustApiPerformPasswordlessRecoveryHeartbeat();
|
.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.
|
/// Deletes temporary media whose messages are finished with it.
|
||||||
static Future<void> purgeMediaTempFolder() =>
|
static Future<void> purgeMediaTempFolder() =>
|
||||||
RustLib.instance.api.crateBridgeApiRustApiPurgeMediaTempFolder();
|
RustLib.instance.api.crateBridgeApiRustApiPurgeMediaTempFolder();
|
||||||
|
|
@ -736,6 +748,18 @@ class RustApi {
|
||||||
requiresAuthentication: requiresAuthentication,
|
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
|
static Future<void> setNetworkAvailable({required bool available}) => RustLib
|
||||||
.instance
|
.instance
|
||||||
.api
|
.api
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
|
||||||
String get codegenVersion => '2.12.0';
|
String get codegenVersion => '2.12.0';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get rustContentHash => -1370816897;
|
int get rustContentHash => 1963960341;
|
||||||
|
|
||||||
static const kDefaultExternalLibraryLoaderConfig =
|
static const kDefaultExternalLibraryLoaderConfig =
|
||||||
ExternalLibraryLoaderConfig(
|
ExternalLibraryLoaderConfig(
|
||||||
|
|
@ -285,6 +285,8 @@ abstract class RustLibApi extends BaseApi {
|
||||||
required String username,
|
required String username,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Future<void> crateBridgeApiRustApiHandOutboxToOs();
|
||||||
|
|
||||||
Future<String> crateBridgeApiRustApiInitializeMediaUpload({
|
Future<String> crateBridgeApiRustApiInitializeMediaUpload({
|
||||||
required String mediaType,
|
required String mediaType,
|
||||||
PlatformInt64? displayLimitInMilliseconds,
|
PlatformInt64? displayLimitInMilliseconds,
|
||||||
|
|
@ -339,6 +341,8 @@ abstract class RustLibApi extends BaseApi {
|
||||||
|
|
||||||
Future<void> crateBridgeApiRustApiPerformPasswordlessRecoveryHeartbeat();
|
Future<void> crateBridgeApiRustApiPerformPasswordlessRecoveryHeartbeat();
|
||||||
|
|
||||||
|
Future<void> crateBridgeApiRustApiPrerenderMedia({required String mediaId});
|
||||||
|
|
||||||
Future<void> crateBridgeApiRustApiPurgeMediaTempFolder();
|
Future<void> crateBridgeApiRustApiPurgeMediaTempFolder();
|
||||||
|
|
||||||
Future<PlatformInt64> crateBridgeApiRustApiRegister({
|
Future<PlatformInt64> crateBridgeApiRustApiRegister({
|
||||||
|
|
@ -457,6 +461,12 @@ abstract class RustLibApi extends BaseApi {
|
||||||
required bool requiresAuthentication,
|
required bool requiresAuthentication,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Future<void> crateBridgeApiRustApiSetMediaTrim({
|
||||||
|
required String mediaId,
|
||||||
|
PlatformInt64? trimStartMs,
|
||||||
|
PlatformInt64? trimEndMs,
|
||||||
|
});
|
||||||
|
|
||||||
Future<void> crateBridgeApiRustApiSetNetworkAvailable({
|
Future<void> crateBridgeApiRustApiSetNetworkAvailable({
|
||||||
required bool available,
|
required bool available,
|
||||||
});
|
});
|
||||||
|
|
@ -2536,6 +2546,36 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
argNames: ["username"],
|
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
|
@override
|
||||||
Future<String> crateBridgeApiRustApiInitializeMediaUpload({
|
Future<String> crateBridgeApiRustApiInitializeMediaUpload({
|
||||||
required String mediaType,
|
required String mediaType,
|
||||||
|
|
@ -2555,7 +2595,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 57,
|
funcId: 58,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -2592,7 +2632,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 58,
|
funcId: 59,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -2628,7 +2668,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 59,
|
funcId: 60,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -2663,7 +2703,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 60,
|
funcId: 61,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -2700,7 +2740,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 61,
|
funcId: 62,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -2737,7 +2777,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 62,
|
funcId: 63,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -2767,7 +2807,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 63,
|
funcId: 64,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -2800,7 +2840,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 64,
|
funcId: 65,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -2835,7 +2875,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 65,
|
funcId: 66,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -2865,7 +2905,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 66,
|
funcId: 67,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -2900,7 +2940,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 67,
|
funcId: 68,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -2930,7 +2970,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 68,
|
funcId: 69,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -2953,6 +2993,37 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
argNames: [],
|
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
|
@override
|
||||||
Future<void> crateBridgeApiRustApiPurgeMediaTempFolder() {
|
Future<void> crateBridgeApiRustApiPurgeMediaTempFolder() {
|
||||||
return handler.executeNormal(
|
return handler.executeNormal(
|
||||||
|
|
@ -2962,7 +3033,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 69,
|
funcId: 71,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -3001,7 +3072,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 70,
|
funcId: 72,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -3040,7 +3111,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 71,
|
funcId: 73,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -3082,7 +3153,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 72,
|
funcId: 74,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -3113,7 +3184,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 73,
|
funcId: 75,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -3146,7 +3217,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 74,
|
funcId: 76,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -3179,7 +3250,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 75,
|
funcId: 77,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -3214,7 +3285,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 76,
|
funcId: 78,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -3247,7 +3318,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 77,
|
funcId: 79,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -3280,7 +3351,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 78,
|
funcId: 80,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -3313,7 +3384,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 79,
|
funcId: 81,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -3350,7 +3421,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 80,
|
funcId: 82,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -3380,7 +3451,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 81,
|
funcId: 83,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -3410,7 +3481,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 82,
|
funcId: 84,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -3440,7 +3511,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 83,
|
funcId: 85,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -3473,7 +3544,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 84,
|
funcId: 86,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -3504,7 +3575,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 85,
|
funcId: 87,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -3537,7 +3608,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 86,
|
funcId: 88,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -3583,7 +3654,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 87,
|
funcId: 89,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -3639,7 +3710,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 88,
|
funcId: 90,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -3685,7 +3756,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 89,
|
funcId: 91,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -3718,7 +3789,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 90,
|
funcId: 92,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -3753,7 +3824,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 91,
|
funcId: 93,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -3788,7 +3859,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 92,
|
funcId: 94,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -3821,7 +3892,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 93,
|
funcId: 95,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -3852,7 +3923,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 94,
|
funcId: 96,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -3890,7 +3961,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 95,
|
funcId: 97,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -3925,7 +3996,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 96,
|
funcId: 98,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -3948,6 +4019,43 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
argNames: ["mediaId", "requiresAuthentication"],
|
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
|
@override
|
||||||
Future<void> crateBridgeApiRustApiSetNetworkAvailable({
|
Future<void> crateBridgeApiRustApiSetNetworkAvailable({
|
||||||
required bool available,
|
required bool available,
|
||||||
|
|
@ -3960,7 +4068,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 97,
|
funcId: 100,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -3991,7 +4099,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 98,
|
funcId: 101,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -4026,7 +4134,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 99,
|
funcId: 102,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -4059,7 +4167,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 100,
|
funcId: 103,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -4094,7 +4202,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 101,
|
funcId: 104,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -4125,7 +4233,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 102,
|
funcId: 105,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -4162,7 +4270,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 103,
|
funcId: 106,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -4214,7 +4322,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 104,
|
funcId: 107,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -4267,7 +4375,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 105,
|
funcId: 108,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -4307,7 +4415,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 106,
|
funcId: 109,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -4340,7 +4448,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 107,
|
funcId: 110,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -4373,7 +4481,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 108,
|
funcId: 111,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -4410,7 +4518,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 109,
|
funcId: 112,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -4442,7 +4550,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 110,
|
funcId: 113,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -4475,7 +4583,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 111,
|
funcId: 114,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -4510,7 +4618,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 112,
|
funcId: 115,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -4542,7 +4650,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 113,
|
funcId: 116,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -4580,7 +4688,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 114,
|
funcId: 117,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -4613,7 +4721,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 115,
|
funcId: 118,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -4651,7 +4759,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 116,
|
funcId: 119,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -4688,7 +4796,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 117,
|
funcId: 120,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -4725,7 +4833,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 118,
|
funcId: 121,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -4763,7 +4871,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 119,
|
funcId: 122,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -4801,7 +4909,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 120,
|
funcId: 123,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -4834,7 +4942,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 121,
|
funcId: 124,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -4866,7 +4974,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 122,
|
funcId: 125,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -4901,7 +5009,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 123,
|
funcId: 126,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -4938,7 +5046,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 124,
|
funcId: 127,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -4971,7 +5079,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 125,
|
funcId: 128,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -5003,7 +5111,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 126,
|
funcId: 129,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -5038,7 +5146,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 127,
|
funcId: 130,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -5077,7 +5185,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 128,
|
funcId: 131,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -5114,7 +5222,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 129,
|
funcId: 132,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -5144,7 +5252,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 130,
|
funcId: 133,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -5176,7 +5284,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 131,
|
funcId: 134,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -5211,7 +5319,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 132,
|
funcId: 135,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -5243,7 +5351,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 133,
|
funcId: 136,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -5281,7 +5389,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 134,
|
funcId: 137,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -5320,7 +5428,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 135,
|
funcId: 138,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -5355,7 +5463,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 136,
|
funcId: 139,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -5390,7 +5498,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 137,
|
funcId: 140,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -5425,7 +5533,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 138,
|
funcId: 141,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -5458,7 +5566,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
return pdeCallFfi(
|
return pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 139,
|
funcId: 142,
|
||||||
)!;
|
)!;
|
||||||
},
|
},
|
||||||
codec: SseCodec(
|
codec: SseCodec(
|
||||||
|
|
@ -5498,7 +5606,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 140,
|
funcId: 143,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -5543,7 +5651,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 141,
|
funcId: 144,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -5573,7 +5681,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 142,
|
funcId: 145,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -5606,7 +5714,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 143,
|
funcId: 146,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -5641,7 +5749,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 144,
|
funcId: 147,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -5680,7 +5788,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
return pdeCallFfi(
|
return pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 145,
|
funcId: 148,
|
||||||
)!;
|
)!;
|
||||||
},
|
},
|
||||||
codec: SseCodec(
|
codec: SseCodec(
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,6 @@ class AppEnvironment {
|
||||||
|
|
||||||
class AppState {
|
class AppState {
|
||||||
static bool isAppInBackground = true;
|
static bool isAppInBackground = true;
|
||||||
static bool isInBackgroundTask = false;
|
|
||||||
static bool allowErrorTrackingViaSentry = false;
|
static bool allowErrorTrackingViaSentry = false;
|
||||||
static bool gotMessageFromServer = false;
|
static bool gotMessageFromServer = false;
|
||||||
static int latestAppVersionId = 119;
|
static int latestAppVersionId = 119;
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,13 @@ class MediaFiles extends Table {
|
||||||
IntColumn get displayLimitInMilliseconds => integer().nullable()();
|
IntColumn get displayLimitInMilliseconds => integer().nullable()();
|
||||||
BoolColumn get removeAudio => boolean().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 downloadToken => blob().nullable()();
|
||||||
BlobColumn get encryptionKey => blob().nullable()();
|
BlobColumn get encryptionKey => blob().nullable()();
|
||||||
BlobColumn get encryptionMac => blob().nullable()();
|
BlobColumn get encryptionMac => blob().nullable()();
|
||||||
|
|
|
||||||
|
|
@ -3297,6 +3297,28 @@ class $MediaFilesTable extends MediaFiles
|
||||||
'CHECK ("remove_audio" IN (0, 1))',
|
'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(
|
static const VerificationMeta _downloadTokenMeta = const VerificationMeta(
|
||||||
'downloadToken',
|
'downloadToken',
|
||||||
);
|
);
|
||||||
|
|
@ -3423,6 +3445,8 @@ class $MediaFilesTable extends MediaFiles
|
||||||
reuploadRequestedBy,
|
reuploadRequestedBy,
|
||||||
displayLimitInMilliseconds,
|
displayLimitInMilliseconds,
|
||||||
removeAudio,
|
removeAudio,
|
||||||
|
trimStartMs,
|
||||||
|
trimEndMs,
|
||||||
downloadToken,
|
downloadToken,
|
||||||
encryptionKey,
|
encryptionKey,
|
||||||
encryptionMac,
|
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')) {
|
if (data.containsKey('download_token')) {
|
||||||
context.handle(
|
context.handle(
|
||||||
_downloadTokenMeta,
|
_downloadTokenMeta,
|
||||||
|
|
@ -3683,6 +3722,14 @@ class $MediaFilesTable extends MediaFiles
|
||||||
DriftSqlType.bool,
|
DriftSqlType.bool,
|
||||||
data['${effectivePrefix}remove_audio'],
|
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(
|
downloadToken: attachedDatabase.typeMapping.read(
|
||||||
DriftSqlType.blob,
|
DriftSqlType.blob,
|
||||||
data['${effectivePrefix}download_token'],
|
data['${effectivePrefix}download_token'],
|
||||||
|
|
@ -3765,6 +3812,13 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
||||||
final List<int>? reuploadRequestedBy;
|
final List<int>? reuploadRequestedBy;
|
||||||
final int? displayLimitInMilliseconds;
|
final int? displayLimitInMilliseconds;
|
||||||
final bool? removeAudio;
|
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? downloadToken;
|
||||||
final Uint8List? encryptionKey;
|
final Uint8List? encryptionKey;
|
||||||
final Uint8List? encryptionMac;
|
final Uint8List? encryptionMac;
|
||||||
|
|
@ -3790,6 +3844,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
||||||
this.reuploadRequestedBy,
|
this.reuploadRequestedBy,
|
||||||
this.displayLimitInMilliseconds,
|
this.displayLimitInMilliseconds,
|
||||||
this.removeAudio,
|
this.removeAudio,
|
||||||
|
this.trimStartMs,
|
||||||
|
this.trimEndMs,
|
||||||
this.downloadToken,
|
this.downloadToken,
|
||||||
this.encryptionKey,
|
this.encryptionKey,
|
||||||
this.encryptionMac,
|
this.encryptionMac,
|
||||||
|
|
@ -3850,6 +3906,12 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
||||||
if (!nullToAbsent || removeAudio != null) {
|
if (!nullToAbsent || removeAudio != null) {
|
||||||
map['remove_audio'] = Variable<bool>(removeAudio);
|
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) {
|
if (!nullToAbsent || downloadToken != null) {
|
||||||
map['download_token'] = Variable<Uint8List>(downloadToken);
|
map['download_token'] = Variable<Uint8List>(downloadToken);
|
||||||
}
|
}
|
||||||
|
|
@ -3908,6 +3970,12 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
||||||
removeAudio: removeAudio == null && nullToAbsent
|
removeAudio: removeAudio == null && nullToAbsent
|
||||||
? const Value.absent()
|
? const Value.absent()
|
||||||
: Value(removeAudio),
|
: Value(removeAudio),
|
||||||
|
trimStartMs: trimStartMs == null && nullToAbsent
|
||||||
|
? const Value.absent()
|
||||||
|
: Value(trimStartMs),
|
||||||
|
trimEndMs: trimEndMs == null && nullToAbsent
|
||||||
|
? const Value.absent()
|
||||||
|
: Value(trimEndMs),
|
||||||
downloadToken: downloadToken == null && nullToAbsent
|
downloadToken: downloadToken == null && nullToAbsent
|
||||||
? const Value.absent()
|
? const Value.absent()
|
||||||
: Value(downloadToken),
|
: Value(downloadToken),
|
||||||
|
|
@ -3971,6 +4039,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
||||||
json['displayLimitInMilliseconds'],
|
json['displayLimitInMilliseconds'],
|
||||||
),
|
),
|
||||||
removeAudio: serializer.fromJson<bool?>(json['removeAudio']),
|
removeAudio: serializer.fromJson<bool?>(json['removeAudio']),
|
||||||
|
trimStartMs: serializer.fromJson<int?>(json['trimStartMs']),
|
||||||
|
trimEndMs: serializer.fromJson<int?>(json['trimEndMs']),
|
||||||
downloadToken: serializer.fromJson<Uint8List?>(json['downloadToken']),
|
downloadToken: serializer.fromJson<Uint8List?>(json['downloadToken']),
|
||||||
encryptionKey: serializer.fromJson<Uint8List?>(json['encryptionKey']),
|
encryptionKey: serializer.fromJson<Uint8List?>(json['encryptionKey']),
|
||||||
encryptionMac: serializer.fromJson<Uint8List?>(json['encryptionMac']),
|
encryptionMac: serializer.fromJson<Uint8List?>(json['encryptionMac']),
|
||||||
|
|
@ -4011,6 +4081,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
||||||
displayLimitInMilliseconds,
|
displayLimitInMilliseconds,
|
||||||
),
|
),
|
||||||
'removeAudio': serializer.toJson<bool?>(removeAudio),
|
'removeAudio': serializer.toJson<bool?>(removeAudio),
|
||||||
|
'trimStartMs': serializer.toJson<int?>(trimStartMs),
|
||||||
|
'trimEndMs': serializer.toJson<int?>(trimEndMs),
|
||||||
'downloadToken': serializer.toJson<Uint8List?>(downloadToken),
|
'downloadToken': serializer.toJson<Uint8List?>(downloadToken),
|
||||||
'encryptionKey': serializer.toJson<Uint8List?>(encryptionKey),
|
'encryptionKey': serializer.toJson<Uint8List?>(encryptionKey),
|
||||||
'encryptionMac': serializer.toJson<Uint8List?>(encryptionMac),
|
'encryptionMac': serializer.toJson<Uint8List?>(encryptionMac),
|
||||||
|
|
@ -4039,6 +4111,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
||||||
Value<List<int>?> reuploadRequestedBy = const Value.absent(),
|
Value<List<int>?> reuploadRequestedBy = const Value.absent(),
|
||||||
Value<int?> displayLimitInMilliseconds = const Value.absent(),
|
Value<int?> displayLimitInMilliseconds = const Value.absent(),
|
||||||
Value<bool?> removeAudio = 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?> downloadToken = const Value.absent(),
|
||||||
Value<Uint8List?> encryptionKey = const Value.absent(),
|
Value<Uint8List?> encryptionKey = const Value.absent(),
|
||||||
Value<Uint8List?> encryptionMac = const Value.absent(),
|
Value<Uint8List?> encryptionMac = const Value.absent(),
|
||||||
|
|
@ -4073,6 +4147,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
||||||
? displayLimitInMilliseconds.value
|
? displayLimitInMilliseconds.value
|
||||||
: this.displayLimitInMilliseconds,
|
: this.displayLimitInMilliseconds,
|
||||||
removeAudio: removeAudio.present ? removeAudio.value : this.removeAudio,
|
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: downloadToken.present
|
||||||
? downloadToken.value
|
? downloadToken.value
|
||||||
: this.downloadToken,
|
: this.downloadToken,
|
||||||
|
|
@ -4134,6 +4210,10 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
||||||
removeAudio: data.removeAudio.present
|
removeAudio: data.removeAudio.present
|
||||||
? data.removeAudio.value
|
? data.removeAudio.value
|
||||||
: this.removeAudio,
|
: this.removeAudio,
|
||||||
|
trimStartMs: data.trimStartMs.present
|
||||||
|
? data.trimStartMs.value
|
||||||
|
: this.trimStartMs,
|
||||||
|
trimEndMs: data.trimEndMs.present ? data.trimEndMs.value : this.trimEndMs,
|
||||||
downloadToken: data.downloadToken.present
|
downloadToken: data.downloadToken.present
|
||||||
? data.downloadToken.value
|
? data.downloadToken.value
|
||||||
: this.downloadToken,
|
: this.downloadToken,
|
||||||
|
|
@ -4180,6 +4260,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
||||||
..write('reuploadRequestedBy: $reuploadRequestedBy, ')
|
..write('reuploadRequestedBy: $reuploadRequestedBy, ')
|
||||||
..write('displayLimitInMilliseconds: $displayLimitInMilliseconds, ')
|
..write('displayLimitInMilliseconds: $displayLimitInMilliseconds, ')
|
||||||
..write('removeAudio: $removeAudio, ')
|
..write('removeAudio: $removeAudio, ')
|
||||||
|
..write('trimStartMs: $trimStartMs, ')
|
||||||
|
..write('trimEndMs: $trimEndMs, ')
|
||||||
..write('downloadToken: $downloadToken, ')
|
..write('downloadToken: $downloadToken, ')
|
||||||
..write('encryptionKey: $encryptionKey, ')
|
..write('encryptionKey: $encryptionKey, ')
|
||||||
..write('encryptionMac: $encryptionMac, ')
|
..write('encryptionMac: $encryptionMac, ')
|
||||||
|
|
@ -4210,6 +4292,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
||||||
reuploadRequestedBy,
|
reuploadRequestedBy,
|
||||||
displayLimitInMilliseconds,
|
displayLimitInMilliseconds,
|
||||||
removeAudio,
|
removeAudio,
|
||||||
|
trimStartMs,
|
||||||
|
trimEndMs,
|
||||||
$driftBlobEquality.hash(downloadToken),
|
$driftBlobEquality.hash(downloadToken),
|
||||||
$driftBlobEquality.hash(encryptionKey),
|
$driftBlobEquality.hash(encryptionKey),
|
||||||
$driftBlobEquality.hash(encryptionMac),
|
$driftBlobEquality.hash(encryptionMac),
|
||||||
|
|
@ -4239,6 +4323,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
||||||
other.reuploadRequestedBy == this.reuploadRequestedBy &&
|
other.reuploadRequestedBy == this.reuploadRequestedBy &&
|
||||||
other.displayLimitInMilliseconds == this.displayLimitInMilliseconds &&
|
other.displayLimitInMilliseconds == this.displayLimitInMilliseconds &&
|
||||||
other.removeAudio == this.removeAudio &&
|
other.removeAudio == this.removeAudio &&
|
||||||
|
other.trimStartMs == this.trimStartMs &&
|
||||||
|
other.trimEndMs == this.trimEndMs &&
|
||||||
$driftBlobEquality.equals(other.downloadToken, this.downloadToken) &&
|
$driftBlobEquality.equals(other.downloadToken, this.downloadToken) &&
|
||||||
$driftBlobEquality.equals(other.encryptionKey, this.encryptionKey) &&
|
$driftBlobEquality.equals(other.encryptionKey, this.encryptionKey) &&
|
||||||
$driftBlobEquality.equals(other.encryptionMac, this.encryptionMac) &&
|
$driftBlobEquality.equals(other.encryptionMac, this.encryptionMac) &&
|
||||||
|
|
@ -4272,6 +4358,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
||||||
final Value<List<int>?> reuploadRequestedBy;
|
final Value<List<int>?> reuploadRequestedBy;
|
||||||
final Value<int?> displayLimitInMilliseconds;
|
final Value<int?> displayLimitInMilliseconds;
|
||||||
final Value<bool?> removeAudio;
|
final Value<bool?> removeAudio;
|
||||||
|
final Value<int?> trimStartMs;
|
||||||
|
final Value<int?> trimEndMs;
|
||||||
final Value<Uint8List?> downloadToken;
|
final Value<Uint8List?> downloadToken;
|
||||||
final Value<Uint8List?> encryptionKey;
|
final Value<Uint8List?> encryptionKey;
|
||||||
final Value<Uint8List?> encryptionMac;
|
final Value<Uint8List?> encryptionMac;
|
||||||
|
|
@ -4298,6 +4386,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
||||||
this.reuploadRequestedBy = const Value.absent(),
|
this.reuploadRequestedBy = const Value.absent(),
|
||||||
this.displayLimitInMilliseconds = const Value.absent(),
|
this.displayLimitInMilliseconds = const Value.absent(),
|
||||||
this.removeAudio = const Value.absent(),
|
this.removeAudio = const Value.absent(),
|
||||||
|
this.trimStartMs = const Value.absent(),
|
||||||
|
this.trimEndMs = const Value.absent(),
|
||||||
this.downloadToken = const Value.absent(),
|
this.downloadToken = const Value.absent(),
|
||||||
this.encryptionKey = const Value.absent(),
|
this.encryptionKey = const Value.absent(),
|
||||||
this.encryptionMac = const Value.absent(),
|
this.encryptionMac = const Value.absent(),
|
||||||
|
|
@ -4325,6 +4415,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
||||||
this.reuploadRequestedBy = const Value.absent(),
|
this.reuploadRequestedBy = const Value.absent(),
|
||||||
this.displayLimitInMilliseconds = const Value.absent(),
|
this.displayLimitInMilliseconds = const Value.absent(),
|
||||||
this.removeAudio = const Value.absent(),
|
this.removeAudio = const Value.absent(),
|
||||||
|
this.trimStartMs = const Value.absent(),
|
||||||
|
this.trimEndMs = const Value.absent(),
|
||||||
this.downloadToken = const Value.absent(),
|
this.downloadToken = const Value.absent(),
|
||||||
this.encryptionKey = const Value.absent(),
|
this.encryptionKey = const Value.absent(),
|
||||||
this.encryptionMac = const Value.absent(),
|
this.encryptionMac = const Value.absent(),
|
||||||
|
|
@ -4353,6 +4445,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
||||||
Expression<String>? reuploadRequestedBy,
|
Expression<String>? reuploadRequestedBy,
|
||||||
Expression<int>? displayLimitInMilliseconds,
|
Expression<int>? displayLimitInMilliseconds,
|
||||||
Expression<bool>? removeAudio,
|
Expression<bool>? removeAudio,
|
||||||
|
Expression<int>? trimStartMs,
|
||||||
|
Expression<int>? trimEndMs,
|
||||||
Expression<Uint8List>? downloadToken,
|
Expression<Uint8List>? downloadToken,
|
||||||
Expression<Uint8List>? encryptionKey,
|
Expression<Uint8List>? encryptionKey,
|
||||||
Expression<Uint8List>? encryptionMac,
|
Expression<Uint8List>? encryptionMac,
|
||||||
|
|
@ -4384,6 +4478,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
||||||
if (displayLimitInMilliseconds != null)
|
if (displayLimitInMilliseconds != null)
|
||||||
'display_limit_in_milliseconds': displayLimitInMilliseconds,
|
'display_limit_in_milliseconds': displayLimitInMilliseconds,
|
||||||
if (removeAudio != null) 'remove_audio': removeAudio,
|
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 (downloadToken != null) 'download_token': downloadToken,
|
||||||
if (encryptionKey != null) 'encryption_key': encryptionKey,
|
if (encryptionKey != null) 'encryption_key': encryptionKey,
|
||||||
if (encryptionMac != null) 'encryption_mac': encryptionMac,
|
if (encryptionMac != null) 'encryption_mac': encryptionMac,
|
||||||
|
|
@ -4413,6 +4509,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
||||||
Value<List<int>?>? reuploadRequestedBy,
|
Value<List<int>?>? reuploadRequestedBy,
|
||||||
Value<int?>? displayLimitInMilliseconds,
|
Value<int?>? displayLimitInMilliseconds,
|
||||||
Value<bool?>? removeAudio,
|
Value<bool?>? removeAudio,
|
||||||
|
Value<int?>? trimStartMs,
|
||||||
|
Value<int?>? trimEndMs,
|
||||||
Value<Uint8List?>? downloadToken,
|
Value<Uint8List?>? downloadToken,
|
||||||
Value<Uint8List?>? encryptionKey,
|
Value<Uint8List?>? encryptionKey,
|
||||||
Value<Uint8List?>? encryptionMac,
|
Value<Uint8List?>? encryptionMac,
|
||||||
|
|
@ -4443,6 +4541,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
||||||
displayLimitInMilliseconds:
|
displayLimitInMilliseconds:
|
||||||
displayLimitInMilliseconds ?? this.displayLimitInMilliseconds,
|
displayLimitInMilliseconds ?? this.displayLimitInMilliseconds,
|
||||||
removeAudio: removeAudio ?? this.removeAudio,
|
removeAudio: removeAudio ?? this.removeAudio,
|
||||||
|
trimStartMs: trimStartMs ?? this.trimStartMs,
|
||||||
|
trimEndMs: trimEndMs ?? this.trimEndMs,
|
||||||
downloadToken: downloadToken ?? this.downloadToken,
|
downloadToken: downloadToken ?? this.downloadToken,
|
||||||
encryptionKey: encryptionKey ?? this.encryptionKey,
|
encryptionKey: encryptionKey ?? this.encryptionKey,
|
||||||
encryptionMac: encryptionMac ?? this.encryptionMac,
|
encryptionMac: encryptionMac ?? this.encryptionMac,
|
||||||
|
|
@ -4522,6 +4622,12 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
||||||
if (removeAudio.present) {
|
if (removeAudio.present) {
|
||||||
map['remove_audio'] = Variable<bool>(removeAudio.value);
|
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) {
|
if (downloadToken.present) {
|
||||||
map['download_token'] = Variable<Uint8List>(downloadToken.value);
|
map['download_token'] = Variable<Uint8List>(downloadToken.value);
|
||||||
}
|
}
|
||||||
|
|
@ -4573,6 +4679,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
||||||
..write('reuploadRequestedBy: $reuploadRequestedBy, ')
|
..write('reuploadRequestedBy: $reuploadRequestedBy, ')
|
||||||
..write('displayLimitInMilliseconds: $displayLimitInMilliseconds, ')
|
..write('displayLimitInMilliseconds: $displayLimitInMilliseconds, ')
|
||||||
..write('removeAudio: $removeAudio, ')
|
..write('removeAudio: $removeAudio, ')
|
||||||
|
..write('trimStartMs: $trimStartMs, ')
|
||||||
|
..write('trimEndMs: $trimEndMs, ')
|
||||||
..write('downloadToken: $downloadToken, ')
|
..write('downloadToken: $downloadToken, ')
|
||||||
..write('encryptionKey: $encryptionKey, ')
|
..write('encryptionKey: $encryptionKey, ')
|
||||||
..write('encryptionMac: $encryptionMac, ')
|
..write('encryptionMac: $encryptionMac, ')
|
||||||
|
|
@ -15510,6 +15618,8 @@ typedef $$MediaFilesTableCreateCompanionBuilder =
|
||||||
Value<List<int>?> reuploadRequestedBy,
|
Value<List<int>?> reuploadRequestedBy,
|
||||||
Value<int?> displayLimitInMilliseconds,
|
Value<int?> displayLimitInMilliseconds,
|
||||||
Value<bool?> removeAudio,
|
Value<bool?> removeAudio,
|
||||||
|
Value<int?> trimStartMs,
|
||||||
|
Value<int?> trimEndMs,
|
||||||
Value<Uint8List?> downloadToken,
|
Value<Uint8List?> downloadToken,
|
||||||
Value<Uint8List?> encryptionKey,
|
Value<Uint8List?> encryptionKey,
|
||||||
Value<Uint8List?> encryptionMac,
|
Value<Uint8List?> encryptionMac,
|
||||||
|
|
@ -15538,6 +15648,8 @@ typedef $$MediaFilesTableUpdateCompanionBuilder =
|
||||||
Value<List<int>?> reuploadRequestedBy,
|
Value<List<int>?> reuploadRequestedBy,
|
||||||
Value<int?> displayLimitInMilliseconds,
|
Value<int?> displayLimitInMilliseconds,
|
||||||
Value<bool?> removeAudio,
|
Value<bool?> removeAudio,
|
||||||
|
Value<int?> trimStartMs,
|
||||||
|
Value<int?> trimEndMs,
|
||||||
Value<Uint8List?> downloadToken,
|
Value<Uint8List?> downloadToken,
|
||||||
Value<Uint8List?> encryptionKey,
|
Value<Uint8List?> encryptionKey,
|
||||||
Value<Uint8List?> encryptionMac,
|
Value<Uint8List?> encryptionMac,
|
||||||
|
|
@ -15662,6 +15774,16 @@ class $$MediaFilesTableFilterComposer
|
||||||
builder: (column) => ColumnFilters(column),
|
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(
|
ColumnFilters<Uint8List> get downloadToken => $composableBuilder(
|
||||||
column: $table.downloadToken,
|
column: $table.downloadToken,
|
||||||
builder: (column) => ColumnFilters(column),
|
builder: (column) => ColumnFilters(column),
|
||||||
|
|
@ -15817,6 +15939,16 @@ class $$MediaFilesTableOrderingComposer
|
||||||
builder: (column) => ColumnOrderings(column),
|
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(
|
ColumnOrderings<Uint8List> get downloadToken => $composableBuilder(
|
||||||
column: $table.downloadToken,
|
column: $table.downloadToken,
|
||||||
builder: (column) => ColumnOrderings(column),
|
builder: (column) => ColumnOrderings(column),
|
||||||
|
|
@ -15943,6 +16075,14 @@ class $$MediaFilesTableAnnotationComposer
|
||||||
builder: (column) => column,
|
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(
|
GeneratedColumn<Uint8List> get downloadToken => $composableBuilder(
|
||||||
column: $table.downloadToken,
|
column: $table.downloadToken,
|
||||||
builder: (column) => column,
|
builder: (column) => column,
|
||||||
|
|
@ -16055,6 +16195,8 @@ class $$MediaFilesTableTableManager
|
||||||
Value<List<int>?> reuploadRequestedBy = const Value.absent(),
|
Value<List<int>?> reuploadRequestedBy = const Value.absent(),
|
||||||
Value<int?> displayLimitInMilliseconds = const Value.absent(),
|
Value<int?> displayLimitInMilliseconds = const Value.absent(),
|
||||||
Value<bool?> removeAudio = 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?> downloadToken = const Value.absent(),
|
||||||
Value<Uint8List?> encryptionKey = const Value.absent(),
|
Value<Uint8List?> encryptionKey = const Value.absent(),
|
||||||
Value<Uint8List?> encryptionMac = const Value.absent(),
|
Value<Uint8List?> encryptionMac = const Value.absent(),
|
||||||
|
|
@ -16081,6 +16223,8 @@ class $$MediaFilesTableTableManager
|
||||||
reuploadRequestedBy: reuploadRequestedBy,
|
reuploadRequestedBy: reuploadRequestedBy,
|
||||||
displayLimitInMilliseconds: displayLimitInMilliseconds,
|
displayLimitInMilliseconds: displayLimitInMilliseconds,
|
||||||
removeAudio: removeAudio,
|
removeAudio: removeAudio,
|
||||||
|
trimStartMs: trimStartMs,
|
||||||
|
trimEndMs: trimEndMs,
|
||||||
downloadToken: downloadToken,
|
downloadToken: downloadToken,
|
||||||
encryptionKey: encryptionKey,
|
encryptionKey: encryptionKey,
|
||||||
encryptionMac: encryptionMac,
|
encryptionMac: encryptionMac,
|
||||||
|
|
@ -16109,6 +16253,8 @@ class $$MediaFilesTableTableManager
|
||||||
Value<List<int>?> reuploadRequestedBy = const Value.absent(),
|
Value<List<int>?> reuploadRequestedBy = const Value.absent(),
|
||||||
Value<int?> displayLimitInMilliseconds = const Value.absent(),
|
Value<int?> displayLimitInMilliseconds = const Value.absent(),
|
||||||
Value<bool?> removeAudio = 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?> downloadToken = const Value.absent(),
|
||||||
Value<Uint8List?> encryptionKey = const Value.absent(),
|
Value<Uint8List?> encryptionKey = const Value.absent(),
|
||||||
Value<Uint8List?> encryptionMac = const Value.absent(),
|
Value<Uint8List?> encryptionMac = const Value.absent(),
|
||||||
|
|
@ -16135,6 +16281,8 @@ class $$MediaFilesTableTableManager
|
||||||
reuploadRequestedBy: reuploadRequestedBy,
|
reuploadRequestedBy: reuploadRequestedBy,
|
||||||
displayLimitInMilliseconds: displayLimitInMilliseconds,
|
displayLimitInMilliseconds: displayLimitInMilliseconds,
|
||||||
removeAudio: removeAudio,
|
removeAudio: removeAudio,
|
||||||
|
trimStartMs: trimStartMs,
|
||||||
|
trimEndMs: trimEndMs,
|
||||||
downloadToken: downloadToken,
|
downloadToken: downloadToken,
|
||||||
encryptionKey: encryptionKey,
|
encryptionKey: encryptionKey,
|
||||||
encryptionMac: encryptionMac,
|
encryptionMac: encryptionMac,
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
Subproject commit 17d101801f02cfeaa2a175083111a59fa6d6822c
|
Subproject commit 66a65b7787ae6c66b8fefbb8d016494de0355ef8
|
||||||
|
|
@ -9,16 +9,23 @@ class CustomChangeProvider with ChangeNotifier, DiagnosticableTreeMixin {
|
||||||
unawaited(_loadConnectionState());
|
unawaited(_loadConnectionState());
|
||||||
_connSub = apiService.events
|
_connSub = apiService.events
|
||||||
.where((event) => event.kind == ApiEventKind.connectionStateChanged)
|
.where((event) => event.kind == ApiEventKind.connectionStateChanged)
|
||||||
.listen(
|
.listen(_handleConnectionState);
|
||||||
(event) => updateConnectionState(
|
|
||||||
event.state == ApiConnectionState.authenticated,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
late bool _isConnected;
|
late bool _isConnected;
|
||||||
late StreamSubscription<ApiEvent> _connSub;
|
late StreamSubscription<ApiEvent> _connSub;
|
||||||
bool get isConnected => _isConnected;
|
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 {
|
Future<void> _loadConnectionState() async {
|
||||||
final state = await RustApi.connectionState();
|
final state = await RustApi.connectionState();
|
||||||
await updateConnectionState(state == ApiConnectionState.authenticated);
|
await updateConnectionState(state == ApiConnectionState.authenticated);
|
||||||
|
|
|
||||||
|
|
@ -49,9 +49,7 @@ class ApiService {
|
||||||
Future<void> onAuthenticated() async {
|
Future<void> onAuthenticated() async {
|
||||||
await FcmNotificationService.initFCMAfterAuthenticated();
|
await FcmNotificationService.initFCMAfterAuthenticated();
|
||||||
|
|
||||||
if (AppState.isInBackgroundTask) {
|
if (!AppState.isAppInBackground) {
|
||||||
await rust_api.RustApi.reuploadPendingMedia();
|
|
||||||
} else if (!AppState.isAppInBackground) {
|
|
||||||
unawaitedRustCall(
|
unawaitedRustCall(
|
||||||
rust_api.RustApi.reuploadPendingMedia(),
|
rust_api.RustApi.reuploadPendingMedia(),
|
||||||
'reuploadPendingMedia',
|
'reuploadPendingMedia',
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,27 @@ class MediaFileService {
|
||||||
|
|
||||||
bool get removeAudio => mediaFile.removeAudio ?? false;
|
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 {
|
Future<void> toggleRemoveAudio() async {
|
||||||
await RustApi.toggleMediaRemoveAudio(mediaId: mediaFile.mediaId);
|
await RustApi.toggleMediaRemoveAudio(mediaId: mediaFile.mediaId);
|
||||||
await updateFromDB();
|
await updateFromDB();
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ class Log {
|
||||||
record.level >= Level.WARNING) {
|
record.level >= Level.WARNING) {
|
||||||
// ignore: avoid_print
|
// ignore: avoid_print
|
||||||
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,
|
source: record.loggerName,
|
||||||
message: record.message,
|
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;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import 'package:twonly/locator.dart';
|
||||||
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
||||||
import 'package:twonly/src/database/twonly.db.dart';
|
import 'package:twonly/src/database/twonly.db.dart';
|
||||||
import 'package:twonly/src/services/mediafiles/mediafile.service.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/services/user.service.dart';
|
||||||
import 'package:twonly/src/utils/log.dart';
|
import 'package:twonly/src/utils/log.dart';
|
||||||
import 'package:twonly/src/utils/misc.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/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/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/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/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.view.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/action_button.dart';
|
||||||
import 'package:twonly/src/visual/views/home.view.dart';
|
import 'package:twonly/src/visual/views/home.view.dart';
|
||||||
|
|
||||||
int maxVideoRecordingTime = 60;
|
|
||||||
|
|
||||||
class SelectedCameraDetails {
|
class SelectedCameraDetails {
|
||||||
double maxAvailableZoom = 1;
|
double maxAvailableZoom = 1;
|
||||||
double minAvailableZoom = 1;
|
double minAvailableZoom = 1;
|
||||||
|
|
@ -153,6 +153,7 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
||||||
DateTime? _videoRecordingStarted;
|
DateTime? _videoRecordingStarted;
|
||||||
Timer? _videoRecordingTimer;
|
Timer? _videoRecordingTimer;
|
||||||
bool _videoRecordingLocked = false;
|
bool _videoRecordingLocked = false;
|
||||||
|
Duration _currentMaxRecordingTime = Duration.zero;
|
||||||
|
|
||||||
DateTime _currentTime = clock.now();
|
DateTime _currentTime = clock.now();
|
||||||
final GlobalKey keyTriggerButton = GlobalKey();
|
final GlobalKey keyTriggerButton = GlobalKey();
|
||||||
|
|
@ -166,6 +167,7 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
unawaited(VideoRecordingBudget.ensureLoaded());
|
||||||
initVolumeControl();
|
initVolumeControl();
|
||||||
initAsync();
|
initAsync();
|
||||||
_checkAndInitCamera();
|
_checkAndInitCamera();
|
||||||
|
|
@ -411,8 +413,14 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
||||||
..copySync(mediaFileService.originalPath.path)
|
..copySync(mediaFileService.originalPath.path)
|
||||||
..deleteSync();
|
..deleteSync();
|
||||||
|
|
||||||
// Start with compressing the video, to speed up the process in case the video is not changed.
|
// Transcode while the user is still editing. A send then only has to
|
||||||
// unawaited(mediaFileService.compressMedia());
|
// 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();
|
await _deInitVolumeControl();
|
||||||
|
|
@ -611,6 +619,15 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
||||||
mc.setFilter(mc.currentFilterType.goRight());
|
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 {
|
Future<void> startVideoRecording() async {
|
||||||
if (mc.cameraController != null &&
|
if (mc.cameraController != null &&
|
||||||
mc.cameraController!.value.isRecordingVideo) {
|
mc.cameraController!.value.isRecordingVideo) {
|
||||||
|
|
@ -626,24 +643,31 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await mc.cameraController?.startVideoRecording();
|
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,
|
timer,
|
||||||
) {
|
) {
|
||||||
|
final startedAt = _videoRecordingStarted;
|
||||||
|
if (startedAt == null) {
|
||||||
|
timer.cancel();
|
||||||
|
_videoRecordingTimer = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
_currentTime = clock.now();
|
_currentTime = clock.now();
|
||||||
});
|
});
|
||||||
if (_videoRecordingStarted != null &&
|
if (_currentTime.difference(startedAt) >= maxRecordingTime) {
|
||||||
_currentTime.difference(_videoRecordingStarted!).inSeconds >=
|
// The budget is spent, so this stop is not the user letting go of
|
||||||
maxVideoRecordingTime) {
|
// the button: it has to go through even while recording is locked.
|
||||||
timer.cancel();
|
unawaited(stopVideoRecording(force: true));
|
||||||
_videoRecordingTimer = null;
|
|
||||||
stopVideoRecording();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
setState(() {
|
|
||||||
_videoRecordingStarted = clock.now();
|
|
||||||
mc.isVideoRecording = true;
|
|
||||||
});
|
|
||||||
} on CameraException catch (e) {
|
} on CameraException catch (e) {
|
||||||
setState(() {
|
setState(() {
|
||||||
mc.isVideoRecording = false;
|
mc.isVideoRecording = false;
|
||||||
|
|
@ -663,6 +687,15 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
||||||
_videoRecordingTimer = null;
|
_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);
|
await mc.cameraController?.setFlashMode(FlashMode.off);
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
@ -684,7 +717,24 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
||||||
final videoPath = await mc.cameraController?.stopVideoRecording();
|
final videoPath = await mc.cameraController?.stopVideoRecording();
|
||||||
if (videoPath == null) return;
|
if (videoPath == null) return;
|
||||||
await mc.cameraController?.pausePreview();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
} on CameraException catch (e) {
|
} on CameraException catch (e) {
|
||||||
|
|
@ -838,7 +888,8 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
||||||
),
|
),
|
||||||
VideoRecordingTimer(
|
VideoRecordingTimer(
|
||||||
videoRecordingStarted: _videoRecordingStarted,
|
videoRecordingStarted: _videoRecordingStarted,
|
||||||
maxVideoRecordingTime: maxVideoRecordingTime,
|
currentTime: _currentTime,
|
||||||
|
maxRecordingTime: _currentMaxRecordingTime,
|
||||||
),
|
),
|
||||||
if (!mc.isSharePreviewIsShown && widget.sendToGroup != null ||
|
if (!mc.isSharePreviewIsShown && widget.sendToGroup != null ||
|
||||||
widget.hideControllers)
|
widget.hideControllers)
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,19 +1,25 @@
|
||||||
import 'package:clock/clock.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class VideoRecordingTimer extends StatelessWidget {
|
class VideoRecordingTimer extends StatelessWidget {
|
||||||
const VideoRecordingTimer({
|
const VideoRecordingTimer({
|
||||||
required this.videoRecordingStarted,
|
required this.videoRecordingStarted,
|
||||||
required this.maxVideoRecordingTime,
|
required this.currentTime,
|
||||||
|
required this.maxRecordingTime,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
final DateTime? videoRecordingStarted;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (videoRecordingStarted != null) {
|
if (videoRecordingStarted == null) {
|
||||||
final currentTime = clock.now();
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
final elapsed = currentTime.difference(videoRecordingStarted!);
|
||||||
return Positioned(
|
return Positioned(
|
||||||
top: 50,
|
top: 50,
|
||||||
left: 0,
|
left: 0,
|
||||||
|
|
@ -26,11 +32,11 @@ class VideoRecordingTimer extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
Center(
|
Center(
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
value:
|
value: maxRecordingTime.inMilliseconds == 0
|
||||||
currentTime
|
? 0
|
||||||
.difference(videoRecordingStarted!)
|
: (elapsed.inMilliseconds /
|
||||||
.inMilliseconds /
|
maxRecordingTime.inMilliseconds)
|
||||||
(maxVideoRecordingTime * 1000),
|
.clamp(0.0, 1.0),
|
||||||
strokeWidth: 4,
|
strokeWidth: 4,
|
||||||
valueColor: const AlwaysStoppedAnimation<Color>(Colors.red),
|
valueColor: const AlwaysStoppedAnimation<Color>(Colors.red),
|
||||||
backgroundColor: Colors.grey[300],
|
backgroundColor: Colors.grey[300],
|
||||||
|
|
@ -38,10 +44,7 @@ class VideoRecordingTimer extends StatelessWidget {
|
||||||
),
|
),
|
||||||
Center(
|
Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
currentTime
|
elapsed.inSeconds.toString(),
|
||||||
.difference(videoRecordingStarted!)
|
|
||||||
.inSeconds
|
|
||||||
.toString(),
|
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 17,
|
fontSize: 17,
|
||||||
|
|
@ -59,8 +62,5 @@ class VideoRecordingTimer extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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!),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -31,6 +31,29 @@ message AttachmentDispatch {
|
||||||
bytes download_token = 4;
|
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 {
|
message AttachmentManifest {
|
||||||
int64 encrypted_object_size = 1;
|
int64 encrypted_object_size = 1;
|
||||||
repeated AttachmentDispatch dispatches = 2;
|
repeated AttachmentDispatch dispatches = 2;
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,10 @@ pub(crate) struct ApiClient {
|
||||||
pub(crate) config: ApiConfig,
|
pub(crate) config: ApiConfig,
|
||||||
pub(crate) state: RwLock<ApiConnectionState>,
|
pub(crate) state: RwLock<ApiConnectionState>,
|
||||||
pub(crate) ws_client: Mutex<Option<Arc<WebSocketClient>>>,
|
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) pending: PendingRequests,
|
||||||
pub(crate) next_sequence: Mutex<u64>,
|
pub(crate) next_sequence: Mutex<u64>,
|
||||||
pub(crate) events: broadcast::Sender<ApiEvent>,
|
pub(crate) events: broadcast::Sender<ApiEvent>,
|
||||||
|
|
@ -77,6 +81,7 @@ impl ApiClient {
|
||||||
config,
|
config,
|
||||||
state: RwLock::const_new(ApiConnectionState::Stopped),
|
state: RwLock::const_new(ApiConnectionState::Stopped),
|
||||||
ws_client: Mutex::const_new(None),
|
ws_client: Mutex::const_new(None),
|
||||||
|
connection_change: Mutex::const_new(()),
|
||||||
pending: Arc::new(Mutex::const_new(HashMap::new())),
|
pending: Arc::new(Mutex::const_new(HashMap::new())),
|
||||||
next_sequence: Mutex::const_new(1),
|
next_sequence: Mutex::const_new(1),
|
||||||
events: API_EVENTS.clone(),
|
events: API_EVENTS.clone(),
|
||||||
|
|
@ -200,15 +205,15 @@ impl ApiClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn reconnect(self: &Arc<Self>, stale: &Arc<WebSocketClient>, reason: &str) -> Result<()> {
|
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
|
if !guard
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|current| Arc::ptr_eq(current, stale))
|
.is_some_and(|current| Arc::ptr_eq(current, stale))
|
||||||
{
|
{
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
guard.take();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Counted only for a socket that was still the live one, so a stale
|
// 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"
|
"API WebSocket is dead, reconnecting"
|
||||||
);
|
);
|
||||||
|
|
||||||
self.is_authenticated.store(false, Ordering::Release);
|
self.discard_current_connection(reason).await;
|
||||||
// Dropping the sender ends the catch-up loop bound to the dead socket.
|
drop(connection_change);
|
||||||
*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;
|
|
||||||
|
|
||||||
tokio::time::sleep(delay).await;
|
tokio::time::sleep(delay).await;
|
||||||
|
|
||||||
|
|
@ -259,6 +258,7 @@ impl ApiClient {
|
||||||
if *guard != state {
|
if *guard != state {
|
||||||
*guard = state;
|
*guard = state;
|
||||||
drop(guard);
|
drop(guard);
|
||||||
|
tracing::info!(?state, "API connection state changed");
|
||||||
let _ = self.events.send(ApiEvent {
|
let _ = self.events.send(ApiEvent {
|
||||||
kind: ApiEventKind::ConnectionStateChanged,
|
kind: ApiEventKind::ConnectionStateChanged,
|
||||||
state: Some(state),
|
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<()> {
|
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) {
|
if API_PERMANENTLY_REJECTED.load(Ordering::Acquire) {
|
||||||
self.set_state(ApiConnectionState::PermanentlyRejected)
|
self.set_state(ApiConnectionState::PermanentlyRejected)
|
||||||
.await;
|
.await;
|
||||||
|
|
@ -339,6 +392,12 @@ impl ApiClient {
|
||||||
msg = receiver.recv() => {
|
msg = receiver.recv() => {
|
||||||
match msg {
|
match msg {
|
||||||
Ok(msg) => {
|
Ok(msg) => {
|
||||||
|
let Some(connection) = connection.upgrade() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if !self_clone.is_current_connection(&connection).await {
|
||||||
|
break;
|
||||||
|
}
|
||||||
// Tungstenite message
|
// Tungstenite message
|
||||||
if let stream_tungstenite::tokio_tungstenite::tungstenite::Message::Binary(bytes) = &*msg {
|
if let stream_tungstenite::tokio_tungstenite::tungstenite::Message::Binary(bytes) = &*msg {
|
||||||
self_clone.handle_incoming(bytes).await;
|
self_clone.handle_incoming(bytes).await;
|
||||||
|
|
@ -349,6 +408,12 @@ impl ApiClient {
|
||||||
}
|
}
|
||||||
ev = events.recv() => {
|
ev = events.recv() => {
|
||||||
use stream_tungstenite::ConnectionEvent;
|
use stream_tungstenite::ConnectionEvent;
|
||||||
|
let Some(connection) = connection.upgrade() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if !self_clone.is_current_connection(&connection).await {
|
||||||
|
break;
|
||||||
|
}
|
||||||
match ev {
|
match ev {
|
||||||
Ok(ConnectionEvent::Connected { .. }) => {
|
Ok(ConnectionEvent::Connected { .. }) => {
|
||||||
let is_auth = self_clone.is_authenticated.load(Ordering::Acquire);
|
let is_auth = self_clone.is_authenticated.load(Ordering::Acquire);
|
||||||
|
|
@ -377,13 +442,11 @@ impl ApiClient {
|
||||||
// send. Replace it instead of sitting on it.
|
// send. Replace it instead of sitting on it.
|
||||||
Ok(ConnectionEvent::FatalError { .. } | ConnectionEvent::Shutdown) => {
|
Ok(ConnectionEvent::FatalError { .. } | ConnectionEvent::Shutdown) => {
|
||||||
self_clone.is_authenticated.store(false, Ordering::Release);
|
self_clone.is_authenticated.store(false, Ordering::Release);
|
||||||
if let Some(connection) = connection.upgrade() {
|
|
||||||
self_clone.schedule_reconnect(
|
self_clone.schedule_reconnect(
|
||||||
&connection,
|
&connection,
|
||||||
"supervisor stopped reconnecting",
|
"supervisor stopped reconnecting",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(_) => break,
|
Err(_) => break,
|
||||||
}
|
}
|
||||||
|
|
@ -397,6 +460,7 @@ impl ApiClient {
|
||||||
|
|
||||||
pub async fn close(&self) {
|
pub async fn close(&self) {
|
||||||
self.deliberately_closed.store(true, Ordering::Release);
|
self.deliberately_closed.store(true, Ordering::Release);
|
||||||
|
let _connection_change = self.connection_change.lock().await;
|
||||||
self.is_authenticated.store(false, Ordering::Release);
|
self.is_authenticated.store(false, Ordering::Release);
|
||||||
// Dropping the sender ends the catch-up loop for this connection.
|
// Dropping the sender ends the catch-up loop for this connection.
|
||||||
*self.catch_up_tx.lock().await = None;
|
*self.catch_up_tx.lock().await = None;
|
||||||
|
|
@ -422,24 +486,31 @@ impl ApiClient {
|
||||||
self.in_background.store(in_background, Ordering::Release);
|
self.in_background.store(in_background, Ordering::Release);
|
||||||
if in_background {
|
if in_background {
|
||||||
self.set_state(ApiConnectionState::Suspended).await;
|
self.set_state(ApiConnectionState::Suspended).await;
|
||||||
} else if self.ws_client.lock().await.is_some() {
|
} else {
|
||||||
self.set_state(ApiConnectionState::Authenticated).await;
|
// Mobile platforms can freeze the process while keeping the socket
|
||||||
// Returning to the foreground: pick up whatever arrived while the
|
// object alive. Its TCP connection may be dead even though neither
|
||||||
// socket was suspended.
|
// the OS nor tungstenite has reported that yet, so foregrounding
|
||||||
self.request_catch_up().await;
|
// always starts a fresh attempt instead of waiting for a timeout.
|
||||||
} else if self.network_available.load(Ordering::Acquire) {
|
self.reconnect_now("application entered the foreground")
|
||||||
self.connect().await?;
|
.await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn set_network_available(self: &Arc<Self>, available: bool) -> Result<()> {
|
pub(crate) async fn set_network_available(self: &Arc<Self>, available: bool) -> Result<()> {
|
||||||
self.network_available.store(available, Ordering::Release);
|
self.network_available.store(available, Ordering::Release);
|
||||||
if available
|
if available {
|
||||||
& !self.in_background.load(Ordering::Acquire)
|
// A Wi-Fi/mobile/route change can leave an apparently connected
|
||||||
& self.ws_client.lock().await.is_none()
|
// socket bound to the old network. Replace it now, including when
|
||||||
{
|
// its own supervisor is currently sleeping in reconnect backoff.
|
||||||
self.connect().await?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,11 @@ use crate::api::runtime::ApiRuntime;
|
||||||
use crate::api::Server;
|
use crate::api::Server;
|
||||||
|
|
||||||
use crate::bridge::api::ServerResult;
|
use crate::bridge::api::ServerResult;
|
||||||
use crate::context::{Context, RuntimeMode};
|
use crate::context::Context;
|
||||||
use crate::error::{Result, TwonlyError};
|
use crate::error::{Result, TwonlyError};
|
||||||
use crate::services::direct_media_upload::DirectMediaUploadService;
|
use crate::services::direct_media_upload::DirectMediaUploadService;
|
||||||
use crate::services::groups::GroupService;
|
use crate::services::groups::GroupService;
|
||||||
|
use crate::services::media_upload::MediaUploadService;
|
||||||
use crate::services::mediafiles::MediaFileService;
|
use crate::services::mediafiles::MediaFileService;
|
||||||
use prost::Message as ProstMessage;
|
use prost::Message as ProstMessage;
|
||||||
use std::future::Future;
|
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) {
|
pub(crate) fn schedule_post_authentication(ctx: &Arc<Context>, in_background: bool) {
|
||||||
// Notification workers only drain and commit the mailbox. Media downloads,
|
// Notification workers only drain and commit the mailbox. Media downloads,
|
||||||
// maintenance, and outbox replay belong to the main application runtime.
|
// maintenance, and outbox replay belong to the main application runtime.
|
||||||
if ctx.runtime_mode == RuntimeMode::Notification {
|
if ctx.is_notification_runtime() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let ctx = ctx.clone();
|
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 {
|
if let Err(error) = messages::retransmit_queued_receipts(&ctx).await {
|
||||||
tracing::warn!("failed to retransmit queued receipts: {error}");
|
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 {
|
if let Err(error) = MediaFileService::new(&ctx).download_pending().await {
|
||||||
tracing::warn!("failed to download pending media: {error}");
|
tracing::warn!("failed to download pending media: {error}");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,13 @@ use crate::api::ApiRuntime;
|
||||||
pub use crate::api::PqcPreKeyInput;
|
pub use crate::api::PqcPreKeyInput;
|
||||||
use crate::api::Server;
|
use crate::api::Server;
|
||||||
use crate::context::Context;
|
use crate::context::Context;
|
||||||
use crate::context::RuntimeMode;
|
|
||||||
use crate::error::{Result, TwonlyError};
|
use crate::error::{Result, TwonlyError};
|
||||||
use crate::frb_generated::StreamSink;
|
use crate::frb_generated::StreamSink;
|
||||||
use crate::services::avatars;
|
use crate::services::avatars;
|
||||||
use crate::services::contacts::ContactService;
|
use crate::services::contacts::ContactService;
|
||||||
use crate::services::media_upload::{MediaSizeReport, MediaUploadService};
|
use crate::services::media_upload::{MediaSizeReport, MediaUploadService};
|
||||||
use crate::services::messages::MessageService;
|
use crate::services::messages::MessageService;
|
||||||
|
use crate::services::outbox_dispatch::OutboxDispatchService;
|
||||||
use crate::user_config::UserConfig;
|
use crate::user_config::UserConfig;
|
||||||
use flutter_rust_bridge::frb;
|
use flutter_rust_bridge::frb;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
@ -54,7 +54,7 @@ impl ApiConfig {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
websocket_url: format!("{}client", RustApi::api_base_url("wss".to_owned())),
|
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),
|
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
|
can_use_login_token_for_auth: user
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|value| value.can_use_login_token_for_auth),
|
.is_some_and(|value| value.can_use_login_token_for_auth),
|
||||||
|
|
@ -302,6 +302,19 @@ impl RustApi {
|
||||||
.await
|
.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<()> {
|
pub async fn toggle_media_remove_audio(media_id: String) -> Result<()> {
|
||||||
let ctx = Context::get_static()?;
|
let ctx = Context::get_static()?;
|
||||||
MediaUploadService::new(ctx)
|
MediaUploadService::new(ctx)
|
||||||
|
|
@ -390,14 +403,47 @@ impl RustApi {
|
||||||
|
|
||||||
pub async fn set_background(in_background: bool) -> Result<()> {
|
pub async fn set_background(in_background: bool) -> Result<()> {
|
||||||
let ctx = Context::get_static()?;
|
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
|
// Coming back to the foreground is the first chance to notice that
|
||||||
// the OS finished a media transfer while this process was idle.
|
// the OS finished a media transfer while this process was idle.
|
||||||
crate::services::direct_media_upload::watch_pending_uploads(ctx);
|
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
|
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<()> {
|
pub async fn set_network_available(available: bool) -> Result<()> {
|
||||||
let ctx = Context::get_static()?;
|
let ctx = Context::get_static()?;
|
||||||
ApiRuntime::set_network_available(ctx, available).await
|
ApiRuntime::set_network_available(ctx, available).await
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ use crate::utils::Shared;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
use std::{
|
use std::{
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
sync::Arc,
|
sync::{Arc, OnceLock as StdOnceLock},
|
||||||
};
|
};
|
||||||
use tokio::sync::{Mutex, Notify, OnceCell, RwLock};
|
use tokio::sync::{Mutex, Notify, OnceCell, RwLock};
|
||||||
use zeroize::Zeroize;
|
use zeroize::Zeroize;
|
||||||
|
|
@ -36,6 +36,14 @@ pub enum RuntimeMode {
|
||||||
pub struct Context {
|
pub struct Context {
|
||||||
pub config: InitConfig,
|
pub config: InitConfig,
|
||||||
pub(crate) runtime_mode: RuntimeMode,
|
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 rust_db: Arc<RwLock<Arc<Database>>>,
|
||||||
pub app_db: Arc<RwLock<Arc<AppDatabase>>>,
|
pub app_db: Arc<RwLock<Arc<AppDatabase>>>,
|
||||||
pub(crate) secure_storage: SecureStorage,
|
pub(crate) secure_storage: SecureStorage,
|
||||||
|
|
@ -116,6 +124,8 @@ impl Context {
|
||||||
let ctx = Arc::new(Context {
|
let ctx = Arc::new(Context {
|
||||||
config,
|
config,
|
||||||
runtime_mode: RuntimeMode::Standalone,
|
runtime_mode: RuntimeMode::Standalone,
|
||||||
|
flutter_claimed: AtomicBool::new(false),
|
||||||
|
foreground_runtime: StdOnceLock::new(),
|
||||||
rust_db,
|
rust_db,
|
||||||
app_db,
|
app_db,
|
||||||
secure_storage,
|
secure_storage,
|
||||||
|
|
@ -191,13 +201,20 @@ impl Context {
|
||||||
// Logging is process-wide and owns app.log directly. Initialize it
|
// Logging is process-wide and owns app.log directly. Initialize it
|
||||||
// before the context check so notification and Flutter runtimes both
|
// before the context check so notification and Flutter runtimes both
|
||||||
// have a sink even when the main context already exists.
|
// 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(
|
init_tracing(
|
||||||
Path::new(&config.data_dir),
|
Path::new(&config.data_dir),
|
||||||
runtime_mode != RuntimeMode::Flutter,
|
runtime_mode != RuntimeMode::Flutter && !foreground_already_owns_process,
|
||||||
);
|
);
|
||||||
|
|
||||||
if GLOBAL_CONTEXT.initialized() {
|
if GLOBAL_CONTEXT.initialized() {
|
||||||
tracing::info!("twonly already initialized. Ensuring storage directories exist.");
|
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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -278,6 +295,8 @@ impl Context {
|
||||||
let ctx = Arc::new(Context {
|
let ctx = Arc::new(Context {
|
||||||
config,
|
config,
|
||||||
runtime_mode,
|
runtime_mode,
|
||||||
|
flutter_claimed: AtomicBool::new(true),
|
||||||
|
foreground_runtime: StdOnceLock::new(),
|
||||||
secure_storage,
|
secure_storage,
|
||||||
rust_db: rust_db_handle,
|
rust_db: rust_db_handle,
|
||||||
app_db,
|
app_db,
|
||||||
|
|
@ -319,6 +338,8 @@ impl Context {
|
||||||
let ctx = Arc::new(Context {
|
let ctx = Arc::new(Context {
|
||||||
config,
|
config,
|
||||||
runtime_mode,
|
runtime_mode,
|
||||||
|
flutter_claimed: AtomicBool::new(false),
|
||||||
|
foreground_runtime: StdOnceLock::new(),
|
||||||
rust_db: rust_db_handle,
|
rust_db: rust_db_handle,
|
||||||
app_db,
|
app_db,
|
||||||
key_manager,
|
key_manager,
|
||||||
|
|
@ -341,12 +362,45 @@ impl Context {
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
let ctx = res?;
|
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?;
|
ApiRuntime::connect(ctx).await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
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>> {
|
pub(super) fn get_static() -> Result<&'static Arc<Context>> {
|
||||||
GLOBAL_CONTEXT.get().ok_or(TwonlyError::Initialization)
|
GLOBAL_CONTEXT.get().ok_or(TwonlyError::Initialization)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
21
rust/src/database/app/migrations/0009_outbox_dispatch.sql
Normal file
21
rust/src/database/app/migrations/0009_outbox_dispatch.sql
Normal 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);
|
||||||
11
rust/src/database/app/migrations/0010_media_trim.sql
Normal file
11
rust/src/database/app/migrations/0010_media_trim.sql
Normal 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;
|
||||||
|
|
@ -16,7 +16,7 @@ mod legacy_import;
|
||||||
pub mod tables;
|
pub mod tables;
|
||||||
|
|
||||||
pub const APP_DATABASE_FILE: &str = "app_db.sqlite";
|
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
|
/// 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
|
/// Drift schema 25, because a missing table aborts the whole import. Rust-only
|
||||||
|
|
|
||||||
|
|
@ -32,20 +32,24 @@ impl MediaFile {
|
||||||
/// Single owner of the "media reached the server" transition. The state
|
/// Single owner of the "media reached the server" transition. The state
|
||||||
/// change, the per-recipient message actions, and the receipt bookkeeping
|
/// change, the per-recipient message actions, and the receipt bookkeeping
|
||||||
/// have to become visible together, so they share one transaction.
|
/// 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(
|
pub async fn mark_uploaded(
|
||||||
transaction: &mut Transaction<'_, Sqlite>,
|
transaction: &mut Transaction<'_, Sqlite>,
|
||||||
media_id: &str,
|
media_id: &str,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let updated = sqlx::query!(
|
sqlx::query!(
|
||||||
"UPDATE media_files SET upload_state = 'uploaded' WHERE media_id = ? AND upload_state IS NOT 'uploaded'",
|
"UPDATE media_files SET upload_state = 'uploaded' WHERE media_id = ? AND upload_state IS NOT 'uploaded'",
|
||||||
media_id,
|
media_id,
|
||||||
)
|
)
|
||||||
.execute(&mut **transaction)
|
.execute(&mut **transaction)
|
||||||
.await?
|
.await?;
|
||||||
.rows_affected();
|
|
||||||
if updated == 0 {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let now = chrono::Utc::now().timestamp();
|
let now = chrono::Utc::now().timestamp();
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
|
|
@ -56,8 +60,10 @@ impl MediaFile {
|
||||||
JOIN group_members ON group_members.group_id = messages.group_id
|
JOIN group_members ON group_members.group_id = messages.group_id
|
||||||
WHERE messages.media_id = ?
|
WHERE messages.media_id = ?
|
||||||
AND (group_members.member_state IS NULL OR group_members.member_state != 'leftGroup')
|
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)
|
ON CONFLICT(message_id, contact_id, type)
|
||||||
DO UPDATE SET action_at = excluded.action_at
|
DO NOTHING
|
||||||
"#,
|
"#,
|
||||||
now,
|
now,
|
||||||
media_id,
|
media_id,
|
||||||
|
|
@ -65,19 +71,21 @@ impl MediaFile {
|
||||||
.execute(&mut **transaction)
|
.execute(&mut **transaction)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
sqlx::query!(
|
let acknowledged = sqlx::query!(
|
||||||
"UPDATE messages SET ack_by_server = ? WHERE media_id = ?",
|
"UPDATE messages SET ack_by_server = ? WHERE media_id = ? AND ack_by_server IS NULL",
|
||||||
now,
|
now,
|
||||||
media_id,
|
media_id,
|
||||||
)
|
)
|
||||||
.execute(&mut **transaction)
|
.execute(&mut **transaction)
|
||||||
.await?;
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
r#"
|
r#"
|
||||||
UPDATE receipts
|
UPDATE receipts
|
||||||
SET ack_by_server_at = ?, retry_count = 1, last_retry = ?, mark_for_retry = NULL
|
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
|
SELECT 1
|
||||||
FROM messages
|
FROM messages
|
||||||
JOIN group_members ON group_members.group_id = messages.group_id
|
JOIN group_members ON group_members.group_id = messages.group_id
|
||||||
|
|
@ -94,10 +102,13 @@ impl MediaFile {
|
||||||
.execute(&mut **transaction)
|
.execute(&mut **transaction)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
if acknowledged > 0 {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
media_id,
|
media_id,
|
||||||
|
acknowledged,
|
||||||
"marked media upload as successful after receiver response"
|
"marked media upload as successful after receiver response"
|
||||||
);
|
);
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
|
||||||
default_rust_auto_opaque = RustAutoOpaqueMoi,
|
default_rust_auto_opaque = RustAutoOpaqueMoi,
|
||||||
);
|
);
|
||||||
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0";
|
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
|
// 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(
|
fn wire__crate__bridge__api__rust_api_initialize_media_upload_impl(
|
||||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
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(
|
fn wire__crate__bridge__api__rust_api_purge_media_temp_folder_impl(
|
||||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
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(
|
fn wire__crate__bridge__api__rust_api_set_network_available_impl(
|
||||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
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),
|
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),
|
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),
|
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),
|
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_insert_and_send_additional_data_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_ask_about_user_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_contact_share_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_text_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_ipa_purchase_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_load_plan_balance_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_media_size_limit_report_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_step_finished_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_notification_badge_count_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_notify_messages_opened_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_perform_passwordless_recovery_heartbeat_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_purge_media_temp_folder_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_register_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_register_passwordless_notification_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_passwordless_recovery_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_reload_configuration_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_remove_additional_user_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_remove_media_files_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_report_user_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_request_binary_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_request_contact_by_username_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_media_reupload_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_memories_upload_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_retransmit_all_messages_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_retry_pending_media_reuploads_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_reupload_pending_media_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_save_media_to_gallery_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_send_binary_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_send_contact_profile_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_encrypted_content_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_encrypted_content_to_group_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_media_to_groups_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_queued_message_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_text_message_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_typing_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_set_background_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_set_login_token_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_media_display_limit_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_media_requires_authentication_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_network_available_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_store_media_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_submit_recovery_share_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_toggle_media_remove_audio_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_try_request_contact_by_id_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_update_fcm_token_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_update_signed_pre_key_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_upload_pqc_pre_keys_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__wrapper__app_database__rust_app_database_changes_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__wrapper__app_database__rust_app_database_execute_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__wrapper__app_database__rust_app_database_legacy_import_complete_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_migrate_legacy_database_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_select_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__backup__rust_backup_archive_create_backup_archive_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__backup__rust_backup_archive_get_backup_download_token_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__backup__rust_backup_archive_restore_backup_archive_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_identity_get_backup_id_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_identity_get_backup_password_keys_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_identity_get_identity_backup_bytes_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_import_backup_password_keys_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_restore_identity_backup_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_set_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__key_manager__rust_key_manager_decrypt_cloud_media_key_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__key_manager__rust_key_manager_encrypt_cloud_media_key_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__key_manager__rust_key_manager_get_signal_identity_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_get_user_id_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_import_serialized_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_import_signal_identity_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_remove_local_credentials_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_serialize_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_set_user_id_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__signal__rust_signal_decrypt_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__signal__rust_signal_encrypt_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__signal__rust_signal_generate_bundle_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_generate_pqc_prekeys_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_get_contact_public_key_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_get_user_public_key_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_process_prekey_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__rust_utils_generate_shares_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__rust_utils_recover_secret_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__groups__update_chat_deletion_time_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__groups__update_group_name_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),
|
||||||
140 => wire__crate__bridge__user_config__user_config_api_create_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),
|
||||||
141 => wire__crate__bridge__user_config__user_config_api_import_json_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),
|
||||||
142 => wire__crate__bridge__user_config__user_config_api_load_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_save_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_update_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!(),
|
_ => unreachable!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -6649,12 +6767,12 @@ fn pde_ffi_dispatcher_sync_impl(
|
||||||
37 => {
|
37 => {
|
||||||
wire__crate__bridge__api__rust_api_decode_avatar_svg_impl(ptr, rust_vec_len, data_len)
|
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,
|
ptr,
|
||||||
rust_vec_len,
|
rust_vec_len,
|
||||||
data_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!(),
|
_ => unreachable!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -283,9 +283,13 @@ where
|
||||||
tracing::Level::WARN => "\x1b[33m",
|
tracing::Level::WARN => "\x1b[33m",
|
||||||
tracing::Level::ERROR => "\x1b[31m",
|
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 {
|
} else {
|
||||||
write!(writer, "{time} {:<5} ", metadata.level())?;
|
write!(writer, "{time} {:<5} [twonly] ", metadata.level())?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ansi {
|
if ansi {
|
||||||
|
|
|
||||||
189
rust/src/native/background.rs
Normal file
189
rust/src/native/background.rs
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,8 +10,10 @@
|
||||||
//! decoder, durable background transfer, the photo library and notifications.
|
//! decoder, durable background transfer, the photo library and notifications.
|
||||||
//! None of them require the Flutter engine to be running.
|
//! None of them require the Flutter engine to be running.
|
||||||
|
|
||||||
|
pub(crate) mod background;
|
||||||
pub(crate) mod gallery;
|
pub(crate) mod gallery;
|
||||||
pub(crate) mod image;
|
pub(crate) mod image;
|
||||||
pub(crate) mod notifications;
|
pub(crate) mod notifications;
|
||||||
|
pub(crate) mod prepare;
|
||||||
pub(crate) mod transfer;
|
pub(crate) mod transfer;
|
||||||
pub(crate) mod video;
|
pub(crate) mod video;
|
||||||
|
|
|
||||||
151
rust/src/native/prepare.rs
Normal file
151
rust/src/native/prepare.rs
Normal 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) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -40,12 +40,14 @@ pub(crate) mod android {
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
const TRANSFER_CLASS: &str = "eu/twonly/directmedia/DirectMediaTransfer";
|
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 MEDIA_CODEC_CLASS: &str = "eu/twonly/media/NativeImageCodec";
|
||||||
const VIDEO_CODEC_CLASS: &str = "eu/twonly/media/NativeVideoCodec";
|
const VIDEO_CODEC_CLASS: &str = "eu/twonly/media/NativeVideoCodec";
|
||||||
const GALLERY_CLASS: &str = "eu/twonly/media/NativeGallery";
|
const GALLERY_CLASS: &str = "eu/twonly/media/NativeGallery";
|
||||||
|
|
||||||
static JAVA_VM: OnceLock<JavaVM> = OnceLock::new();
|
static JAVA_VM: OnceLock<JavaVM> = OnceLock::new();
|
||||||
static TRANSFER: OnceLock<GlobalRef> = OnceLock::new();
|
static TRANSFER: OnceLock<GlobalRef> = OnceLock::new();
|
||||||
|
static PREPARE: OnceLock<GlobalRef> = OnceLock::new();
|
||||||
static MEDIA_CODEC: OnceLock<GlobalRef> = OnceLock::new();
|
static MEDIA_CODEC: OnceLock<GlobalRef> = OnceLock::new();
|
||||||
static VIDEO_CODEC: OnceLock<GlobalRef> = OnceLock::new();
|
static VIDEO_CODEC: OnceLock<GlobalRef> = OnceLock::new();
|
||||||
static GALLERY: OnceLock<GlobalRef> = OnceLock::new();
|
static GALLERY: OnceLock<GlobalRef> = OnceLock::new();
|
||||||
|
|
@ -65,6 +67,7 @@ pub(crate) mod android {
|
||||||
Ok(mut env) => {
|
Ok(mut env) => {
|
||||||
for (name, cache) in [
|
for (name, cache) in [
|
||||||
(TRANSFER_CLASS, &TRANSFER),
|
(TRANSFER_CLASS, &TRANSFER),
|
||||||
|
(PREPARE_CLASS, &PREPARE),
|
||||||
(MEDIA_CODEC_CLASS, &MEDIA_CODEC),
|
(MEDIA_CODEC_CLASS, &MEDIA_CODEC),
|
||||||
(VIDEO_CODEC_CLASS, &VIDEO_CODEC),
|
(VIDEO_CODEC_CLASS, &VIDEO_CODEC),
|
||||||
(GALLERY_CLASS, &GALLERY),
|
(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> {
|
pub(crate) fn media_codec_class() -> Result<&'static GlobalRef> {
|
||||||
cached_class(&MEDIA_CODEC, MEDIA_CODEC_CLASS)
|
cached_class(&MEDIA_CODEC, MEDIA_CODEC_CLASS)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,9 @@
|
||||||
//! background or was relaunched without its UI.
|
//! background or was relaunched without its UI.
|
||||||
|
|
||||||
use crate::error::{Result, TwonlyError};
|
use crate::error::{Result, TwonlyError};
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
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
|
/// 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
|
/// video frame; the editor draws it once when the user hits send. Owned so the
|
||||||
|
|
@ -25,20 +26,60 @@ pub(crate) struct RenderRequest {
|
||||||
pub overlay: Option<PathBuf>,
|
pub overlay: Option<PathBuf>,
|
||||||
pub output: PathBuf,
|
pub output: PathBuf,
|
||||||
pub remove_audio: bool,
|
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,
|
/// 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
|
/// a GCD timer on iOS — which is not inside the async runtime. Keep the runtime
|
||||||
/// there would panic, and a panic unwinding back through the C ABI aborts the
|
/// for each active render only. A process-wide `OnceLock<Handle>` used to retain
|
||||||
/// process, so the runtime is captured while still on a Rust thread.
|
/// the first WorkManager runtime forever, even after that runtime was destroyed.
|
||||||
#[cfg_attr(not(any(target_os = "android", target_os = "ios")), allow(dead_code))]
|
#[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))]
|
#[cfg_attr(not(any(target_os = "android", target_os = "ios")), allow(dead_code))]
|
||||||
fn remember_runtime() {
|
struct ProgressRuntimeGuard {
|
||||||
if let Ok(handle) = tokio::runtime::Handle::try_current() {
|
media_id: String,
|
||||||
let _ = RUNTIME.set(handle);
|
}
|
||||||
|
|
||||||
|
#[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
|
/// Reports transcoding progress back into the media row so the send state in
|
||||||
|
|
@ -48,25 +89,32 @@ fn report_progress(media_id: &str, percent: i64) {
|
||||||
let Ok(ctx) = crate::context::Context::get_static() else {
|
let Ok(ctx) = crate::context::Context::get_static() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Some(runtime) = RUNTIME.get() else {
|
let Some(runtime) = PROGRESS_RUNTIMES
|
||||||
|
.lock()
|
||||||
|
.ok()
|
||||||
|
.and_then(|runtimes| runtimes.get(media_id).cloned())
|
||||||
|
else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let ctx = ctx.clone();
|
let ctx = ctx.clone();
|
||||||
let media_id = media_id.to_owned();
|
let media_id = media_id.to_owned();
|
||||||
runtime.spawn(async move {
|
runtime.spawn(async move {
|
||||||
let database = ctx.app_db.read().await.clone();
|
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 = ?")
|
sqlx::query("UPDATE media_files SET pre_progressing_process = ? WHERE media_id = ?")
|
||||||
.bind(percent.clamp(0, 100))
|
.bind(percent.clamp(0, 100))
|
||||||
.bind(&media_id)
|
.bind(&media_id)
|
||||||
.execute(&database.pool)
|
.execute(&database.pool)
|
||||||
.await;
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(media_id, %error, "could not store video render progress");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "ios")]
|
#[cfg(target_os = "ios")]
|
||||||
pub(crate) fn render(request: &RenderRequest) -> Result<()> {
|
pub(crate) fn render(request: &RenderRequest) -> Result<()> {
|
||||||
remember_runtime();
|
let _progress_runtime = remember_runtime(&request.media_id);
|
||||||
use std::ffi::{CStr, CString};
|
use std::ffi::{CStr, CString};
|
||||||
use std::os::raw::{c_char, c_int};
|
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,
|
||||||
*const c_char,
|
*const c_char,
|
||||||
bool,
|
bool,
|
||||||
|
i64,
|
||||||
|
i64,
|
||||||
*const c_char,
|
*const c_char,
|
||||||
ProgressCallback,
|
ProgressCallback,
|
||||||
) -> bool;
|
) -> bool;
|
||||||
|
|
@ -126,6 +176,8 @@ pub(crate) fn render(request: &RenderRequest) -> Result<()> {
|
||||||
.map_or(std::ptr::null(), |path| path.as_ptr()),
|
.map_or(std::ptr::null(), |path| path.as_ptr()),
|
||||||
output.as_ptr(),
|
output.as_ptr(),
|
||||||
request.remove_audio,
|
request.remove_audio,
|
||||||
|
request.trim_start_or_negative(),
|
||||||
|
request.trim_end_or_negative(),
|
||||||
media_id.as_ptr(),
|
media_id.as_ptr(),
|
||||||
progress,
|
progress,
|
||||||
)
|
)
|
||||||
|
|
@ -141,7 +193,7 @@ pub(crate) fn render(request: &RenderRequest) -> Result<()> {
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
pub(crate) fn render(request: &RenderRequest) -> Result<()> {
|
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 crate::native::transfer::android::{jni_env, video_codec_class};
|
||||||
use jni::objects::{JObject, JValue};
|
use jni::objects::{JObject, JValue};
|
||||||
|
|
||||||
|
|
@ -163,12 +215,14 @@ pub(crate) fn render(request: &RenderRequest) -> Result<()> {
|
||||||
.call_static_method(
|
.call_static_method(
|
||||||
class,
|
class,
|
||||||
"render",
|
"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(&input),
|
||||||
JValue::Object(&overlay),
|
JValue::Object(&overlay),
|
||||||
JValue::Object(&output),
|
JValue::Object(&output),
|
||||||
JValue::Bool(u8::from(request.remove_audio)),
|
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),
|
JValue::Object(&media_id),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
163
rust/src/services/background.rs
Normal file
163
rust/src/services/background.rs
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -18,38 +18,75 @@ use sqlx::FromRow;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
const REFRESH_BEFORE_SECONDS: i64 = 24 * 60 * 60;
|
const REFRESH_BEFORE_SECONDS: i64 = 24 * 60 * 60;
|
||||||
/// How long a running app keeps asking the server about transfers the OS is
|
/// When to top the slot cache up, until the server has said otherwise.
|
||||||
/// carrying for it. Anything still unfinished after this is picked up by the
|
///
|
||||||
/// reconciliation pass on the next launch.
|
/// A slot is the one part of preparing a send that needs the network, so the
|
||||||
const WATCH_BUDGET: Duration = Duration::from_secs(10 * 60);
|
/// 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_FIRST_DELAY: Duration = Duration::from_secs(2);
|
||||||
const WATCH_MAX_DELAY: Duration = Duration::from_secs(120);
|
const WATCH_MAX_DELAY: Duration = Duration::from_secs(120);
|
||||||
|
|
||||||
static WATCHING: AtomicBool = AtomicBool::new(false);
|
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
|
/// 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
|
/// 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
|
/// the server for the attachments it is still waiting on, backing off as the
|
||||||
/// wait grows, and stop as soon as everything has settled.
|
/// wait grows, and stop as soon as everything has settled.
|
||||||
pub fn watch_pending_uploads(ctx: &Arc<Context>) {
|
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) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
let ctx = ctx.clone();
|
let ctx = ctx.clone();
|
||||||
tokio::spawn(async move {
|
runtime.spawn(async move {
|
||||||
|
let guard = WatchingGuard;
|
||||||
let service = DirectMediaUploadService::new(&ctx);
|
let service = DirectMediaUploadService::new(&ctx);
|
||||||
let deadline = tokio::time::Instant::now() + WATCH_BUDGET;
|
let mut seen = requested;
|
||||||
let mut delay = WATCH_FIRST_DELAY;
|
let mut delay = WATCH_FIRST_DELAY;
|
||||||
while tokio::time::Instant::now() < deadline {
|
loop {
|
||||||
tokio::time::sleep(delay).await;
|
tokio::time::sleep(delay).await;
|
||||||
if let Err(error) = service.reconcile().await {
|
if let Err(error) = service.reconcile().await {
|
||||||
tracing::warn!(%error, "could not reconcile direct-media uploads");
|
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 {
|
match service.pending_job_count().await {
|
||||||
Ok(0) => break,
|
Ok(0) => break,
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
|
|
@ -58,9 +95,26 @@ pub fn watch_pending_uploads(ctx: &Arc<Context>) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let requests = WATCH_REQUESTS.load(Ordering::SeqCst);
|
||||||
|
if requests == seen {
|
||||||
delay = (delay * 2).min(WATCH_MAX_DELAY);
|
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)))
|
.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> {
|
pub async fn preload_slots(&self) -> Result<usize> {
|
||||||
let database = self.ctx.app_db.read().await.clone();
|
let database = self.ctx.app_db.read().await.clone();
|
||||||
let known = sqlx::query_scalar::<_, String>(
|
let known = sqlx::query_scalar::<_, String>(
|
||||||
|
|
@ -197,6 +269,18 @@ impl DirectMediaUploadService {
|
||||||
)?;
|
)?;
|
||||||
let count = slots.slots.len();
|
let count = slots.slots.len();
|
||||||
let mut transaction = database.pool.begin().await?;
|
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 {
|
for slot in slots.slots {
|
||||||
let upload = slot.media_upload.ok_or_else(|| {
|
let upload = slot.media_upload.ok_or_else(|| {
|
||||||
TwonlyError::Generic("server returned an upload slot without a POST".into())
|
TwonlyError::Generic("server returned an upload slot without a POST".into())
|
||||||
|
|
@ -232,9 +316,24 @@ impl DirectMediaUploadService {
|
||||||
.await?)
|
.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<()> {
|
async fn ensure_slots(&self) -> Result<()> {
|
||||||
let usable = self.usable_slot_count().await?;
|
let usable = self.usable_slot_count().await?;
|
||||||
if usable > 5 {
|
if usable > self.refill_threshold().await {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
match self.preload_slots().await {
|
match self.preload_slots().await {
|
||||||
|
|
@ -279,9 +378,18 @@ impl DirectMediaUploadService {
|
||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Native background transfers cannot report back into a process that may
|
/// The server's attachment state is the authority on what happened to a
|
||||||
/// not be running, so the server's attachment state is the authority. Every
|
/// transfer the OS carried.
|
||||||
/// launch settles the jobs the device believes are still in flight.
|
///
|
||||||
|
/// 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<()> {
|
pub async fn reconcile(&self) -> Result<()> {
|
||||||
let database = self.ctx.app_db.read().await.clone();
|
let database = self.ctx.app_db.read().await.clone();
|
||||||
let jobs = sqlx::query_as::<_, PendingJob>(
|
let jobs = sqlx::query_as::<_, PendingJob>(
|
||||||
|
|
@ -310,6 +418,12 @@ impl DirectMediaUploadService {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
tracing::info!(
|
||||||
|
attachment_id = job.attachment_id,
|
||||||
|
media_id = job.media_id,
|
||||||
|
?state,
|
||||||
|
"read direct-media attachment status"
|
||||||
|
);
|
||||||
match state {
|
match state {
|
||||||
AttachmentState::Ready => self.settle(&job, Outcome::Uploaded).await?,
|
AttachmentState::Ready => self.settle(&job, Outcome::Uploaded).await?,
|
||||||
AttachmentState::Rejected => self.settle(&job, Outcome::Rejected).await?,
|
AttachmentState::Rejected => self.settle(&job, Outcome::Rejected).await?,
|
||||||
|
|
@ -383,6 +497,9 @@ impl DirectMediaUploadService {
|
||||||
} else {
|
} else {
|
||||||
"abandoned"
|
"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 = ?")
|
sqlx::query("UPDATE direct_media_upload_slots SET state = ? WHERE attachment_id = ?")
|
||||||
.bind(slot_state)
|
.bind(slot_state)
|
||||||
.bind(&job.attachment_id)
|
.bind(&job.attachment_id)
|
||||||
|
|
@ -561,6 +678,9 @@ impl DirectMediaUploadService {
|
||||||
.bind(&slot.attachment_id)
|
.bind(&slot.attachment_id)
|
||||||
.execute(&database.pool)
|
.execute(&database.pool)
|
||||||
.await?;
|
.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);
|
watch_pending_uploads(&self.ctx);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -746,6 +866,13 @@ fn write_multipart_body(
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancelled_watcher_releases_process_claim() {
|
||||||
|
WATCHING.store(true, Ordering::SeqCst);
|
||||||
|
drop(WatchingGuard);
|
||||||
|
assert!(!WATCHING.load(Ordering::SeqCst));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn multipart_body_is_deterministic_and_keeps_exact_media_bytes() {
|
fn multipart_body_is_deterministic_and_keeps_exact_media_bytes() {
|
||||||
let directory = tempfile::tempdir().unwrap();
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
|
|
||||||
|
|
@ -5,16 +5,17 @@
|
||||||
|
|
||||||
//! Image codec work, previously done by `flutter_image_compress`.
|
//! Image codec work, previously done by `flutter_image_compress`.
|
||||||
//!
|
//!
|
||||||
//! Both platforms encode WebP with libwebp — iOS has no system WebP encoder at
|
//! Rust links libwebp directly. The previous Flutter plugin used Android's
|
||||||
//! all and Android's `Bitmap.compress` routes through Skia to the same library
|
//! system `Bitmap` pipeline and SDWebImageWebPCoder on iOS; those may ultimately
|
||||||
//! — so linking libwebp here is the identical code path without the Dart hop.
|
//! use libwebp too, but their resize, pixel format, build, and encoder settings
|
||||||
//! Only formats this crate cannot decode (HEIC/HEIF/AVIF) are handed to the
|
//! are not identical to this path. Only formats this crate cannot decode
|
||||||
//! platform, which decodes them to PNG for us.
|
//! (HEIC/HEIF/AVIF) are handed to the platform, which decodes them to PNG for us.
|
||||||
|
|
||||||
use crate::error::{Result, TwonlyError};
|
use crate::error::{Result, TwonlyError};
|
||||||
use crate::native::image as native_image;
|
use crate::native::image as native_image;
|
||||||
use image::{DynamicImage, ImageReader};
|
use image::{DynamicImage, ImageReader};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
/// Quality the sender's media is encoded with, and the lower quality retried
|
/// Quality the sender's media is encoded with, and the lower quality retried
|
||||||
/// when the first attempt is too large to be worth sending.
|
/// 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
|
/// Encodes with libwebp. RGB input is encoded without an alpha channel so
|
||||||
/// opaque photos do not pay for one.
|
/// opaque photos do not pay for one.
|
||||||
fn encode_webp(image: &DynamicImage, quality: f32) -> Result<Vec<u8>> {
|
fn encode_webp(image: &DynamicImage, quality: f32) -> Result<Vec<u8>> {
|
||||||
|
let total_started = Instant::now();
|
||||||
let has_alpha = image.color().has_alpha();
|
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();
|
let rgba = image.to_rgba8();
|
||||||
webp::Encoder::from_rgba(rgba.as_raw(), rgba.width(), rgba.height())
|
let conversion_ms = elapsed_ms(conversion_started);
|
||||||
.encode_simple(false, quality)
|
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 {
|
} else {
|
||||||
let rgb = image.to_rgb8();
|
let rgb = image.to_rgb8();
|
||||||
webp::Encoder::from_rgb(rgb.as_raw(), rgb.width(), rgb.height())
|
let conversion_ms = elapsed_ms(conversion_started);
|
||||||
.encode_simple(false, quality)
|
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:?}")))?;
|
.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
|
/// 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.
|
/// crate has no decoder for, which in practice means HEIC/HEIF from an iPhone.
|
||||||
pub(crate) fn decode(path: &Path) -> Result<DynamicImage> {
|
pub(crate) fn decode(path: &Path) -> Result<DynamicImage> {
|
||||||
|
let total_started = Instant::now();
|
||||||
|
let source_bytes = file_size(path);
|
||||||
let reader = ImageReader::open(path)?
|
let reader = ImageReader::open(path)?
|
||||||
.with_guessed_format()
|
.with_guessed_format()
|
||||||
.map_err(|error| TwonlyError::Generic(error.to_string()))?;
|
.map_err(|error| TwonlyError::Generic(error.to_string()))?;
|
||||||
|
let format = reader.format();
|
||||||
|
let rust_decode_started = Instant::now();
|
||||||
match reader.decode() {
|
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) => {
|
Err(error) => {
|
||||||
|
let rust_decode_ms = elapsed_ms(rust_decode_started);
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
path = %path.display(),
|
path = %path.display(),
|
||||||
%error,
|
%error,
|
||||||
"asking the platform to decode an unsupported image format"
|
"asking the platform to decode an unsupported image format"
|
||||||
);
|
);
|
||||||
let decoded = path.with_extension("decoded.png");
|
let decoded = path.with_extension("decoded.png");
|
||||||
|
let platform_started = Instant::now();
|
||||||
native_image::decode_to_png(path, &decoded)?;
|
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)?
|
let image = ImageReader::open(&decoded)?
|
||||||
.with_guessed_format()
|
.with_guessed_format()
|
||||||
.map_err(|error| TwonlyError::Generic(error.to_string()))?
|
.map_err(|error| TwonlyError::Generic(error.to_string()))?
|
||||||
.decode()
|
.decode()
|
||||||
.map_err(|error| TwonlyError::Generic(error.to_string()))?;
|
.map_err(|error| TwonlyError::Generic(error.to_string()))?;
|
||||||
|
let png_decode_ms = elapsed_ms(png_decode_started);
|
||||||
let _ = std::fs::remove_file(&decoded);
|
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)
|
Ok(image)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Produces the file that actually gets encrypted and uploaded. A first pass at
|
/// Produces the file that actually gets encrypted and uploaded. Unlike the old
|
||||||
/// high quality is re-encoded lower only when the result is big enough that the
|
/// 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.
|
/// quality loss is worth the transfer.
|
||||||
pub(crate) fn compress_for_send(source: &Path, destination: &Path) -> Result<()> {
|
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 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 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 {
|
if encoded.len() >= LARGE_IMAGE_BYTES {
|
||||||
|
let low_quality_started = Instant::now();
|
||||||
match encode_webp(&image, SEND_QUALITY_LARGE) {
|
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) => {
|
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");
|
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
|
/// 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
|
/// an already small image up. A GIF decodes to its first frame, which is
|
||||||
/// exactly what its thumbnail should show.
|
/// exactly what its thumbnail should show.
|
||||||
pub(crate) fn create_image_thumbnail(source: &Path, destination: &Path) -> Result<()> {
|
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 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 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)?;
|
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 {
|
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
|
/// Trims fully transparent borders left by the editor. Returns whether the
|
||||||
/// image was rewritten.
|
/// image was rewritten.
|
||||||
pub(crate) fn crop_transparent_borders(path: &Path) -> Result<bool> {
|
pub(crate) fn crop_transparent_borders(path: &Path) -> Result<bool> {
|
||||||
|
|
@ -178,6 +326,16 @@ fn write_atomically(destination: &Path, bytes: &[u8]) -> Result<()> {
|
||||||
Ok(())
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ use crate::context::Context;
|
||||||
use crate::database::app::tables::{Group, MediaFile};
|
use crate::database::app::tables::{Group, MediaFile};
|
||||||
use crate::error::{Result, TwonlyError};
|
use crate::error::{Result, TwonlyError};
|
||||||
use crate::native::gallery;
|
use crate::native::gallery;
|
||||||
|
use crate::native::prepare;
|
||||||
use crate::native::video;
|
use crate::native::video;
|
||||||
use crate::services::direct_media_upload::DirectMediaUploadService;
|
use crate::services::direct_media_upload::DirectMediaUploadService;
|
||||||
use crate::services::media_codec;
|
use crate::services::media_codec;
|
||||||
|
|
@ -66,6 +67,8 @@ struct MediaRow {
|
||||||
display_limit_in_milliseconds: Option<i64>,
|
display_limit_in_milliseconds: Option<i64>,
|
||||||
reupload_requested_by: Option<String>,
|
reupload_requested_by: Option<String>,
|
||||||
remove_audio: Option<i64>,
|
remove_audio: Option<i64>,
|
||||||
|
trim_start_ms: Option<i64>,
|
||||||
|
trim_end_ms: Option<i64>,
|
||||||
created_at: i64,
|
created_at: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -202,12 +205,100 @@ impl MediaUploadService {
|
||||||
|
|
||||||
// Preparation compresses and encrypts, which is far too slow to keep the
|
// Preparation compresses and encrypts, which is far too slow to keep the
|
||||||
// send button blocked; the media row is already durable at this point.
|
// send button blocked; the media row is already durable at this point.
|
||||||
|
// 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();
|
let ctx = self.ctx.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(error) = MediaUploadService::new(&ctx).start_upload(&media_id).await {
|
// 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");
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -318,7 +409,7 @@ impl MediaUploadService {
|
||||||
let pending = sqlx::query_as::<_, MediaRow>(
|
let pending = sqlx::query_as::<_, MediaRow>(
|
||||||
r#"SELECT media_id, type AS media_type, upload_state, requires_authentication,
|
r#"SELECT media_id, type AS media_type, upload_state, requires_authentication,
|
||||||
is_draft_media, display_limit_in_milliseconds, reupload_requested_by,
|
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
|
FROM media_files
|
||||||
WHERE upload_state IN
|
WHERE upload_state IN
|
||||||
('initialized', 'preprocessing', 'uploading', 'uploadLimitReached')"#,
|
('initialized', 'preprocessing', 'uploading', 'uploadLimitReached')"#,
|
||||||
|
|
@ -861,6 +952,28 @@ impl MediaUploadService {
|
||||||
Ok(())
|
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<()> {
|
pub async fn toggle_remove_audio(&self, media_id: &str) -> Result<()> {
|
||||||
let database = self.ctx.app_db.read().await.clone();
|
let database = self.ctx.app_db.read().await.clone();
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
|
|
@ -869,6 +982,11 @@ impl MediaUploadService {
|
||||||
.bind(media_id)
|
.bind(media_id)
|
||||||
.execute(&database.pool)
|
.execute(&database.pool)
|
||||||
.await?;
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -956,7 +1074,7 @@ impl MediaUploadService {
|
||||||
Ok(sqlx::query_as::<_, MediaRow>(
|
Ok(sqlx::query_as::<_, MediaRow>(
|
||||||
r#"SELECT media_id, type AS media_type, upload_state, requires_authentication,
|
r#"SELECT media_id, type AS media_type, upload_state, requires_authentication,
|
||||||
is_draft_media, display_limit_in_milliseconds, reupload_requested_by,
|
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 = ?"#,
|
FROM media_files WHERE media_id = ?"#,
|
||||||
)
|
)
|
||||||
.bind(media_id)
|
.bind(media_id)
|
||||||
|
|
@ -989,15 +1107,28 @@ impl MediaUploadService {
|
||||||
"video" => self.render_video(media, &original, &temp).await,
|
"video" => self.render_video(media, &original, &temp).await,
|
||||||
"image" => {
|
"image" => {
|
||||||
let (source, destination) = (original.clone(), temp.clone());
|
let (source, destination) = (original.clone(), temp.clone());
|
||||||
if let Err(error) =
|
let started = std::time::Instant::now();
|
||||||
blocking(move || media_codec::compress_for_send(&source, &destination)).await
|
let mut used_uncompressed_fallback = false;
|
||||||
|
match blocking(move || media_codec::compress_for_send(&source, &destination)).await
|
||||||
{
|
{
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(error) => {
|
||||||
// Sending the original beats not sending at all, which is
|
// Sending the original beats not sending at all, which is
|
||||||
// what the Flutter implementation did on a codec failure.
|
// what the Flutter implementation did on a codec failure.
|
||||||
tracing::warn!(media_id = media.media_id, %error, "sending the uncompressed image");
|
tracing::warn!(media_id = media.media_id, %error, "sending the uncompressed image");
|
||||||
MediaFileService::ensure_parent(&temp)?;
|
MediaFileService::ensure_parent(&temp)?;
|
||||||
std::fs::copy(&original, &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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
// GIF keeps its animation and audio is already in its delivery
|
// 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<()> {
|
async fn render_video(&self, media: &MediaRow, original: &Path, temp: &Path) -> Result<()> {
|
||||||
let files = MediaFileService::new(&self.ctx);
|
let files = MediaFileService::new(&self.ctx);
|
||||||
let overlay = files.overlay_image_path(&media.media_id);
|
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
|
// 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
|
// camera produces varies by device and is not guaranteed to play on the
|
||||||
// other platform; the render is what makes the output predictable.
|
// other platform; the render is what makes the output predictable.
|
||||||
|
let (trim_start_ms, trim_end_ms) = trim_bounds(media);
|
||||||
let request = video::RenderRequest {
|
let request = video::RenderRequest {
|
||||||
media_id: media.media_id.clone(),
|
media_id: media.media_id.clone(),
|
||||||
input: original.to_path_buf(),
|
input: original.to_path_buf(),
|
||||||
overlay,
|
overlay,
|
||||||
output: temp.to_path_buf(),
|
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();
|
let media_id = media.media_id.clone();
|
||||||
if let Err(error) = blocking(move || video::render(&request)).await {
|
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
|
/// Image codecs are CPU-bound: a full-resolution photo takes long enough to
|
||||||
/// encode that it must not run on an async worker.
|
/// encode that it must not run on an async worker.
|
||||||
async fn blocking<F, T>(task: F) -> Result<T>
|
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
|
/// Drift persists this column as a JSON array of contact ids. Tolerate the
|
||||||
/// single-scalar form that an earlier Rust write produced.
|
/// single-scalar form that an earlier Rust write produced.
|
||||||
fn parse_reupload_requested_by(value: Option<&str>) -> Vec<i64> {
|
fn parse_reupload_requested_by(value: Option<&str>) -> Vec<i64> {
|
||||||
|
|
|
||||||
|
|
@ -378,6 +378,9 @@ impl MediaFileService {
|
||||||
base.join("tmp")
|
base.join("tmp")
|
||||||
.join(format!("{media_id}.ffmpeg.{extension}")),
|
.join(format!("{media_id}.ffmpeg.{extension}")),
|
||||||
base.join("tmp").join(format!("{media_id}.overlay.png")),
|
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}.{extension}")),
|
||||||
base.join("stored")
|
base.join("stored")
|
||||||
.join(format!("{media_id}.thumbnail.webp")),
|
.join(format!("{media_id}.thumbnail.webp")),
|
||||||
|
|
@ -420,6 +423,42 @@ impl MediaFileService {
|
||||||
self.media_path("tmp", media_id, ".upload", Self::extension(media_type))
|
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 {
|
pub(crate) fn temp_path(&self, media_id: &str, media_type: &str) -> PathBuf {
|
||||||
PathBuf::from(&self.ctx.config.data_dir)
|
PathBuf::from(&self.ctx.config.data_dir)
|
||||||
.join("mediafiles/tmp")
|
.join("mediafiles/tmp")
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
pub mod avatars;
|
pub mod avatars;
|
||||||
|
pub mod background;
|
||||||
pub mod contacts;
|
pub mod contacts;
|
||||||
pub mod direct_media_upload;
|
pub mod direct_media_upload;
|
||||||
pub mod groups;
|
pub mod groups;
|
||||||
|
|
@ -13,3 +14,4 @@ pub mod media_upload;
|
||||||
pub mod mediafiles;
|
pub mod mediafiles;
|
||||||
pub mod messages;
|
pub mod messages;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
|
pub mod outbox_dispatch;
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
use crate::api::proto::client::{self as proto, encrypted_content};
|
use crate::api::proto::client::{self as proto, encrypted_content};
|
||||||
use crate::api::runtime::ApiRuntime;
|
use crate::api::runtime::ApiRuntime;
|
||||||
use crate::bridge::InitConfig;
|
use crate::bridge::InitConfig;
|
||||||
use crate::context::{Context, RuntimeMode};
|
use crate::context::Context;
|
||||||
use crate::database::app::AppDatabase;
|
use crate::database::app::AppDatabase;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::user_config::UserConfig;
|
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 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();
|
let generation = ctx.mailbox_generation();
|
||||||
ApiRuntime::connect(&ctx).await?;
|
ApiRuntime::connect(&ctx).await?;
|
||||||
tokio::time::timeout(deadline, ctx.wait_for_mailbox_after(generation))
|
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?;
|
let mut batch = pending_batch(&ctx, locale).await?;
|
||||||
batch.completed = completed;
|
batch.completed = completed;
|
||||||
|
|
||||||
if ctx.runtime_mode == RuntimeMode::Notification {
|
if owns_connection && ctx.is_notification_runtime() {
|
||||||
ApiRuntime::close(&ctx).await?;
|
ApiRuntime::close(&ctx).await?;
|
||||||
}
|
}
|
||||||
Ok(batch)
|
Ok(batch)
|
||||||
|
|
|
||||||
251
rust/src/services/outbox_dispatch.rs
Normal file
251
rust/src/services/outbox_dispatch.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,18 @@ char *twonly_notification_acknowledge(const char *event_ids_json);
|
||||||
|
|
||||||
void twonly_notification_string_free(char *pointer);
|
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
|
#ifdef __cplusplus
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
|
||||||
180
test/visual/video_recording_budget_test.dart
Normal file
180
test/visual/video_recording_budget_test.dart
Normal 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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue