direct media upload

This commit is contained in:
otsmr 2026-08-31 15:24:18 +02:00
parent da751cbded
commit bd198acd0c
185 changed files with 10330 additions and 4393 deletions

View file

@ -76,6 +76,11 @@ dependencies {
implementation platform('com.google.firebase:firebase-bom:34.9.0')
implementation 'com.google.firebase:firebase-messaging'
implementation 'androidx.work:work-runtime:2.10.2'
implementation 'com.otaliastudios:transcoder:0.11.0'
// Hardware video composition and transcoding: MediaCodec for decode/encode,
// OpenGL for the overlay. Replaces both the software Flutter editor pass and
// the previous third-party transcoder.
implementation 'androidx.media3:media3-transformer:1.11.0'
implementation 'androidx.media3:media3-effect:1.11.0'
implementation 'androidx.media3:media3-common:1.11.0'
implementation 'androidx.core:core-splashscreen:1.0.1'
}

View file

@ -78,6 +78,10 @@
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
<!-- Twonly owns background FCM delivery natively. Keep the Flutter
plugin for foreground permission/token APIs, but never allow it
to launch a Dart background isolate. -->
@ -96,6 +100,9 @@
</application>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>

View file

@ -68,7 +68,6 @@ class MainActivity : FlutterFragmentActivity() {
Keyring.initializeNdkContext(applicationContext)
VideoCompressionChannel.configure(flutterEngine, applicationContext)
NotificationTapChannel.configure(flutterEngine, applicationContext)

View file

@ -1,15 +1,17 @@
package eu.twonly
import io.flutter.app.FlutterApplication
import dev.fluttercommunity.workmanager.WorkmanagerDebug
import dev.fluttercommunity.workmanager.LoggingDebugHandler
import io.crates.keyring.Keyring
class MyApplication : FlutterApplication() {
companion object {
lateinit var instance: MyApplication
private set
}
override fun onCreate() {
super.onCreate()
instance = this
Keyring.initializeNdkContext(this)
// This enables the internal plugin logging to Logcat
WorkmanagerDebug.setCurrent(LoggingDebugHandler())
}
}
}

View file

@ -1,104 +0,0 @@
package eu.twonly
import android.content.Context
import android.media.MediaFormat
import android.os.Handler
import android.os.Looper
import com.otaliastudios.transcoder.Transcoder
import com.otaliastudios.transcoder.TranscoderListener
import com.otaliastudios.transcoder.strategy.DefaultVideoStrategy
import com.otaliastudios.transcoder.strategy.PassThroughTrackStrategy
import com.otaliastudios.transcoder.strategy.TrackStrategy
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
object VideoCompressionChannel {
private const val CHANNEL = "eu.twonly/videoCompression"
// Compression parameters defined natively (as requested)
private const val VIDEO_BITRATE = 2_000_000L // 2 Mbps
fun configure(flutterEngine: FlutterEngine, context: Context) {
val channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
channel.setMethodCallHandler { call, result ->
try {
if (call.method == "compressVideo") {
val arguments = call.arguments<Map<String, Any>>() ?: emptyMap()
val inputPath = arguments["input"] as? String
val outputPath = arguments["output"] as? String
if (inputPath == null || outputPath == null) {
result.error("INVALID_ARGS", "Input or output path missing", null)
return@setMethodCallHandler
}
val mainHandler = Handler(Looper.getMainLooper())
val baseVideoStrategy = DefaultVideoStrategy.Builder()
.keyFrameInterval(3f)
.bitRate(VIDEO_BITRATE)
.addResizer(com.otaliastudios.transcoder.resize.AtMostResizer(1920, 1080))
.build()
val trackStrategyClass = TrackStrategy::class.java
val hevcStrategy = java.lang.reflect.Proxy.newProxyInstance(
trackStrategyClass.classLoader,
arrayOf(trackStrategyClass)
) { _, method, args ->
val result = if (args != null) method.invoke(baseVideoStrategy, *args) else method.invoke(baseVideoStrategy)
if (method.name == "createOutputFormat" && result is MediaFormat) {
result.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_VIDEO_HEVC)
if (result.containsKey(MediaFormat.KEY_WIDTH) && result.containsKey(MediaFormat.KEY_HEIGHT)) {
val width = result.getInteger(MediaFormat.KEY_WIDTH)
val height = result.getInteger(MediaFormat.KEY_HEIGHT)
// Align dimensions to a multiple of 16 to prevent edge artifacts (green lines/distortions)
result.setInteger(MediaFormat.KEY_WIDTH, width - (width % 16))
result.setInteger(MediaFormat.KEY_HEIGHT, height - (height % 16))
}
}
result
} as TrackStrategy
Transcoder.into(outputPath)
.addDataSource(inputPath)
.setVideoTrackStrategy(hevcStrategy)
.setAudioTrackStrategy(PassThroughTrackStrategy())
.setListener(object : TranscoderListener {
override fun onTranscodeProgress(progress: Double) {
mainHandler.post {
val mappedProgress = (progress * 100).toInt()
channel.invokeMethod("onProgress", mapOf("progress" to mappedProgress))
}
}
override fun onTranscodeCompleted(successCode: Int) {
mainHandler.post {
result.success(outputPath)
}
}
override fun onTranscodeCanceled() {
mainHandler.post {
result.error("CANCELED", "Video compression canceled", null)
}
}
override fun onTranscodeFailed(exception: Throwable) {
mainHandler.post {
result.error("FAILED", exception.message, null)
}
}
})
.transcode()
} else {
result.notImplemented()
}
} catch (e: Exception) {
result.error("EXCEPTION", e.message, null)
}
}
}
}

View file

@ -0,0 +1,64 @@
package eu.twonly.directmedia
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import eu.twonly.MyApplication
import java.io.File
import java.util.concurrent.TimeUnit
import org.json.JSONObject
/** Called directly from Rust/JNI. It persists the descriptor in app-private
* storage and enqueues both OS-owned transfers before reporting success. */
object DirectMediaTransfer {
@JvmStatic
fun schedule(descriptorJson: String): Boolean = try {
val descriptor = JSONObject(descriptorJson)
val attachmentId = descriptor.getString("attachment_id")
val expiresAt = descriptor.getLong("expires_at")
if (expiresAt <= System.currentTimeMillis() / 1_000) return false
val directory = File(MyApplication.instance.noBackupFilesDir, "direct-media/$attachmentId")
if (!directory.exists() && !directory.mkdirs()) return false
val descriptorFile = File(directory, "descriptor.json")
descriptorFile.writeText(descriptorJson, Charsets.UTF_8)
val manager = WorkManager.getInstance(MyApplication.instance)
val media = request(attachmentId, "media", descriptorFile, expiresAt)
val manifest = request(attachmentId, "manifest", descriptorFile, expiresAt)
manager.enqueueUniqueWork("direct-media-$attachmentId-media", ExistingWorkPolicy.KEEP, media)
manager.enqueueUniqueWork(
"direct-media-$attachmentId-manifest",
ExistingWorkPolicy.KEEP,
manifest,
)
true
} catch (_: Throwable) {
false
}
private fun request(
attachmentId: String,
role: String,
descriptorFile: File,
expiresAt: Long,
): OneTimeWorkRequest {
val data = Data.Builder()
.putString(DirectMediaUploadWorker.ATTACHMENT_ID, attachmentId)
.putString(DirectMediaUploadWorker.ROLE, role)
.putString(DirectMediaUploadWorker.DESCRIPTOR_PATH, descriptorFile.absolutePath)
.putLong(DirectMediaUploadWorker.EXPIRES_AT, expiresAt)
.build()
return OneTimeWorkRequest.Builder(DirectMediaUploadWorker::class.java)
.setInputData(data)
.setConstraints(
Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build(),
)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 15, TimeUnit.SECONDS)
.addTag("direct-media-$attachmentId")
.build()
}
}

View file

@ -0,0 +1,110 @@
package eu.twonly.directmedia
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.pm.ServiceInfo
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.work.ForegroundInfo
import androidx.work.Worker
import androidx.work.WorkerParameters
import eu.twonly.R
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import org.json.JSONObject
/** Transport-only worker: request construction, protobufs, encryption and
* policy decisions have already been completed and persisted by Rust. */
class DirectMediaUploadWorker(
appContext: Context,
params: WorkerParameters,
) : Worker(appContext, params) {
override fun doWork(): Result {
val attachmentId = inputData.getString(ATTACHMENT_ID) ?: return Result.failure()
val role = inputData.getString(ROLE) ?: return Result.failure()
val expiresAt = inputData.getLong(EXPIRES_AT, 0)
if (expiresAt <= System.currentTimeMillis() / 1_000) return Result.failure()
return try {
// Become a data-sync foreground service before opening the file/network
// streams, otherwise Android may stop long uploads in the background.
setForegroundAsync(foregroundInfo(attachmentId)).get()
val path = inputData.getString(DESCRIPTOR_PATH) ?: return Result.failure()
val descriptor = JSONObject(File(path).readText(Charsets.UTF_8))
val request = descriptor.getJSONObject(role)
val status = upload(request)
if (status in 200..299) {
if (role == "media") {
// Best effort only. A 202 is success because server reconciliation
// owns completion when the manifest has not arrived yet.
upload(descriptor.getJSONObject("complete"))
}
Result.success()
} else if (retryable(status) && expiresAt > System.currentTimeMillis() / 1_000) {
Result.retry()
} else {
Result.failure()
}
} catch (_: Throwable) {
if (expiresAt > System.currentTimeMillis() / 1_000) Result.retry() else Result.failure()
}
}
private fun upload(request: JSONObject): Int {
val file = File(request.getString("body_path"))
if (!file.isFile) return 0
val connection = (URL(request.getString("url")).openConnection() as HttpURLConnection).apply {
requestMethod = request.optString("method", "POST")
connectTimeout = 30_000
readTimeout = 60_000
doOutput = true
instanceFollowRedirects = false
val headers = request.getJSONObject("headers")
for (name in headers.keys()) setRequestProperty(name, headers.getString(name))
setFixedLengthStreamingMode(file.length())
}
connection.outputStream.use { output ->
file.inputStream().use { input -> input.copyTo(output, DEFAULT_BUFFER_SIZE) }
}
val status = connection.responseCode
try {
(if (status >= 400) connection.errorStream else connection.inputStream)?.close()
} finally {
connection.disconnect()
}
return status
}
private fun retryable(status: Int): Boolean =
status == 0 || status == 408 || status == 429 || status >= 500
private fun foregroundInfo(attachmentId: String): ForegroundInfo {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
applicationContext.getSystemService(NotificationManager::class.java).createNotificationChannel(
NotificationChannel(CHANNEL_ID, "Media uploads", NotificationManager.IMPORTANCE_LOW),
)
}
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle("Uploading encrypted media")
.setContentText("twonly will finish this upload in the background")
.setOngoing(true)
.setOnlyAlertOnce(true)
.build()
val id = attachmentId.hashCode() and 0x7fffffff
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ForegroundInfo(id, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
ForegroundInfo(id, notification)
}
}
companion object {
const val ATTACHMENT_ID = "attachment_id"
const val ROLE = "role"
const val DESCRIPTOR_PATH = "descriptor_path"
const val EXPIRES_AT = "expires_at"
private const val CHANNEL_ID = "twonly_direct_media_upload"
}
}

View file

@ -0,0 +1,79 @@
package eu.twonly.media
import android.content.ContentValues
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import eu.twonly.MyApplication
import java.io.File
/**
* Writes a finished media file into the user's photo library.
*
* Called directly from Rust/JNI. Rust decides whether an export should happen,
* embeds the EXIF metadata beforehand, and owns the resulting state; this only
* hands the bytes to MediaStore.
*/
object NativeGallery {
private const val ALBUM = "twonly"
@JvmStatic
fun save(path: String, isVideo: Boolean, displayName: String, createdAtMillis: Long): Boolean {
val source = File(path)
if (!source.isFile) return false
val resolver = MyApplication.instance.contentResolver
val collection = if (isVideo) {
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
} else {
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
}
val extension = source.extension.ifEmpty { if (isVideo) "mp4" else "webp" }
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, "$displayName.$extension")
put(MediaStore.MediaColumns.MIME_TYPE, mimeType(isVideo, extension))
// Galleries sort on this, so it has to carry the capture time
// rather than the moment the file was exported.
put(MediaStore.MediaColumns.DATE_ADDED, createdAtMillis / 1000)
put(MediaStore.MediaColumns.DATE_MODIFIED, createdAtMillis / 1000)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val directory = if (isVideo) Environment.DIRECTORY_MOVIES else Environment.DIRECTORY_PICTURES
put(MediaStore.MediaColumns.RELATIVE_PATH, "$directory/$ALBUM")
// Hide the entry until the bytes are fully written.
put(MediaStore.MediaColumns.IS_PENDING, 1)
}
put(
if (isVideo) MediaStore.Video.Media.DATE_TAKEN else MediaStore.Images.Media.DATE_TAKEN,
createdAtMillis,
)
}
val uri = runCatching { resolver.insert(collection, values) }.getOrNull() ?: return false
return try {
resolver.openOutputStream(uri).use { output ->
if (output == null) return@use false
source.inputStream().use { input -> input.copyTo(output) }
true
}.also { written ->
if (written && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
resolver.update(
uri,
ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) },
null,
null,
)
}
if (!written) resolver.delete(uri, null, null)
}
} catch (_: Throwable) {
runCatching { resolver.delete(uri, null, null) }
false
}
}
private fun mimeType(isVideo: Boolean, extension: String): String = when {
isVideo -> "video/mp4"
extension.equals("gif", ignoreCase = true) -> "image/gif"
extension.equals("png", ignoreCase = true) -> "image/png"
else -> "image/webp"
}
}

View file

@ -0,0 +1,34 @@
package eu.twonly.media
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import java.io.File
/**
* Called directly from Rust/JNI for the container formats Rust has no decoder
* for, HEIC/HEIF above all. Rust owns every decision around this; the only job
* here is to hand back pixels in a format Rust can read.
*
* HEIF decoding needs API 28. Below that the decode fails and Rust falls back
* to sending the original file, exactly as it does for any unreadable input.
*/
object NativeImageCodec {
@JvmStatic
fun decodeToPng(inputPath: String, outputPath: String): Boolean {
var bitmap: Bitmap? = null
return try {
bitmap = BitmapFactory.decodeFile(inputPath) ?: return false
val output = File(outputPath)
output.parentFile?.mkdirs()
output.outputStream().use { stream ->
// PNG is lossless, so nothing is thrown away before Rust
// re-encodes to WebP at the quality it chose.
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
}
} catch (_: Throwable) {
false
} finally {
bitmap?.recycle()
}
}
}

View file

@ -0,0 +1,331 @@
package eu.twonly.media
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.media.MediaExtractor
import android.media.MediaFormat
import android.media.MediaMetadataRetriever
import android.util.Log
import android.os.Handler
import android.os.Looper
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import androidx.media3.effect.BitmapOverlay
import androidx.media3.effect.FrameDropEffect
import androidx.media3.effect.OverlayEffect
import androidx.media3.effect.Presentation
import androidx.media3.effect.TextureOverlay
import androidx.media3.transformer.Composition
import androidx.media3.transformer.DefaultEncoderFactory
import androidx.media3.transformer.EditedMediaItem
import androidx.media3.transformer.Effects
import androidx.media3.transformer.ExportException
import androidx.media3.transformer.ExportResult
import androidx.media3.transformer.ProgressHolder
import androidx.media3.transformer.Transformer
import androidx.media3.transformer.VideoEncoderSettings
import com.google.common.collect.ImmutableList
import eu.twonly.MyApplication
import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* Burns the editor's overlay into the video and transcodes it, in a single
* hardware pass. Media3 `Transformer` decodes and encodes through MediaCodec and
* composites the overlay on the GPU with OpenGL, so no software codec and no
* Flutter engine is involved.
*
* Called directly from Rust/JNI. Rust decides whether a render is needed at all
* and owns every state transition around it; this only performs the work.
*/
@UnstableApi
object NativeVideoCodec {
private const val TAG = "NativeVideoCodec"
/**
* Every send is normalised to 720p30 regardless of plan. The server caps a
* single media object at 50MB on the free plan and 100MB on the paid ones,
* and 720p30 is the largest format that keeps a clip of ordinary length
* comfortably under the smaller of the two.
*/
private const val SHORT_SIDE = 720
private const val MAX_FRAME_RATE = 30.0
/**
* Bits per pixel per frame asked of the encoder. HEVC stays close to the
* source at roughly this rate; below it motion smears into blocks, and above
* it the extra bits go to detail a phone camera never recorded. The bitrate
* is derived from the output size and frame rate rather than fixed, so a
* clip that is downscaled hard is not given the same budget as one that is
* already 720p. Kept in sync with the same constants in the iOS renderer so
* the same clip looks the same whichever platform sent it.
*/
private const val BITS_PER_PIXEL_PER_FRAME = 0.12
private const val MIN_BITRATE = 1_500_000
private const val MAX_BITRATE = 4_000_000
/** 720p30 at the rate above, used when the source cannot be probed. */
private const val DEFAULT_BITRATE = 3_300_000
private const val DEFAULT_FRAME_RATE = 30.0
private const val PROGRESS_INTERVAL_MS = 500L
/** No send should hold a background worker hostage indefinitely. */
private const val RENDER_TIMEOUT_MINUTES = 30L
@JvmStatic
external fun reportProgress(mediaId: String, percent: Int)
@JvmStatic
fun render(
inputPath: String,
overlayPath: String?,
outputPath: String,
removeAudio: Boolean,
mediaId: String,
): Boolean {
val context = MyApplication.instance
val output = File(outputPath)
output.parentFile?.mkdirs()
// Transformer refuses to write over an existing file.
output.delete()
val source = probe(inputPath)
val bitrate = bitrateFor(source)
val finished = CountDownLatch(1)
var succeeded = false
// Transformer posts its callbacks through a Looper, so it has to be
// driven from the main thread while the calling Rust thread waits.
val handler = Handler(Looper.getMainLooper())
var transformer: Transformer? = null
handler.post {
try {
val effects = buildEffects(overlayPath, source)
val editedItem = EditedMediaItem.Builder(MediaItem.fromUri(File(inputPath).toURI().toString()))
.setRemoveAudio(removeAudio)
.setEffects(effects)
.build()
val built = Transformer.Builder(context)
.setVideoMimeType(MimeTypes.VIDEO_H265)
// Without this the source audio is transmuxed untouched, and
// Android cameras record AMR-NB, which iOS cannot decode at
// all: AVPlayer then refuses the whole asset and the video
// never appears. AAC is the only audio codec both platforms
// are guaranteed to support.
.setAudioMimeType(MimeTypes.AUDIO_AAC)
.setEncoderFactory(
DefaultEncoderFactory.Builder(context)
.setRequestedVideoEncoderSettings(
VideoEncoderSettings.Builder().setBitrate(bitrate).build(),
)
// Falling back lets a device without an HEVC encoder
// still produce a file rather than failing the send.
.setEnableFallback(true)
.build(),
)
.addListener(
object : Transformer.Listener {
override fun onCompleted(composition: Composition, result: ExportResult) {
// Recorded so a file the recipient cannot play
// can be diagnosed from a log rather than a
// round trip between two devices.
Log.i(
TAG,
"rendered ${result.width}x${result.height} " +
"${result.videoMimeType}/${result.audioMimeType} " +
"@ ${result.averageVideoBitrate}bps " +
"(asked ${bitrate}bps for $source)",
)
succeeded = true
finished.countDown()
}
override fun onError(
composition: Composition,
result: ExportResult,
exception: ExportException,
) {
Log.e(TAG, "video render failed", exception)
succeeded = false
finished.countDown()
}
},
)
.build()
transformer = built
built.start(editedItem, outputPath)
pollProgress(handler, built, mediaId, finished)
} catch (_: Throwable) {
succeeded = false
finished.countDown()
}
}
val completed = finished.await(RENDER_TIMEOUT_MINUTES, TimeUnit.MINUTES)
if (!completed) {
handler.post { runCatching { transformer?.cancel() } }
return false
}
return succeeded && output.isFile && output.length() > 0
}
/**
* Grabs the first frame as a PNG. Only the decode needs the platform; Rust
* scales and encodes the thumbnail itself, exactly as it does for stills.
*/
@JvmStatic
fun extractFrame(inputPath: String, outputPath: String): Boolean {
val retriever = MediaMetadataRetriever()
var frame: Bitmap? = null
return try {
retriever.setDataSource(inputPath)
frame = retriever.getFrameAtTime(0, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)
?: return false
val output = File(outputPath)
output.parentFile?.mkdirs()
output.outputStream().use { stream ->
frame.compress(Bitmap.CompressFormat.PNG, 100, stream)
}
} catch (_: Throwable) {
false
} finally {
frame?.recycle()
runCatching { retriever.release() }
}
}
/** What the source actually is, as far as the platform will tell us. */
private data class SourceVideo(val width: Int, val height: Int, val frameRate: Double)
/**
* Reads the displayed size and the frame rate. Both only steer the encoder
* settings, so a source that refuses to be probed still renders it just
* falls back to the 1080p30 defaults.
*/
private fun probe(inputPath: String): SourceVideo? {
val retriever = MediaMetadataRetriever()
return try {
retriever.setDataSource(inputPath)
fun key(id: Int) = retriever.extractMetadata(id)?.toIntOrNull()
val storedWidth = key(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) ?: return null
val storedHeight = key(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) ?: return null
if (storedWidth <= 0 || storedHeight <= 0) return null
// The frame is stored unrotated; a portrait clip from the camera is
// a landscape frame plus a rotation, and it is the displayed size
// that gets scaled and encoded.
val rotation = key(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) ?: 0
val sideways = rotation % 180 != 0
SourceVideo(
width = if (sideways) storedHeight else storedWidth,
height = if (sideways) storedWidth else storedHeight,
frameRate = frameRate(inputPath),
)
} catch (_: Throwable) {
null
} finally {
runCatching { retriever.release() }
}
}
/**
* `MediaMetadataRetriever` only exposes the frame rate from API 30, so it is
* read off the track format instead, which every supported release has.
*/
private fun frameRate(inputPath: String): Double {
val extractor = MediaExtractor()
return try {
extractor.setDataSource(inputPath)
(0 until extractor.trackCount)
.asSequence()
.map { extractor.getTrackFormat(it) }
.firstOrNull { it.getString(MediaFormat.KEY_MIME)?.startsWith("video/") == true }
// The key is optional, and is an int in practice but a float by
// specification, so neither accessor can be relied on alone.
?.let { format ->
runCatching { format.getInteger(MediaFormat.KEY_FRAME_RATE).toDouble() }
.recoverCatching { format.getFloat(MediaFormat.KEY_FRAME_RATE).toDouble() }
.getOrNull()
}
?.takeIf { it > 0 }
?: DEFAULT_FRAME_RATE
} catch (_: Throwable) {
DEFAULT_FRAME_RATE
} finally {
runCatching { extractor.release() }
}
}
/**
* A rate the output size and frame rate actually justify. A fixed bitrate
* either starves a 1080p60 clip or wastes bits on a 480p one; this spends
* the same amount per pixel either way, within bounds that keep a send both
* watchable and small enough to upload.
*/
private fun bitrateFor(source: SourceVideo?): Int {
if (source == null) return DEFAULT_BITRATE
val scale = outputScale(source)
val pixels = source.width * scale * source.height * scale
val bits = pixels * outputFrameRate(source) * BITS_PER_PIXEL_PER_FRAME
return bits.coerceIn(MIN_BITRATE.toDouble(), MAX_BITRATE.toDouble()).toInt()
}
/** The rate frames actually leave the pipeline at; never above the cap. */
private fun outputFrameRate(source: SourceVideo) = minOf(source.frameRate, MAX_FRAME_RATE)
/** How much the source is shrunk to meet [SHORT_SIDE]; never above 1. */
private fun outputScale(source: SourceVideo): Double {
val shortSide = minOf(source.width, source.height)
return if (shortSide > SHORT_SIDE) SHORT_SIDE.toDouble() / shortSide else 1.0
}
private fun buildEffects(overlayPath: String?, source: SourceVideo?): Effects {
val videoEffects = mutableListOf<androidx.media3.common.Effect>()
// Cap the resolution before the overlay so both scale together. A source
// already below the cap is left alone: scaling it up would only spend
// bits on pixels the camera never recorded.
if (source == null || outputScale(source) < 1.0) {
videoEffects.add(Presentation.createForShortSide(SHORT_SIDE))
}
// A 60fps clip encoded at 30fps spends its whole budget on the frames it
// keeps instead of halving the bits every frame gets. Dropping is only
// asked for when there is something to drop: targeting 30 on a 24fps
// source would make the effect duplicate frames back up to the target.
if (source != null && source.frameRate > MAX_FRAME_RATE) {
videoEffects.add(FrameDropEffect.createDefaultFrameDropEffect(MAX_FRAME_RATE.toFloat()))
}
if (overlayPath != null) {
val bitmap = BitmapFactory.decodeFile(overlayPath)
if (bitmap != null) {
val overlays: ImmutableList<TextureOverlay> =
ImmutableList.of(BitmapOverlay.createStaticBitmapOverlay(bitmap))
videoEffects.add(OverlayEffect(overlays))
}
}
return Effects(ImmutableList.of(), ImmutableList.copyOf(videoEffects))
}
private fun pollProgress(
handler: Handler,
transformer: Transformer,
mediaId: String,
finished: CountDownLatch,
) {
val holder = ProgressHolder()
handler.postDelayed(
object : Runnable {
override fun run() {
if (finished.count == 0L) return
runCatching {
if (transformer.getProgress(holder) == Transformer.PROGRESS_STATE_AVAILABLE) {
reportProgress(mediaId, holder.progress)
}
}
handler.postDelayed(this, PROGRESS_INTERVAL_MS)
}
},
PROGRESS_INTERVAL_MS,
)
}
}

View file

@ -1,16 +1,16 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:twonly/core/frb_generated.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/services/background/callback_dispatcher.background.dart';
import 'package:twonly/main.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() async => RustLib.init());
test('Can initialize twonlyDB and connect to api server', () async {
// Initialize global variables
await initBackgroundExecution();
await AppEnvironment.init();
expect(await twonlyMinimumInitialization(), isFalse);
await userService.tryInit();
// Check the API connection state
final state = await RustApi.connectionState();

View file

@ -4,11 +4,6 @@ PODS:
- cryptography_flutter_plus (0.2.0):
- Flutter
- Flutter (1.0.0)
- flutter_image_compress_common (1.0.0):
- Flutter
- Mantle
- SDWebImage
- SDWebImageWebPCoder
- flutter_sharing_intent (1.0.1):
- Flutter
- flutter_volume_controller (0.0.1):
@ -50,21 +45,6 @@ PODS:
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GTMSessionFetcher/Core (3.5.0)
- libwebp (1.5.0):
- libwebp/demux (= 1.5.0)
- libwebp/mux (= 1.5.0)
- libwebp/sharpyuv (= 1.5.0)
- libwebp/webp (= 1.5.0)
- libwebp/demux (1.5.0):
- libwebp/webp
- libwebp/mux (1.5.0):
- libwebp/demux
- libwebp/sharpyuv (1.5.0)
- libwebp/webp (1.5.0):
- libwebp/sharpyuv
- Mantle (2.2.0):
- Mantle/extobjc (= 2.2.0)
- Mantle/extobjc (2.2.0)
- MLImage (1.0.0-beta8)
- MLKitBarcodeScanning (8.0.0):
- MLKitCommon (~> 14.0)
@ -98,21 +78,12 @@ PODS:
- Flutter
- ScreenProtectorKit (= 1.5.1)
- ScreenProtectorKit (1.5.1)
- SDWebImage (5.21.7):
- SDWebImage/Core (= 5.21.7)
- SDWebImage/Core (5.21.7)
- SDWebImageWebPCoder (0.15.0):
- libwebp (~> 1.0)
- SDWebImage/Core (~> 5.17)
- SwiftProtobuf (1.38.0)
- workmanager_apple (0.0.1):
- Flutter
DEPENDENCIES:
- audio_waveforms (from `.symlinks/plugins/audio_waveforms/ios`)
- cryptography_flutter_plus (from `.symlinks/plugins/cryptography_flutter_plus/ios`)
- Flutter (from `Flutter`)
- flutter_image_compress_common (from `.symlinks/plugins/flutter_image_compress_common/ios`)
- flutter_sharing_intent (from `.symlinks/plugins/flutter_sharing_intent/ios`)
- flutter_volume_controller (from `.symlinks/plugins/flutter_volume_controller/ios`)
- google_mlkit_barcode_scanning (from `.symlinks/plugins/google_mlkit_barcode_scanning/ios`)
@ -122,7 +93,6 @@ DEPENDENCIES:
- rust_lib_twonly (from `.symlinks/plugins/rust_lib_twonly/ios`)
- screen_protector (from `.symlinks/plugins/screen_protector/ios`)
- SwiftProtobuf
- workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`)
SPEC REPOS:
trunk:
@ -131,8 +101,6 @@ SPEC REPOS:
- GoogleToolboxForMac
- GoogleUtilities
- GTMSessionFetcher
- libwebp
- Mantle
- MLImage
- MLKitBarcodeScanning
- MLKitCommon
@ -141,8 +109,6 @@ SPEC REPOS:
- nanopb
- PromisesObjC
- ScreenProtectorKit
- SDWebImage
- SDWebImageWebPCoder
- SwiftProtobuf
EXTERNAL SOURCES:
@ -152,8 +118,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/cryptography_flutter_plus/ios"
Flutter:
:path: Flutter
flutter_image_compress_common:
:path: ".symlinks/plugins/flutter_image_compress_common/ios"
flutter_sharing_intent:
:path: ".symlinks/plugins/flutter_sharing_intent/ios"
flutter_volume_controller:
@ -170,14 +134,11 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/rust_lib_twonly/ios"
screen_protector:
:path: ".symlinks/plugins/screen_protector/ios"
workmanager_apple:
:path: ".symlinks/plugins/workmanager_apple/ios"
SPEC CHECKSUMS:
audio_waveforms: a6dde7fe7c0ea05f06ffbdb0f7c1b2b2ba6cedcf
cryptography_flutter_plus: 44f4e9e4079395fcbb3e7809c0ac2c6ae2d9576f
Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
flutter_image_compress_common: 1697a328fd72bfb335507c6bca1a65fa5ad87df1
flutter_sharing_intent: 0c1e53949f09fa8df8ac2268505687bde8ff264c
flutter_volume_controller: c2be490cb0487e8b88d0d9fc2b7e1c139a4ebccb
google_mlkit_barcode_scanning: 12d8422d8f7b00726dedf9cac00188a2b98750c2
@ -188,8 +149,6 @@ SPEC CHECKSUMS:
GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d
MLImage: 0de5c6c2bf9e93b80ef752e2797f0836f03b58c0
MLKitBarcodeScanning: 39de223e7b1b8a8fbf10816a536dd292d8a39343
MLKitCommon: 47d47b50a031d00db62f1b0efe5a1d8b09a3b2e6
@ -201,10 +160,7 @@ SPEC CHECKSUMS:
rust_lib_twonly: 6586fdf02e31cd8a3ad9f1ee84796da6bbf289bb
screen_protector: 18c6aca2dc5d2a832f6787a5318f97f03e9d3150
ScreenProtectorKit: 6ceb3e0808341a9bc15d175bff40dfdd4b32da71
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377
SwiftProtobuf: d724b5145bfc609d9a49c1e3e3a3dabb07273ffb
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
PODFILE CHECKSUM: f83bbaaed0b8c29b006472e50864d40e54617e24

View file

@ -22,7 +22,10 @@
D21FCEAB2D9F2B750088701D /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D21FCEA42D9F2B750088701D /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
D25D4D1E2EF626E30029F805 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D25D4D1D2EF626E30029F805 /* StoreKit.framework */; };
D25D4D7A2EFF41DB0029F805 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D25D4D702EFF41DB0029F805 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
D2B2E0FF2F63819600E729C1 /* VideoCompressionChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2B2E0FE2F63819600E729C1 /* VideoCompressionChannel.swift */; };
D3A100022F70000100D1A001 /* DirectMediaTransfer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A100012F70000100D1A001 /* DirectMediaTransfer.swift */; };
D3A100032F70000100D1A002 /* NativeImageCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A100042F70000100D1A002 /* NativeImageCodec.swift */; };
D3A100052F70000100D1A003 /* NativeVideoCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A100062F70000100D1A003 /* NativeVideoCodec.swift */; };
D3A100072F70000100D1A004 /* NativeGallery.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A100082F70000100D1A004 /* NativeGallery.swift */; };
F3C66D726A2EB28484DF0B10 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 16FBC6F5B58E1C6646F5D447 /* GoogleService-Info.plist */; };
/* End PBXBuildFile section */
@ -110,7 +113,10 @@
D25D4D1D2EF626E30029F805 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; };
D25D4D702EFF41DB0029F805 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
D25D4D802EFF437F0029F805 /* RunnerDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerDebug.entitlements; sourceTree = "<group>"; };
D2B2E0FE2F63819600E729C1 /* VideoCompressionChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoCompressionChannel.swift; sourceTree = "<group>"; };
D3A100012F70000100D1A001 /* DirectMediaTransfer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectMediaTransfer.swift; sourceTree = "<group>"; };
D3A100042F70000100D1A002 /* NativeImageCodec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeImageCodec.swift; sourceTree = "<group>"; };
D3A100062F70000100D1A003 /* NativeVideoCodec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeVideoCodec.swift; sourceTree = "<group>"; };
D3A100082F70000100D1A004 /* NativeGallery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeGallery.swift; sourceTree = "<group>"; };
DC1EE71614E1B4F84D6FDC2D /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
E190E82D9973B318A389650B /* Pods_ShareExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_ShareExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; };
E96A5ACA32A7118204F050A5 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
@ -241,7 +247,10 @@
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
D2B2E0FE2F63819600E729C1 /* VideoCompressionChannel.swift */,
D3A100012F70000100D1A001 /* DirectMediaTransfer.swift */,
D3A100042F70000100D1A002 /* NativeImageCodec.swift */,
D3A100062F70000100D1A003 /* NativeVideoCodec.swift */,
D3A100082F70000100D1A004 /* NativeGallery.swift */,
D24E27CC2F38ABC10055D9D1 /* RunnerRelease.entitlements */,
D25D4D802EFF437F0029F805 /* RunnerDebug.entitlements */,
D2265DD42D920142000D99BB /* Runner.entitlements */,
@ -637,7 +646,10 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D2B2E0FF2F63819600E729C1 /* VideoCompressionChannel.swift in Sources */,
D3A100022F70000100D1A001 /* DirectMediaTransfer.swift in Sources */,
D3A100032F70000100D1A002 /* NativeImageCodec.swift in Sources */,
D3A100052F70000100D1A003 /* NativeVideoCodec.swift in Sources */,
D3A100072F70000100D1A004 /* NativeGallery.swift in Sources */,
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
);

View file

@ -4,7 +4,6 @@ import Foundation
import UIKit
import UserNotifications
import flutter_sharing_intent
import workmanager_apple
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
@ -14,31 +13,27 @@ import workmanager_apple
) -> Bool {
UNUserNotificationCenter.current().delegate = self
if let registrar = self.registrar(forPlugin: "VideoCompressionChannel") {
VideoCompressionChannel.register(with: registrar.messenger())
}
WorkmanagerDebug.setCurrent(LoggingDebugHandler())
WorkmanagerPlugin.setPluginRegistrantCallback { registry in
GeneratedPluginRegistrant.register(with: registry)
// Background tasks call AppEnvironment.init() too, so this engine needs
// the runtime storage channel just as much as the implicit one.
RuntimeStorageChannel.register(with: registry)
}
WorkmanagerPlugin.registerPeriodicTask(
withIdentifier: "eu.twonly.periodic_task",
frequency: NSNumber(value: 20 * 60)
)
WorkmanagerPlugin.registerBGProcessingTask(
withIdentifier: "eu.twonly.processing_task"
)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
override func application(
_ application: UIApplication,
handleEventsForBackgroundURLSession identifier: String,
completionHandler: @escaping () -> Void
) {
if DirectMediaTransfer.shared.handleEvents(
identifier: identifier,
completion: completionHandler
) {
return
}
super.application(
application,
handleEventsForBackgroundURLSession: identifier,
completionHandler: completionHandler
)
}
override func application(
_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {

View file

@ -0,0 +1,155 @@
import Foundation
private struct DirectMediaRequest: Codable {
let role: String
let url: String
let method: String
let headers: [String: String]
let bodyPath: String
}
private struct DirectMediaDescriptor: Codable {
let attachmentId: String
let expiresAt: Int64
let media: DirectMediaRequest
let manifest: DirectMediaRequest
let complete: DirectMediaRequest
}
/// Thin transport-only adapter. Rust supplies immutable request files and all
/// retry/expiry metadata; Swift only gives those files to background URLSession.
final class DirectMediaTransfer: NSObject, URLSessionTaskDelegate, URLSessionDelegate {
static let shared = DirectMediaTransfer()
static let sessionIdentifier = "eu.twonly.direct-media-transfer"
private let defaults = UserDefaults.standard
private let descriptorPrefix = "direct-media-descriptor-"
private var backgroundCompletion: (() -> Void)?
private var sessionStorage: URLSession?
private lazy var decoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return decoder
}()
private var session: URLSession {
if let sessionStorage { return sessionStorage }
let configuration = URLSessionConfiguration.background(
withIdentifier: Self.sessionIdentifier
)
configuration.sessionSendsLaunchEvents = true
configuration.isDiscretionary = false
configuration.waitsForConnectivity = true
configuration.allowsCellularAccess = true
let created = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
sessionStorage = created
return created
}
func schedule(json: String) -> Bool {
guard
let data = json.data(using: .utf8),
let descriptor = try? decoder.decode(DirectMediaDescriptor.self, from: data),
descriptor.expiresAt > Int64(Date().timeIntervalSince1970),
FileManager.default.fileExists(atPath: descriptor.media.bodyPath),
FileManager.default.fileExists(atPath: descriptor.manifest.bodyPath)
else { return false }
defaults.set(data, forKey: descriptorPrefix + descriptor.attachmentId)
// Both durable transfers are created before either is resumed, so process
// death cannot leave a media object with no corresponding manifest task.
guard
let mediaTask = makeTask(descriptor.media, attachmentId: descriptor.attachmentId),
let manifestTask = makeTask(descriptor.manifest, attachmentId: descriptor.attachmentId)
else { return false }
mediaTask.resume()
manifestTask.resume()
return true
}
func handleEvents(identifier: String, completion: @escaping () -> Void) -> Bool {
guard identifier == Self.sessionIdentifier else { return false }
backgroundCompletion = completion
_ = session
return true
}
private func makeTask(_ request: DirectMediaRequest, attachmentId: String) -> URLSessionUploadTask? {
guard let url = URL(string: request.url) else { return nil }
let fileURL = URL(fileURLWithPath: request.bodyPath)
guard FileManager.default.fileExists(atPath: fileURL.path) else { return nil }
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = request.method
for (name, value) in request.headers {
urlRequest.setValue(value, forHTTPHeaderField: name)
}
let task = session.uploadTask(with: urlRequest, fromFile: fileURL)
task.taskDescription = "\(attachmentId)|\(request.role)"
return task
}
private func descriptor(attachmentId: String) -> DirectMediaDescriptor? {
guard let data = defaults.data(forKey: descriptorPrefix + attachmentId) else { return nil }
return try? decoder.decode(DirectMediaDescriptor.self, from: data)
}
private func request(_ role: String, from descriptor: DirectMediaDescriptor) -> DirectMediaRequest? {
switch role {
case "media": return descriptor.media
case "manifest": return descriptor.manifest
case "complete": return descriptor.complete
default: return nil
}
}
func urlSession(
_ session: URLSession,
task: URLSessionTask,
didCompleteWithError error: Error?
) {
guard
let description = task.taskDescription,
description.split(separator: "|", maxSplits: 1).count == 2
else { return }
let parts = description.split(separator: "|", maxSplits: 1)
let attachmentId = String(parts[0])
let role = String(parts[1])
guard let descriptor = descriptor(attachmentId: attachmentId) else { return }
let status = (task.response as? HTTPURLResponse)?.statusCode ?? 0
let expired = descriptor.expiresAt <= Int64(Date().timeIntervalSince1970)
let success = error == nil && (200...299).contains(status)
if success && role == "media" {
makeTask(descriptor.complete, attachmentId: attachmentId)?.resume()
return
}
if success {
return
}
// Authentication/policy failures are permanent. Connectivity failures,
// throttling and server failures remain retryable for the slot lifetime.
let retryable = error != nil || status == 0 || status == 408 || status == 429 || status >= 500
if !expired, retryable, let retry = request(role, from: descriptor) {
let retryTask = makeTask(retry, attachmentId: attachmentId)
retryTask?.earliestBeginDate = Date().addingTimeInterval(15)
retryTask?.resume()
}
}
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
DispatchQueue.main.async { [weak self] in
let completion = self?.backgroundCompletion
self?.backgroundCompletion = nil
completion?()
}
}
}
/// C ABI called directly by Rust. Flutter and Dart never participate in
/// scheduling or executing these background uploads.
@_cdecl("twonly_schedule_direct_media_uploads")
func twonlyScheduleDirectMediaUploads(_ descriptor: UnsafePointer<CChar>?) -> Bool {
guard let descriptor else { return false }
return DirectMediaTransfer.shared.schedule(json: String(cString: descriptor))
}

View file

@ -59,6 +59,8 @@
<string>Use your microphone to enable audio when making videos.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>twonly will save photos or videos to your library.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>twonly will save photos or videos to your library.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>This app does not use or store your location information.</string>
<key>UIApplicationSceneManifest</key>
@ -90,11 +92,6 @@
<string>remote-notification</string>
<string>processing</string>
</array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>eu.twonly.periodic_task</string>
<string>eu.twonly.processing_task</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>

View file

@ -0,0 +1,48 @@
import Foundation
import Photos
/// Writes a finished media file into the user's photo library.
///
/// Called directly from Rust. Rust decides whether an export should happen,
/// embeds the EXIF metadata beforehand, and owns the resulting state; this only
/// hands the file to Photos.
@_cdecl("twonly_save_to_gallery")
func twonlySaveToGallery(
_ path: UnsafePointer<CChar>?,
_ isVideo: Bool,
_ createdAtMillis: Int64
) -> Bool {
guard let path else { return false }
let url = URL(fileURLWithPath: String(cString: path))
guard FileManager.default.fileExists(atPath: url.path) else { return false }
// Authorisation is requested for adding only; a denied library never blocks
// the send itself, the export just fails.
let status = PHPhotoLibrary.authorizationStatus(for: .addOnly)
if status == .notDetermined {
let waiting = DispatchSemaphore(value: 0)
PHPhotoLibrary.requestAuthorization(for: .addOnly) { _ in waiting.signal() }
waiting.wait()
}
switch PHPhotoLibrary.authorizationStatus(for: .addOnly) {
case .authorized, .limited: break
default: return false
}
var saved = false
let finished = DispatchSemaphore(value: 0)
PHPhotoLibrary.shared().performChanges {
let request: PHAssetChangeRequest? =
isVideo
? PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: url)
: PHAssetChangeRequest.creationRequestForAssetFromImage(atFileURL: url)
// Photos sorts on this, so it has to carry the capture time rather than
// the moment the file was exported.
request?.creationDate = Date(timeIntervalSince1970: Double(createdAtMillis) / 1000)
} completionHandler: { success, _ in
saved = success
finished.signal()
}
finished.wait()
return saved
}

View file

@ -0,0 +1,38 @@
import Foundation
import ImageIO
import UniformTypeIdentifiers
/// C ABI called directly by Rust for the container formats Rust has no decoder
/// for HEIC/HEIF from the camera above all. ImageIO decodes every format the
/// OS knows; Rust re-encodes the result to WebP itself. Flutter is not involved.
@_cdecl("twonly_decode_image_to_png")
func twonlyDecodeImageToPng(
_ input: UnsafePointer<CChar>?,
_ output: UnsafePointer<CChar>?
) -> Bool {
guard let input, let output else { return false }
let inputURL = URL(fileURLWithPath: String(cString: input))
let outputURL = URL(fileURLWithPath: String(cString: output))
guard
let source = CGImageSourceCreateWithURL(inputURL as CFURL, nil),
let image = CGImageSourceCreateImageAtIndex(source, 0, nil)
else { return false }
try? FileManager.default.createDirectory(
at: outputURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
// PNG is lossless, so the quality decision stays with Rust's WebP encode.
let type: CFString
if #available(iOS 14.0, *) {
type = UTType.png.identifier as CFString
} else {
type = "public.png" as CFString
}
guard
let destination = CGImageDestinationCreateWithURL(outputURL as CFURL, type, 1, nil)
else { return false }
CGImageDestinationAddImage(destination, image, nil)
return CGImageDestinationFinalize(destination)
}

View file

@ -0,0 +1,320 @@
import AVFoundation
import CoreImage
import Foundation
import ImageIO
/// Burns the editor's overlay into the video and transcodes it in one pass:
/// Core Image composites each frame on the GPU and VideoToolbox encodes it.
/// No ffmpeg and no Flutter engine, so a send can finish in the background.
///
/// Called directly from Rust. Rust decides whether a render is needed and owns
/// every state transition around it; this only performs the work.
enum NativeVideoCodec {
/// Every send is normalised to 720p30 regardless of plan. The server caps a
/// single media object at 50MB on the free plan and 100MB on the paid ones,
/// and 720p30 is the largest format that keeps a clip of ordinary length
/// comfortably under the smaller of the two.
private static let maxLongSide: CGFloat = 1280
private static let maxShortSide: CGFloat = 720
private static let maxFrameRate: Double = 30
/// Bits per pixel per frame asked of VideoToolbox. HEVC stays close to the
/// source at roughly this rate; below it motion smears into blocks, and above
/// it the extra bits go to detail a phone camera never recorded. The bitrate
/// is derived from the output size and frame rate rather than fixed, so a clip
/// that is downscaled hard is not given the same budget as one that is already
/// 720p. Kept in sync with the same constants in the Android renderer so the
/// same clip looks the same whichever platform sent it.
///
/// `AVAssetExportSession` presets cannot express any of this, which is why
/// the reader/writer pair is driven by hand.
private static let bitsPerPixelPerFrame: Double = 0.12
private static let minBitrate = 1_500_000
private static let maxBitrate = 4_000_000
private static let defaultFrameRate: Double = 30
private static let audioBitrate = 128_000
static func render(
inputPath: String,
overlayPath: String?,
outputPath: String,
removeAudio: Bool,
onProgress: @escaping (Int) -> Void
) -> Bool {
let asset = AVURLAsset(url: URL(fileURLWithPath: inputPath))
guard let videoTrack = asset.tracks(withMediaType: .video).first else { return false }
let outputURL = URL(fileURLWithPath: outputPath)
try? FileManager.default.removeItem(at: outputURL)
try? FileManager.default.createDirectory(
at: outputURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
// The frame is stored unrotated; its displayed size is what has to be
// scaled, and what the overlay was drawn against.
let transformed = videoTrack.naturalSize.applying(videoTrack.preferredTransform)
let displayed = CGSize(width: abs(transformed.width), height: abs(transformed.height))
let target = evenScaledSize(displayed)
guard target.width > 0, target.height > 0 else { return false }
// A track with no declared frame rate still has to be given a budget.
let nominal = Double(videoTrack.nominalFrameRate)
let sourceFrameRate = nominal > 0 ? nominal : defaultFrameRate
let frameRate = min(sourceFrameRate, maxFrameRate)
let videoBitrate = bitrate(for: target, frameRate: frameRate)
let overlay = overlayPath.flatMap { CIImage(contentsOf: URL(fileURLWithPath: $0)) }
let composition = ciComposition(
for: asset,
target: target,
overlay: overlay,
// A 60fps clip encoded at 30fps spends its whole budget on the frames it
// keeps instead of halving the bits every frame gets. The source rate is
// left alone when it is already at or below the cap, so a 24fps clip is
// not resampled up to 30.
frameRate: sourceFrameRate > maxFrameRate ? maxFrameRate : nil
)
do {
let reader = try AVAssetReader(asset: asset)
let writer = try AVAssetWriter(outputURL: outputURL, fileType: .mp4)
writer.shouldOptimizeForNetworkUse = true
let videoOutput = AVAssetReaderVideoCompositionOutput(
videoTracks: asset.tracks(withMediaType: .video),
videoSettings: [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA
]
)
videoOutput.videoComposition = composition
videoOutput.alwaysCopiesSampleData = false
let videoInput = AVAssetWriterInput(
mediaType: .video,
outputSettings: [
AVVideoCodecKey: AVVideoCodecType.hevc,
AVVideoWidthKey: Int(target.width),
AVVideoHeightKey: Int(target.height),
// The source frame rate lets rate control budget a whole second
// instead of guessing from the samples it has seen so far.
AVVideoCompressionPropertiesKey: [
AVVideoAverageBitRateKey: videoBitrate,
AVVideoExpectedSourceFrameRateKey: Int(frameRate.rounded()),
],
]
)
videoInput.expectsMediaDataInRealTime = false
// The composition already applied the track's transform, so tagging the
// output again would rotate it a second time on playback.
guard reader.canAdd(videoOutput), writer.canAdd(videoInput) else { return false }
reader.add(videoOutput)
writer.add(videoInput)
var audioOutput: AVAssetReaderTrackOutput?
var audioInput: AVAssetWriterInput?
if !removeAudio, let audioTrack = asset.tracks(withMediaType: .audio).first {
let output = AVAssetReaderTrackOutput(
track: audioTrack,
outputSettings: [AVFormatIDKey: kAudioFormatLinearPCM]
)
let input = AVAssetWriterInput(
mediaType: .audio,
outputSettings: [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVNumberOfChannelsKey: 2,
AVSampleRateKey: 44100,
AVEncoderBitRateKey: audioBitrate,
]
)
input.expectsMediaDataInRealTime = false
if reader.canAdd(output), writer.canAdd(input) {
reader.add(output)
writer.add(input)
audioOutput = output
audioInput = input
}
}
guard reader.startReading(), writer.startWriting() else { return false }
writer.startSession(atSourceTime: .zero)
let duration = CMTimeGetSeconds(asset.duration)
let group = DispatchGroup()
pump(
input: videoInput,
output: videoOutput,
label: "video",
group: group,
duration: duration,
onProgress: onProgress
)
if let audioInput, let audioOutput {
pump(
input: audioInput,
output: audioOutput,
label: "audio",
group: group,
duration: nil,
onProgress: nil
)
}
group.wait()
guard reader.status != .failed else {
writer.cancelWriting()
return false
}
let finished = DispatchSemaphore(value: 0)
writer.finishWriting { finished.signal() }
finished.wait()
guard writer.status == .completed else { return false }
let size = try FileManager.default.attributesOfItem(atPath: outputPath)[.size] as? Int
return (size ?? 0) > 0
} catch {
return false
}
}
/// Core Image runs on the GPU, and unlike `AVVideoCompositionCoreAnimationTool`
/// it also works with a reader/writer pair, which is what lets the bitrate
/// stay under our control.
private static func ciComposition(
for asset: AVAsset,
target: CGSize,
overlay: CIImage?,
frameRate: Double?
) -> AVMutableVideoComposition {
let composition = AVMutableVideoComposition(asset: asset) { request in
let source = request.sourceImage
var frame = source
if source.extent.width > 0, source.extent.height > 0 {
frame = source.transformed(
by: CGAffineTransform(
scaleX: target.width / source.extent.width,
y: target.height / source.extent.height
)
)
}
if let overlay, overlay.extent.width > 0, overlay.extent.height > 0 {
let scaled = overlay.transformed(
by: CGAffineTransform(
scaleX: target.width / overlay.extent.width,
y: target.height / overlay.extent.height
)
)
frame = scaled.composited(over: frame)
}
request.finish(with: frame, context: nil)
}
composition.renderSize = target
if let frameRate {
composition.frameDuration = CMTime(value: 1, timescale: CMTimeScale(frameRate))
}
return composition
}
/// A rate the output size and frame rate actually justify. A fixed bitrate
/// either starves a 1080p60 clip or wastes bits on a 480p one; this spends the
/// same amount per pixel either way, within bounds that keep a send both
/// watchable and small enough to upload.
private static func bitrate(for size: CGSize, frameRate: Double) -> Int {
let bits = Double(size.width) * Double(size.height) * frameRate * bitsPerPixelPerFrame
return min(maxBitrate, max(minBitrate, Int(bits.rounded())))
}
/// Caps the long and short side the way the previous exporter did, and never
/// scales a smaller source up. Hardware encoders produce edge artifacts on
/// odd dimensions.
private static func evenScaledSize(_ size: CGSize) -> CGSize {
guard size.width > 0, size.height > 0 else { return .zero }
let scale = min(
1,
min(maxLongSide / max(size.width, size.height), maxShortSide / min(size.width, size.height))
)
let width = (size.width * scale).rounded(.down)
let height = (size.height * scale).rounded(.down)
return CGSize(
width: width - width.truncatingRemainder(dividingBy: 2),
height: height - height.truncatingRemainder(dividingBy: 2)
)
}
private static func pump(
input: AVAssetWriterInput,
output: AVAssetReaderOutput,
label: String,
group: DispatchGroup,
duration: Double?,
onProgress: ((Int) -> Void)?
) {
group.enter()
let queue = DispatchQueue(label: "eu.twonly.video.\(label)")
input.requestMediaDataWhenReady(on: queue) {
while input.isReadyForMoreMediaData {
guard let sample = output.copyNextSampleBuffer() else {
input.markAsFinished()
group.leave()
return
}
if let duration, duration > 0, let onProgress {
let seconds = CMTimeGetSeconds(CMSampleBufferGetPresentationTimeStamp(sample))
onProgress(Int((seconds / duration * 100).rounded()))
}
input.append(sample)
}
}
}
}
/// C ABI called directly by Rust.
@_cdecl("twonly_render_video")
func twonlyRenderVideo(
_ input: UnsafePointer<CChar>?,
_ overlay: UnsafePointer<CChar>?,
_ output: UnsafePointer<CChar>?,
_ removeAudio: Bool,
_ mediaId: UnsafePointer<CChar>?,
_ progress: @convention(c) (UnsafePointer<CChar>?, Int32) -> Void
) -> Bool {
guard let input, let output, let mediaId else { return false }
let mediaIdString = String(cString: mediaId)
return NativeVideoCodec.render(
inputPath: String(cString: input),
overlayPath: overlay.map { String(cString: $0) },
outputPath: String(cString: output),
removeAudio: removeAudio,
onProgress: { percent in
mediaIdString.withCString { progress($0, Int32(percent)) }
}
)
}
/// Grabs the first frame as a PNG. Only the decode needs the platform; Rust
/// scales and encodes the thumbnail itself, exactly as it does for stills.
@_cdecl("twonly_extract_video_frame")
func twonlyExtractVideoFrame(
_ input: UnsafePointer<CChar>?,
_ output: UnsafePointer<CChar>?
) -> Bool {
guard let input, let output else { return false }
let asset = AVURLAsset(url: URL(fileURLWithPath: String(cString: input)))
let generator = AVAssetImageGenerator(asset: asset)
// The frame has to arrive upright, the way the video is played back.
generator.appliesPreferredTrackTransform = true
guard let frame = try? generator.copyCGImage(at: .zero, actualTime: nil) else { return false }
let outputURL = URL(fileURLWithPath: String(cString: output))
try? FileManager.default.createDirectory(
at: outputURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
guard
let destination = CGImageDestinationCreateWithURL(
outputURL as CFURL, "public.png" as CFString, 1, nil)
else { return false }
CGImageDestinationAddImage(destination, frame, nil)
return CGImageDestinationFinalize(destination)
}

View file

@ -1,253 +0,0 @@
import Foundation
import Flutter
import AVFoundation
class VideoCompressionChannel {
private let channelName = "eu.twonly/videoCompression"
// Hold a strong reference so the instance isn't immediately deallocated
private static var activeInstance: VideoCompressionChannel?
static func register(with messenger: FlutterBinaryMessenger) {
let instance = VideoCompressionChannel()
activeInstance = instance
let channel = FlutterMethodChannel(name: instance.channelName, binaryMessenger: messenger)
print("[VideoCompressionChannel] Registered channel: \(instance.channelName)")
channel.setMethodCallHandler { [weak instance] (call: FlutterMethodCall, result: @escaping FlutterResult) in
print("[VideoCompressionChannel] Received method call: \(call.method)")
instance?.handle(call, result: result, channel: channel)
}
}
init() {}
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult, channel: FlutterMethodChannel) {
if call.method == "compressVideo" {
guard let args = call.arguments as? [String: Any],
let inputPath = args["input"] as? String,
let outputPath = args["output"] as? String else {
print("[VideoCompressionChannel] Error: Missing input or output path in arguments")
result(FlutterError(code: "INVALID_ARGS", message: "Input or output path missing", details: nil))
return
}
print("[VideoCompressionChannel] Starting compressVideo from \(inputPath) to \(outputPath)")
compress(inputPath: inputPath, outputPath: outputPath, channel: channel, result: result)
} else {
print("[VideoCompressionChannel] Method not implemented: \(call.method)")
result(FlutterMethodNotImplemented)
}
}
func compress(inputPath: String, outputPath: String, channel: FlutterMethodChannel, result: @escaping FlutterResult) {
let inputURL = URL(fileURLWithPath: inputPath)
let outputURL = URL(fileURLWithPath: outputPath)
if FileManager.default.fileExists(atPath: outputURL.path) {
print("[VideoCompressionChannel] Removing existing file at output path")
try? FileManager.default.removeItem(at: outputURL)
}
let asset = AVAsset(url: inputURL)
guard let videoTrack = asset.tracks(withMediaType: .video).first else {
print("[VideoCompressionChannel] Error: No video track found in asset")
result(FlutterError(code: "NO_VIDEO_TRACK", message: "Video track not found", details: nil))
return
}
let naturalSize = videoTrack.naturalSize
let transform = videoTrack.preferredTransform
let isPortrait = transform.a == 0 && abs(transform.b) == 1.0 && abs(transform.c) == 1.0 && transform.d == 0
let originalWidth = isPortrait ? naturalSize.height : naturalSize.width
let originalHeight = isPortrait ? naturalSize.width : naturalSize.height
let maxDimension: CGFloat = 1920.0
let minDimension: CGFloat = 1080.0
var targetWidth = originalWidth
var targetHeight = originalHeight
if targetWidth > maxDimension || targetHeight > maxDimension {
let widthRatio = maxDimension / targetWidth
let heightRatio = minDimension / targetHeight
let scaleFactor = min(widthRatio, heightRatio)
targetWidth *= scaleFactor
targetHeight *= scaleFactor
}
let targetBitrate = 3_000_000
do {
let reader = try AVAssetReader(asset: asset)
let writer = try AVAssetWriter(outputURL: outputURL, fileType: .mp4)
writer.shouldOptimizeForNetworkUse = true
let videoSettings: [String: Any] = [
AVVideoCodecKey: AVVideoCodecType.hevc,
AVVideoWidthKey: Int(targetWidth),
AVVideoHeightKey: Int(targetHeight),
AVVideoCompressionPropertiesKey: [
AVVideoAverageBitRateKey: targetBitrate
]
]
let readerOutput = AVAssetReaderTrackOutput(track: videoTrack, outputSettings: [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_420YpCbCr8BiPlanarFullRange
])
let writerInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings)
writerInput.expectsMediaDataInRealTime = false
writerInput.transform = videoTrack.preferredTransform
guard writer.canAdd(writerInput) else {
result(FlutterError(code: "WRITER_ERROR", message: "Cannot add video writer input", details: nil))
return
}
guard reader.canAdd(readerOutput) else {
result(FlutterError(code: "READER_ERROR", message: "Cannot add video reader output", details: nil))
return
}
reader.add(readerOutput)
writer.add(writerInput)
// Audio processing (re-encode to AAC)
var audioReaderOutput: AVAssetReaderTrackOutput?
var audioWriterInput: AVAssetWriterInput?
if let audioTrack = asset.tracks(withMediaType: .audio).first {
let audioReaderSettings: [String: Any] = [
AVFormatIDKey: kAudioFormatLinearPCM
]
let aReaderOutput = AVAssetReaderTrackOutput(track: audioTrack, outputSettings: audioReaderSettings)
let audioWriterSettings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVNumberOfChannelsKey: 2,
AVSampleRateKey: 44100,
AVEncoderBitRateKey: 128000
]
let aWriterInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioWriterSettings)
aWriterInput.expectsMediaDataInRealTime = false
if reader.canAdd(aReaderOutput) && writer.canAdd(aWriterInput) {
reader.add(aReaderOutput)
writer.add(aWriterInput)
audioReaderOutput = aReaderOutput
audioWriterInput = aWriterInput
} else {
print("[VideoCompressionChannel] Warning: Cannot add audio tracks, proceeding without audio")
}
}
guard reader.startReading() else {
result(FlutterError(code: "READER_ERROR", message: "Cannot start reading: \(reader.error?.localizedDescription ?? "unknown error")", details: nil))
return
}
guard writer.startWriting() else {
result(FlutterError(code: "WRITER_ERROR", message: "Cannot start writing: \(writer.error?.localizedDescription ?? "unknown error")", details: nil))
return
}
writer.startSession(atSourceTime: .zero)
let duration = CMTimeGetSeconds(asset.duration)
let videoQueue = DispatchQueue(label: "videoQueue")
let audioQueue = DispatchQueue(label: "audioQueue")
let group = DispatchGroup()
// State tracking flag to avoid sending completed messages prematurely
var isVideoCompleted = false
var isAudioCompleted = audioWriterInput == nil
group.enter()
writerInput.requestMediaDataWhenReady(on: videoQueue) {
while writerInput.isReadyForMoreMediaData {
if reader.status != .reading {
if !isVideoCompleted {
isVideoCompleted = true
writerInput.markAsFinished()
group.leave()
}
return
}
if let sampleBuffer = readerOutput.copyNextSampleBuffer() {
let presentationTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
let timeInSeconds = CMTimeGetSeconds(presentationTime)
if duration > 0 {
let progress = Int((timeInSeconds / duration) * 100)
DispatchQueue.main.async {
channel.invokeMethod("onProgress", arguments: ["progress": progress])
}
}
writerInput.append(sampleBuffer)
} else {
if !isVideoCompleted {
isVideoCompleted = true
writerInput.markAsFinished()
group.leave()
}
break
}
}
}
if let audioWriterInput = audioWriterInput, let audioReaderOutput = audioReaderOutput {
group.enter()
audioWriterInput.requestMediaDataWhenReady(on: audioQueue) {
while audioWriterInput.isReadyForMoreMediaData {
if reader.status != .reading {
if !isAudioCompleted {
isAudioCompleted = true
audioWriterInput.markAsFinished()
group.leave()
}
return
}
if let sampleBuffer = audioReaderOutput.copyNextSampleBuffer() {
audioWriterInput.append(sampleBuffer)
} else {
if !isAudioCompleted {
isAudioCompleted = true
audioWriterInput.markAsFinished()
group.leave()
}
break
}
}
}
}
group.notify(queue: .main) {
if reader.status == .completed {
writer.finishWriting {
if writer.status == .completed {
print("[VideoCompressionChannel] Compression completed successfully!")
result(outputPath)
} else {
print("[VideoCompressionChannel] Writer Error: \(writer.error?.localizedDescription ?? "Unknown error")")
result(FlutterError(code: "WRITER_ERROR", message: writer.error?.localizedDescription, details: nil))
}
}
} else {
writer.cancelWriting()
print("[VideoCompressionChannel] Reader Error: \(reader.error?.localizedDescription ?? "Unknown error")")
result(FlutterError(code: "READER_ERROR", message: reader.error?.localizedDescription, details: nil))
}
}
} catch {
print("[VideoCompressionChannel] Exception: \(error.localizedDescription)")
result(FlutterError(code: "COMPRESS_ERROR", message: error.localizedDescription, details: nil))
}
}
}

View file

@ -5,6 +5,7 @@
import '../api/server/prekeys.dart';
import '../frb_generated.dart';
import '../services/media_upload.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
// These functions are ignored because they are not marked as `pub`: `api_result`, `empty_api_response`, `from_rust_state`
@ -262,6 +263,11 @@ class FrbUserData {
class RustApi {
const RustApi();
/// Gives up on a media file whose source could not be produced, marking
/// its messages as deleted by the sender instead of retrying forever.
static Future<void> abandonMedia({required String mediaId}) =>
RustLib.instance.api.crateBridgeApiRustApiAbandonMedia(mediaId: mediaId);
static Future<void> addAdditionalUser({required PlatformInt64 userId}) =>
RustLib.instance.api.crateBridgeApiRustApiAddAdditionalUser(
userId: userId,
@ -273,6 +279,17 @@ class RustApi {
static String apiBaseUrl({required String protocol}) =>
RustLib.instance.api.crateBridgeApiRustApiApiBaseUrl(protocol: protocol);
static Future<Map<String, String>> authenticationHeaders() =>
RustLib.instance.api.crateBridgeApiRustApiAuthenticationHeaders();
static String avatarPngPath({
required PlatformInt64 contactId,
required PlatformInt64 profileCounter,
}) => RustLib.instance.api.crateBridgeApiRustApiAvatarPngPath(
contactId: contactId,
profileCounter: profileCounter,
);
static Future<void> changeUsername({required String username}) => RustLib
.instance
.api
@ -323,6 +340,21 @@ class RustApi {
static Future<ApiConnectionState> connectionState() =>
RustLib.instance.api.crateBridgeApiRustApiConnectionState();
/// Trims fully transparent borders an editor left around a stored image and
/// refreshes the preview and content hash derived from it.
static Future<void> cropMediaTransparentBorders({required String mediaId}) =>
RustLib.instance.api.crateBridgeApiRustApiCropMediaTransparentBorders(
mediaId: mediaId,
);
static Future<String?> currentUserAvatarPath() =>
RustLib.instance.api.crateBridgeApiRustApiCurrentUserAvatarPath();
static String decodeAvatarSvg({required List<int> avatarSvgCompressed}) =>
RustLib.instance.api.crateBridgeApiRustApiDecodeAvatarSvg(
avatarSvgCompressed: avatarSvgCompressed,
);
static Future<void> deleteAccount() =>
RustLib.instance.api.crateBridgeApiRustApiDeleteAccount();
@ -341,6 +373,13 @@ class RustApi {
static Future<void> downloadPendingMedia() =>
RustLib.instance.api.crateBridgeApiRustApiDownloadPendingMedia();
/// Path to a contact's avatar PNG, rendered from the stored SVG if it is
/// missing. Returns `None` when the contact has no avatar at all.
static Future<String?> ensureAvatarPng({required PlatformInt64 contactId}) =>
RustLib.instance.api.crateBridgeApiRustApiEnsureAvatarPng(
contactId: contactId,
);
static Future<void> establishSignalSession({
required PlatformInt64 contactId,
Uint8List? expectedPublicKey,
@ -352,6 +391,11 @@ class RustApi {
static Stream<ApiEvent> events() =>
RustLib.instance.api.crateBridgeApiRustApiEvents();
/// Settles every background transfer this device believes is still in
/// flight and resumes any upload a terminated process left behind.
static Future<void> finishStartedMediaUploads() =>
RustLib.instance.api.crateBridgeApiRustApiFinishStartedMediaUploads();
static Future<void> forceIpaCheck() =>
RustLib.instance.api.crateBridgeApiRustApiForceIpaCheck();
@ -399,6 +443,18 @@ class RustApi {
username: username,
);
/// Creates the media row and its content-encryption material and returns
/// the media id the UI addresses every later step by.
static Future<String> initializeMediaUpload({
required String mediaType,
PlatformInt64? displayLimitInMilliseconds,
required bool isDraftMedia,
}) => RustLib.instance.api.crateBridgeApiRustApiInitializeMediaUpload(
mediaType: mediaType,
displayLimitInMilliseconds: displayLimitInMilliseconds,
isDraftMedia: isDraftMedia,
);
static Future<String> insertAndSendAdditionalData({
required String groupId,
required String messageType,
@ -448,6 +504,24 @@ class RustApi {
static Future<FrbPlanBalance> loadPlanBalance() =>
RustLib.instance.api.crateBridgeApiRustApiLoadPlanBalance();
/// Explains a send that stopped because the media was too large: the size
/// it reached and the largest single object the plan accepts.
static Future<MediaSizeReport> mediaSizeLimitReport({
required String mediaId,
}) => RustLib.instance.api.crateBridgeApiRustApiMediaSizeLimitReport(
mediaId: mediaId,
);
/// Reports that a Flutter plugin step finished so Rust can record the
/// derived state (thumbnail present, crop analyzed, new size and hash).
static Future<void> mediaStepFinished({
required String mediaId,
required String kind,
}) => RustLib.instance.api.crateBridgeApiRustApiMediaStepFinished(
mediaId: mediaId,
kind: kind,
);
/// The number of pending notification events. iOS cannot derive its app
/// icon badge from the delivered alerts, so the running app pushes this
/// into `UNUserNotificationCenter` whenever the outbox changes.
@ -467,10 +541,9 @@ class RustApi {
.api
.crateBridgeApiRustApiPerformPasswordlessRecoveryHeartbeat();
static Future<Uint8List?> prepareQueuedMessage({required String receiptId}) =>
RustLib.instance.api.crateBridgeApiRustApiPrepareQueuedMessage(
receiptId: receiptId,
);
/// Deletes temporary media whose messages are finished with it.
static Future<void> purgeMediaTempFolder() =>
RustLib.instance.api.crateBridgeApiRustApiPurgeMediaTempFolder();
static Future<PlatformInt64> register({
required String username,
@ -515,6 +588,12 @@ class RustApi {
userId: userId,
);
/// Deletes every file of a media item while keeping its row.
static Future<void> removeMediaFiles({required String mediaId}) => RustLib
.instance
.api
.crateBridgeApiRustApiRemoveMediaFiles(mediaId: mediaId);
static Future<void> reportUser({
required PlatformInt64 userId,
required String reason,
@ -551,6 +630,19 @@ class RustApi {
static Future<void> retransmitAllMessages() =>
RustLib.instance.api.crateBridgeApiRustApiRetransmitAllMessages();
static Future<void> retryPendingMediaReuploads() =>
RustLib.instance.api.crateBridgeApiRustApiRetryPendingMediaReuploads();
/// Retries the media sends whose receipts are still marked for retry.
static Future<void> reuploadPendingMedia() =>
RustLib.instance.api.crateBridgeApiRustApiReuploadPendingMedia();
/// Exports a stored media file to the user's photo library.
static Future<void> saveMediaToGallery({required String mediaId}) => RustLib
.instance
.api
.crateBridgeApiRustApiSaveMediaToGallery(mediaId: mediaId);
static Future<void> sendBinary({required List<int> bytes}) =>
RustLib.instance.api.crateBridgeApiRustApiSendBinary(bytes: bytes);
@ -587,6 +679,17 @@ class RustApi {
onlySendIfNoReceiptsAreOpen: onlySendIfNoReceiptsAreOpen,
);
/// Creates one outgoing message per selected group and starts the upload.
static Future<void> sendMediaToGroups({
required String mediaId,
required List<String> groupIds,
Uint8List? additionalMessageData,
}) => RustLib.instance.api.crateBridgeApiRustApiSendMediaToGroups(
mediaId: mediaId,
groupIds: groupIds,
additionalMessageData: additionalMessageData,
);
static Future<void> sendQueuedMessage({required String receiptId}) => RustLib
.instance
.api
@ -616,11 +719,31 @@ class RustApi {
static Future<void> setLoginToken({required List<int> token}) =>
RustLib.instance.api.crateBridgeApiRustApiSetLoginToken(token: token);
static Future<void> setMediaDisplayLimit({
required String mediaId,
PlatformInt64? displayLimitInMilliseconds,
}) => RustLib.instance.api.crateBridgeApiRustApiSetMediaDisplayLimit(
mediaId: mediaId,
displayLimitInMilliseconds: displayLimitInMilliseconds,
);
static Future<void> setMediaRequiresAuthentication({
required String mediaId,
required bool requiresAuthentication,
}) =>
RustLib.instance.api.crateBridgeApiRustApiSetMediaRequiresAuthentication(
mediaId: mediaId,
requiresAuthentication: requiresAuthentication,
);
static Future<void> setNetworkAvailable({required bool available}) => RustLib
.instance
.api
.crateBridgeApiRustApiSetNetworkAvailable(available: available);
static Future<void> storeMedia({required String mediaId}) =>
RustLib.instance.api.crateBridgeApiRustApiStoreMedia(mediaId: mediaId);
static Future<void> submitRecoveryShare({
required String notificationId,
required List<int> encryptedMessage,
@ -629,6 +752,19 @@ class RustApi {
encryptedMessage: encryptedMessage,
);
static Future<void> toggleMediaRemoveAudio({required String mediaId}) =>
RustLib.instance.api.crateBridgeApiRustApiToggleMediaRemoveAudio(
mediaId: mediaId,
);
static Future<bool> tryRequestContactById({
required PlatformInt64 contactId,
required List<int> expectedPublicKey,
}) => RustLib.instance.api.crateBridgeApiRustApiTryRequestContactById(
contactId: contactId,
expectedPublicKey: expectedPublicKey,
);
static Future<void> updateFcmToken({required String token}) =>
RustLib.instance.api.crateBridgeApiRustApiUpdateFcmToken(token: token);

View file

@ -14,19 +14,11 @@ import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
Future<void> initFlutterCallbacks({
required int callbackId,
required FutureOr<RustStreamSink<String>> Function() loggingGetStreamSink,
required FutureOr<void> Function(String, String, PlatformInt64, String)
apiMediaAction,
required FutureOr<void> Function(PlatformInt64, Uint8List)
apiVerificationProof,
required FutureOr<void> Function(PlatformInt64) apiCreatePushAvatars,
required FutureOr<void> Function(String, PlatformInt64) apiMediaReceived,
required FutureOr<void> Function(PlatformInt64) apiVerificationSucceeded,
required FutureOr<void> Function(UserConfig) apiUserConfigChanged,
}) => RustLib.instance.api.crateBridgeCallbacksInitFlutterCallbacks(
callbackId: callbackId,
loggingGetStreamSink: loggingGetStreamSink,
apiMediaAction: apiMediaAction,
apiVerificationProof: apiVerificationProof,
apiCreatePushAvatars: apiCreatePushAvatars,
apiMediaReceived: apiMediaReceived,
apiVerificationSucceeded: apiVerificationSucceeded,
apiUserConfigChanged: apiUserConfigChanged,
);

View file

@ -27,9 +27,6 @@ class RustKeyManager {
addition: addition,
);
static Future<Uint8List> getLoginToken() => RustLib.instance.api
.crateBridgeWrapperKeyManagerRustKeyManagerGetLoginToken();
static Future<(Uint8List, PlatformInt64)> getSignalIdentity() => RustLib
.instance
.api

File diff suppressed because it is too large Load diff

View file

@ -24,6 +24,7 @@ import 'frb_generated.dart';
import 'keys/backup_password_keys.dart';
import 'lib.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_io.dart';
import 'services/media_upload.dart';
import 'signal/engine.dart';
import 'user_config.dart';
@ -41,16 +42,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
DateTime dco_decode_Chrono_Utc(dynamic raw);
@protected
FutureOr<void> Function(String, String, PlatformInt64, String)
dco_decode_DartFn_Inputs_String_String_i_64_String_Output_unit_AnyhowException(
dynamic raw,
);
@protected
FutureOr<void> Function(String, PlatformInt64)
dco_decode_DartFn_Inputs_String_i_64_Output_unit_AnyhowException(dynamic raw);
@protected
FutureOr<RustStreamSink<String>> Function()
dco_decode_DartFn_Inputs__Output_StreamSink_String_Sse_AnyhowException(
@ -61,12 +52,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
FutureOr<void> Function(PlatformInt64)
dco_decode_DartFn_Inputs_i_64_Output_unit_AnyhowException(dynamic raw);
@protected
FutureOr<void> Function(PlatformInt64, Uint8List)
dco_decode_DartFn_Inputs_i_64_list_prim_u_8_strict_Output_unit_AnyhowException(
dynamic raw,
);
@protected
FutureOr<void> Function(UserConfig)
dco_decode_DartFn_Inputs_user_config_Output_unit_AnyhowException(dynamic raw);
@ -74,6 +59,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
Object dco_decode_DartOpaque(dynamic raw);
@protected
Map<String, String> dco_decode_Map_String_String_None(dynamic raw);
@protected
Map<String, List<String>> dco_decode_Map_String_list_String_None(dynamic raw);
@ -255,6 +243,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
List<SqlValue> dco_decode_list_sql_value(dynamic raw);
@protected
MediaSizeReport dco_decode_media_size_report(dynamic raw);
@protected
Map<String, List<String>>? dco_decode_opt_Map_String_list_String_None(
dynamic raw,
@ -396,6 +387,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
Object sse_decode_DartOpaque(SseDeserializer deserializer);
@protected
Map<String, String> sse_decode_Map_String_String_None(
SseDeserializer deserializer,
);
@protected
Map<String, List<String>> sse_decode_Map_String_list_String_None(
SseDeserializer deserializer,
@ -621,6 +617,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
List<SqlValue> sse_decode_list_sql_value(SseDeserializer deserializer);
@protected
MediaSizeReport sse_decode_media_size_report(SseDeserializer deserializer);
@protected
Map<String, List<String>>? sse_decode_opt_Map_String_list_String_None(
SseDeserializer deserializer,
@ -780,19 +779,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_Chrono_Utc(DateTime self, SseSerializer serializer);
@protected
void
sse_encode_DartFn_Inputs_String_String_i_64_String_Output_unit_AnyhowException(
FutureOr<void> Function(String, String, PlatformInt64, String) self,
SseSerializer serializer,
);
@protected
void sse_encode_DartFn_Inputs_String_i_64_Output_unit_AnyhowException(
FutureOr<void> Function(String, PlatformInt64) self,
SseSerializer serializer,
);
@protected
void sse_encode_DartFn_Inputs__Output_StreamSink_String_Sse_AnyhowException(
FutureOr<RustStreamSink<String>> Function() self,
@ -805,13 +791,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void
sse_encode_DartFn_Inputs_i_64_list_prim_u_8_strict_Output_unit_AnyhowException(
FutureOr<void> Function(PlatformInt64, Uint8List) self,
SseSerializer serializer,
);
@protected
void sse_encode_DartFn_Inputs_user_config_Output_unit_AnyhowException(
FutureOr<void> Function(UserConfig) self,
@ -821,6 +800,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_DartOpaque(Object self, SseSerializer serializer);
@protected
void sse_encode_Map_String_String_None(
Map<String, String> self,
SseSerializer serializer,
);
@protected
void sse_encode_Map_String_list_String_None(
Map<String, List<String>> self,
@ -1103,6 +1088,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_list_sql_value(List<SqlValue> self, SseSerializer serializer);
@protected
void sse_encode_media_size_report(
MediaSizeReport self,
SseSerializer serializer,
);
@protected
void sse_encode_opt_Map_String_list_String_None(
Map<String, List<String>>? self,

View file

@ -26,6 +26,7 @@ import 'frb_generated.dart';
import 'keys/backup_password_keys.dart';
import 'lib.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_web.dart';
import 'services/media_upload.dart';
import 'signal/engine.dart';
import 'user_config.dart';
@ -43,16 +44,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
DateTime dco_decode_Chrono_Utc(dynamic raw);
@protected
FutureOr<void> Function(String, String, PlatformInt64, String)
dco_decode_DartFn_Inputs_String_String_i_64_String_Output_unit_AnyhowException(
dynamic raw,
);
@protected
FutureOr<void> Function(String, PlatformInt64)
dco_decode_DartFn_Inputs_String_i_64_Output_unit_AnyhowException(dynamic raw);
@protected
FutureOr<RustStreamSink<String>> Function()
dco_decode_DartFn_Inputs__Output_StreamSink_String_Sse_AnyhowException(
@ -63,12 +54,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
FutureOr<void> Function(PlatformInt64)
dco_decode_DartFn_Inputs_i_64_Output_unit_AnyhowException(dynamic raw);
@protected
FutureOr<void> Function(PlatformInt64, Uint8List)
dco_decode_DartFn_Inputs_i_64_list_prim_u_8_strict_Output_unit_AnyhowException(
dynamic raw,
);
@protected
FutureOr<void> Function(UserConfig)
dco_decode_DartFn_Inputs_user_config_Output_unit_AnyhowException(dynamic raw);
@ -76,6 +61,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
Object dco_decode_DartOpaque(dynamic raw);
@protected
Map<String, String> dco_decode_Map_String_String_None(dynamic raw);
@protected
Map<String, List<String>> dco_decode_Map_String_list_String_None(dynamic raw);
@ -257,6 +245,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
List<SqlValue> dco_decode_list_sql_value(dynamic raw);
@protected
MediaSizeReport dco_decode_media_size_report(dynamic raw);
@protected
Map<String, List<String>>? dco_decode_opt_Map_String_list_String_None(
dynamic raw,
@ -398,6 +389,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
Object sse_decode_DartOpaque(SseDeserializer deserializer);
@protected
Map<String, String> sse_decode_Map_String_String_None(
SseDeserializer deserializer,
);
@protected
Map<String, List<String>> sse_decode_Map_String_list_String_None(
SseDeserializer deserializer,
@ -623,6 +619,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
List<SqlValue> sse_decode_list_sql_value(SseDeserializer deserializer);
@protected
MediaSizeReport sse_decode_media_size_report(SseDeserializer deserializer);
@protected
Map<String, List<String>>? sse_decode_opt_Map_String_list_String_None(
SseDeserializer deserializer,
@ -782,19 +781,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_Chrono_Utc(DateTime self, SseSerializer serializer);
@protected
void
sse_encode_DartFn_Inputs_String_String_i_64_String_Output_unit_AnyhowException(
FutureOr<void> Function(String, String, PlatformInt64, String) self,
SseSerializer serializer,
);
@protected
void sse_encode_DartFn_Inputs_String_i_64_Output_unit_AnyhowException(
FutureOr<void> Function(String, PlatformInt64) self,
SseSerializer serializer,
);
@protected
void sse_encode_DartFn_Inputs__Output_StreamSink_String_Sse_AnyhowException(
FutureOr<RustStreamSink<String>> Function() self,
@ -807,13 +793,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void
sse_encode_DartFn_Inputs_i_64_list_prim_u_8_strict_Output_unit_AnyhowException(
FutureOr<void> Function(PlatformInt64, Uint8List) self,
SseSerializer serializer,
);
@protected
void sse_encode_DartFn_Inputs_user_config_Output_unit_AnyhowException(
FutureOr<void> Function(UserConfig) self,
@ -823,6 +802,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_DartOpaque(Object self, SseSerializer serializer);
@protected
void sse_encode_Map_String_String_None(
Map<String, String> self,
SseSerializer serializer,
);
@protected
void sse_encode_Map_String_list_String_None(
Map<String, List<String>> self,
@ -1105,6 +1090,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_list_sql_value(List<SqlValue> self, SseSerializer serializer);
@protected
void sse_encode_media_size_report(
MediaSizeReport self,
SseSerializer serializer,
);
@protected
void sse_encode_opt_Map_String_list_String_None(
Map<String, List<String>>? self,

View file

@ -0,0 +1,32 @@
// This file is automatically generated, so please do not edit it.
// @generated by `flutter_rust_bridge`@ 2.12.0.
// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import
import '../frb_generated.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
/// Why a media send stopped at `fileLimitReached`, for the chat entry to show.
class MediaSizeReport {
/// The encoded media as it sits on disk, while it is still there.
final PlatformInt64? mediaBytes;
/// The largest single object the user's plan accepts.
final PlatformInt64? limitBytes;
const MediaSizeReport({
this.mediaBytes,
this.limitBytes,
});
@override
int get hashCode => mediaBytes.hashCode ^ limitBytes.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is MediaSizeReport &&
runtimeType == other.runtimeType &&
mediaBytes == other.mediaBytes &&
limitBytes == other.limitBytes;
}

View file

@ -20,9 +20,6 @@ import 'package:twonly/src/providers/connection.provider.dart';
import 'package:twonly/src/providers/image_editor.provider.dart';
import 'package:twonly/src/providers/purchases.provider.dart';
import 'package:twonly/src/providers/settings.provider.dart';
import 'package:twonly/src/services/api/mediafiles/media_background.api.dart';
import 'package:twonly/src/services/api/mediafiles/upload.api.dart';
import 'package:twonly/src/services/background/callback_dispatcher.background.dart';
import 'package:twonly/src/services/backup.service.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/services/memories/memories.service.dart';
@ -30,7 +27,6 @@ import 'package:twonly/src/services/migrations.service.dart';
import 'package:twonly/src/services/notifications/fcm.notifications.dart';
import 'package:twonly/src/services/notifications/native.notifications.dart';
import 'package:twonly/src/services/notifications/setup.notifications.dart';
import 'package:twonly/src/utils/avatars.dart';
import 'package:twonly/src/utils/exclusive_access.utils.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/startup_guard.dart';
@ -133,7 +129,7 @@ void main() async {
final settingsController = SettingsChangeProvider()..loadSettings();
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
unawaited(initFileDownloader());
unawaited(BackupService.initFileDownloader());
if (userExists) {
unawaited(FcmNotificationService.initAfterUserLoaded());
@ -192,8 +188,7 @@ Future<void> postStartupTasks() async {
unawaited(MediaFileService.purgeTempFolder());
// 2. Service initializations
unawaited(finishStartedPreprocessing());
unawaited(createPushAvatars());
unawaited(RustApi.finishStartedMediaUploads());
unawaited(
newsService.init().then((_) {
final lastDownload = newsService.lastDownloadedAt;
@ -204,8 +199,6 @@ Future<void> postStartupTasks() async {
}),
);
await Future.delayed(const Duration(seconds: 10));
unawaited(initializeBackgroundTaskManager());
// 3. Delayed tasks (Wait for app to settle)
await Future.delayed(const Duration(minutes: 2));
unawaited(BackupService.makeBackup());

View file

@ -1,43 +1,15 @@
import 'package:twonly/core/bridge/callbacks.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/callbacks/logging.callbacks.dart';
import 'package:twonly/src/services/api/mediafiles/upload.api.dart';
import 'package:twonly/src/services/flame.service.dart';
import 'package:twonly/src/services/key_verification.service.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/avatars.dart';
Future<void> _apiMediaAction(
String kind,
String mediaId,
int contactId,
String messageId,
) async {
final media = await twonlyDB.mediaFilesDao.getMediaFileById(mediaId);
if (media == null) return;
switch (kind) {
case 'stored':
await MediaFileService(media).storeMediaFile();
case 'reupload':
await reuploadMediaFile(contactId, media, messageId);
}
}
Future<void> initFlutterCallbacksForRust() async {
await initFlutterCallbacks(
callbackId: isolateCallbackId,
loggingGetStreamSink: LoggingCallbacks.getStreamSink,
apiMediaAction: _apiMediaAction,
apiVerificationProof: KeyVerificationService.handleVerificationProof,
apiCreatePushAvatars: (contactId) =>
createPushAvatars(forceForUserId: contactId),
apiMediaReceived: (groupId, timestamp) => incFlameCounter(
groupId,
true,
DateTime.fromMillisecondsSinceEpoch(timestamp),
),
apiVerificationSucceeded:
KeyVerificationService.handleVerificationSucceeded,
apiUserConfigChanged: UserService.handleRustUserConfigChanged,
);
}

View file

@ -1,46 +0,0 @@
import 'package:flutter/services.dart';
import 'package:twonly/src/utils/log.dart';
abstract class VideoCompressionChannel {
static const MethodChannel _channel = MethodChannel(
'eu.twonly/videoCompression',
);
static void Function(double)? _currentProgressCallback;
static bool _handlerSetup = false;
static void _setupProgressHandler() {
if (_handlerSetup) return;
_channel.setMethodCallHandler((call) async {
if (call.method == 'onProgress') {
// ignore: avoid_dynamic_calls
final progress = call.arguments['progress'] as int;
_currentProgressCallback?.call(progress / 100.0);
}
});
_handlerSetup = true;
}
static Future<String?> compressVideo({
required String inputPath,
required String outputPath,
void Function(double progress)? onProgress,
}) async {
try {
_setupProgressHandler();
_currentProgressCallback = onProgress;
await _channel.invokeMethod('compressVideo', {
'input': inputPath,
'output': outputPath,
});
return outputPath;
} on PlatformException catch (e) {
Log.warn('Failed to compress video: $e');
return null;
} finally {
_currentProgressCallback = null;
}
}
}

View file

@ -1,6 +1,4 @@
class KeyValueKeys {
static const String lastPeriodicTaskExecution =
'last_periodic_task_execution';
static const String currentBackupState = 'current_backup_state';
static const String backupRecoveryState = 'backup_recovery_state';
static const String onboardingState = 'onboarding_state';

View file

@ -188,19 +188,6 @@ Stream<List<MediaFile>> watchMediaFilesByIds(Set<String> mediaIds) {
);
}
Future<void> updateAllRetransmissionUploadingState() async {
await (update(mediaFiles)..where(
(t) =>
t.uploadState.equals(UploadState.uploading.name) &
t.reuploadRequestedBy.isNotNull(),
))
.write(
const MediaFilesCompanion(
uploadState: Value(UploadState.preprocessing),
),
);
}
Future<List<String>> getMessageIdsByMediaHash(
Uint8List hash,
int senderId,

View file

@ -350,7 +350,7 @@ Future<void> purgeMessageTable() async {
final mediaService = await MediaFileService.fromMediaId(msg.mediaId!);
if (mediaService != null) {
mediaService.fullMediaRemoval();
await mediaService.fullMediaRemoval();
}
} else {
Log.info(

View file

@ -4561,6 +4561,42 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Exporting memories...'**
String get memoriesExportingProgress;
/// No description provided for @fileLimitReachedTitle.
///
/// In en, this message translates to:
/// **'File too large to send'**
String get fileLimitReachedTitle;
/// No description provided for @fileLimitReachedDetail.
///
/// In en, this message translates to:
/// **'This file is {size}, but your plan allows at most {limit} per send.'**
String fileLimitReachedDetail(String size, String limit);
/// No description provided for @fileLimitReachedDetailNoSize.
///
/// In en, this message translates to:
/// **'Your plan allows at most {limit} per send.'**
String fileLimitReachedDetailNoSize(String limit);
/// No description provided for @fileLimitReachedHint.
///
/// In en, this message translates to:
/// **'Record a shorter video and send it again.'**
String get fileLimitReachedHint;
/// No description provided for @fileLimitReachedHintFree.
///
/// In en, this message translates to:
/// **'Record a shorter video, or upgrade your plan to send larger files.'**
String get fileLimitReachedHintFree;
/// No description provided for @fileLimitReachedUpgrade.
///
/// In en, this message translates to:
/// **'Upgrade plan'**
String get fileLimitReachedUpgrade;
}
class _AppLocalizationsDelegate

View file

@ -2635,4 +2635,28 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get memoriesExportingProgress => 'Erinnerungen werden exportiert...';
@override
String get fileLimitReachedTitle => 'Datei zu groß zum Senden';
@override
String fileLimitReachedDetail(String size, String limit) {
return 'Diese Datei ist $size groß, dein Tarif erlaubt aber höchstens $limit pro Sendung.';
}
@override
String fileLimitReachedDetailNoSize(String limit) {
return 'Dein Tarif erlaubt höchstens $limit pro Sendung.';
}
@override
String get fileLimitReachedHint =>
'Nimm ein kürzeres Video auf und sende es erneut.';
@override
String get fileLimitReachedHintFree =>
'Nimm ein kürzeres Video auf oder wechsle den Tarif, um größere Dateien zu senden.';
@override
String get fileLimitReachedUpgrade => 'Tarif wechseln';
}

View file

@ -2610,4 +2610,28 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get memoriesExportingProgress => 'Exporting memories...';
@override
String get fileLimitReachedTitle => 'File too large to send';
@override
String fileLimitReachedDetail(String size, String limit) {
return 'This file is $size, but your plan allows at most $limit per send.';
}
@override
String fileLimitReachedDetailNoSize(String limit) {
return 'Your plan allows at most $limit per send.';
}
@override
String get fileLimitReachedHint =>
'Record a shorter video and send it again.';
@override
String get fileLimitReachedHintFree =>
'Record a shorter video, or upgrade your plan to send larger files.';
@override
String get fileLimitReachedUpgrade => 'Upgrade plan';
}

View file

@ -4,7 +4,6 @@ import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:twonly/core/bridge/api.dart' as rust_api;
import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/services/api/mediafiles/upload.api.dart';
import 'package:twonly/src/services/memories/memories_cloud.service.dart';
import 'package:twonly/src/services/notifications/fcm.notifications.dart';
import 'package:twonly/src/utils/log.dart';
@ -41,9 +40,9 @@ class ApiService {
await FcmNotificationService.initFCMAfterAuthenticated();
if (AppState.isInBackgroundTask) {
await reuploadMediaFiles();
await rust_api.RustApi.reuploadPendingMedia();
} else if (!AppState.isAppInBackground) {
unawaited(reuploadMediaFiles());
unawaited(rust_api.RustApi.reuploadPendingMedia());
twonlyDB.markUpdated();
// resetUserDiscoveryRequestUpdates();

View file

@ -1,137 +0,0 @@
import 'dart:async';
import 'package:background_downloader/background_downloader.dart';
import 'package:drift/drift.dart' show Value;
import 'package:flutter/foundation.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/api/mediafiles/upload.api.dart';
import 'package:twonly/src/services/backup.service.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/utils/log.dart';
Future<void> initFileDownloader() async {
FileDownloader().updates.listen((update) async {
switch (update) {
case TaskStatusUpdate():
if (update.task.taskId.contains('upload_')) {
await handleUploadStatusUpdate(update);
}
if (update.task.taskId.contains('backup_')) {
await BackupService.handleBackupStatusUpdate(
update.task.taskId,
update,
);
}
case TaskProgressUpdate():
Log.info(
'Progress update for ${update.task} with progress ${update.progress}',
);
}
});
await FileDownloader().start();
try {
var androidConfig = [];
if (!kReleaseMode) {
androidConfig = [(Config.bypassTLSCertificateValidation, true)];
}
await FileDownloader().configure(androidConfig: androidConfig);
} catch (e) {
Log.error(e);
}
if (!kReleaseMode) {
FileDownloader().configureNotification(
running: const TaskNotification(
'Uploading/Downloading',
'{filename} ({progress}).',
),
progressBar: true,
);
}
}
Future<void> handleUploadStatusUpdate(TaskStatusUpdate update) async {
final mediaId = update.task.taskId.replaceAll('upload_', '');
final media = await twonlyDB.mediaFilesDao.getMediaFileById(mediaId);
if (update.status == TaskStatus.running) {
// Ignore these updates
return;
}
if (media == null) {
Log.error(
'Got an upload task but no upload media in the media upload database',
);
return;
}
if (update.status == TaskStatus.complete) {
if (update.responseStatusCode == 200) {
Log.info('Upload of ${media.mediaId} success!');
await markUploadAsSuccessful(media);
return;
}
Log.warn(
'Got HTTP error ${update.responseStatusCode} for $mediaId',
);
Log.error(
'Got HTTP error ${update.responseStatusCode} for media.',
onlyIfSentryEnabled: true,
);
}
if (update.status == TaskStatus.notFound) {
await twonlyDB.mediaFilesDao.updateMedia(
mediaId,
const MediaFilesCompanion(
uploadState: Value(UploadState.uploadLimitReached),
),
);
Log.info(
'Background upload failed for $mediaId with status ${update.responseStatusCode}. Not trying again.',
);
return;
}
Log.info(
'Background status $mediaId with status ${update.status} and ${update.responseStatusCode}. ',
);
if (update.status == TaskStatus.waitingToRetry) {
if (update.responseStatusCode == 401) {
// auth token is not valid, so either create a new task with a new token, or cancel task
final mediaService = MediaFileService(media);
await FileDownloader().cancelTaskWithId(update.task.taskId);
Log.info('Cancel task, already uploaded or will be reuploaded');
if (mediaService.mediaFile.uploadState != UploadState.uploaded) {
await mediaService.setUploadState(UploadState.uploading);
// In all other cases just try the upload again...
await startBackgroundMediaUpload(mediaService);
}
}
}
if (update.status == TaskStatus.failed ||
update.status == TaskStatus.canceled) {
Log.warn(
'Background upload failed for $mediaId with status ${update.status} and ${update.responseStatusCode}. ',
);
Log.error(
'Background upload failed with status ${update.status} and ${update.responseStatusCode}.',
onlyIfSentryEnabled: true,
);
final mediaService = MediaFileService(media);
// in case the media file is already uploaded to not reqtry
if (mediaService.mediaFile.uploadState != UploadState.uploaded) {
await mediaService.setUploadState(UploadState.uploading);
// In all other cases just try the upload again...
await startBackgroundMediaUpload(mediaService);
}
}
}

View file

@ -1,884 +0,0 @@
import 'dart:async';
import 'package:background_downloader/background_downloader.dart';
import 'package:clock/clock.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:cryptography_flutter_plus/cryptography_flutter_plus.dart';
import 'package:cryptography_plus/cryptography_plus.dart';
import 'package:drift/drift.dart';
import 'package:fixnum/fixnum.dart';
import 'package:http/http.dart' as http;
import 'package:mutex/mutex.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/tables/messages.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart';
import 'package:twonly/src/model/protobuf/client/generated/http_requests.pb.dart';
import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart';
import 'package:twonly/src/services/api/mediafiles/media_background.api.dart';
import 'package:twonly/src/services/api/messages.api.dart';
import 'package:twonly/src/services/api/utils.api.dart';
import 'package:twonly/src/services/flame.service.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/utils/exclusive_access.utils.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:workmanager/workmanager.dart' hide TaskStatus;
final lockRetransmission = Mutex();
final Map<String, Mutex> _uploadMutexes = {};
Future<void> _protectMediaUpload(
String mediaId,
Future<void> Function() action,
) async {
final mutex = _uploadMutexes.putIfAbsent(mediaId, Mutex.new);
await mutex.protect(action);
}
Future<void> reuploadMediaFiles() async {
return exclusiveAccess(
lockName: 'reupload_maintenance',
mutex: lockRetransmission,
action: () async {
final receipts = await twonlyDB.receiptsDao
.getReceiptsForMediaRetransmissions();
if (receipts.isEmpty) return;
Log.info('Reuploading ${receipts.length} media files to the server.');
final contacts = <int, Contact>{};
for (final receipt in receipts) {
if (receipt.retryCount > 1 && receipt.lastRetry != null) {
final twentyFourHoursAgo = DateTime.now().subtract(
const Duration(hours: 6),
);
if (receipt.lastRetry!.isAfter(twentyFourHoursAgo)) {
Log.info(
'Ignoring ${receipt.receiptId} as it was retried in the last 6h',
);
continue;
}
}
var messageId = receipt.messageId;
if (receipt.messageId == null) {
Log.info('Message not in receipt. Loading it from the content.');
try {
final content = EncryptedContent.fromBuffer(receipt.message);
if (content.hasMedia()) {
messageId = content.media.senderMessageId;
final messageExists = await twonlyDB.messagesDao
.getMessageById(messageId)
.getSingleOrNull();
if (messageExists != null) {
await twonlyDB.receiptsDao.updateReceipt(
receipt.receiptId,
ReceiptsCompanion(
messageId: Value(messageId),
),
);
} else {
Log.info(
'Message $messageId not found in DB for receipt recovery. Deleting stale receipt.',
);
await twonlyDB.receiptsDao.deleteReceipt(receipt.receiptId);
continue;
}
}
} catch (e) {
Log.error(e);
}
}
if (messageId == null) {
Log.error('MessageId is empty for media file receipts');
continue;
}
if (receipt.markForRetryAfterAccepted != null) {
if (!contacts.containsKey(receipt.contactId)) {
final contact = await twonlyDB.contactsDao
.getContactByUserId(receipt.contactId)
.getSingleOrNull();
if (contact == null) {
Log.error(
'Contact does not exists, but has a record in receipts, this should not be possible, because of the DELETE CASCADE relation.',
);
continue;
}
contacts[receipt.contactId] = contact;
}
if (!(contacts[receipt.contactId]?.accepted ?? true)) {
Log.warn(
'Could not send message as contact has still not yet accepted.',
);
continue;
}
}
if (receipt.ackByServerAt == null) {
// media file must be reuploaded again in case the media files
// was deleted by the server, the receiver will request a new media reupload
final message = await twonlyDB.messagesDao
.getMessageById(messageId)
.getSingleOrNull();
if (message == null || message.mediaId == null) {
// The message or media file does not exists any more, so delete the receipt...
if (message != null) {
// The media file of the message does not exist anymore. Removing it...
await twonlyDB.messagesDao.deleteMessagesById(messageId);
}
await twonlyDB.receiptsDao.deleteReceipt(receipt.receiptId);
Log.warn(
'Message not found for reupload of the receipt, likely deleted from sender (${message == null} - ${message?.mediaId}).',
);
continue;
}
final mediaFile = await twonlyDB.mediaFilesDao.getMediaFileById(
message.mediaId!,
);
if (mediaFile == null) {
Log.error(
'Mediafile not found for reupload of the receipt (${message.messageId} - ${message.mediaId}).',
);
continue;
}
await reuploadMediaFile(
receipt.contactId,
mediaFile,
message.messageId,
);
} else {
Log.info('Reuploading media file $messageId');
// the media file should be still on the server, so it should be enough
// to just resend the message containing the download token.
await tryToSendCompleteMessage(receiptId: receipt.receiptId);
}
}
},
);
}
Future<void> reuploadMediaFile(
int contactId,
MediaFile mediaFile,
String messageId,
) async {
return _protectMediaUpload(mediaFile.mediaId, () async {
Log.info('Reuploading media file: ${mediaFile.mediaId}');
await twonlyDB.receiptsDao.updateReceiptByContactAndMessageId(
contactId,
messageId,
const ReceiptsCompanion(
markForRetry: Value(null),
markForRetryAfterAccepted: Value(null),
),
);
// Refresh media file to get latest reuploadRequestedBy
final currentMedia = await twonlyDB.mediaFilesDao.getMediaFileById(
mediaFile.mediaId,
);
final reuploadRequestedBy = (currentMedia?.reuploadRequestedBy ?? [])
..add(contactId);
await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId,
MediaFilesCompanion(
uploadState: const Value(UploadState.preprocessing),
reuploadRequestedBy: Value(reuploadRequestedBy),
),
);
final mediaFileUpdated = await MediaFileService.fromMediaId(
mediaFile.mediaId,
);
if (mediaFileUpdated != null) {
if (mediaFileUpdated.uploadRequestPath.existsSync()) {
mediaFileUpdated.uploadRequestPath.deleteSync();
}
await _startBackgroundMediaUploadInternal(mediaFileUpdated);
}
});
}
final Mutex _lockPreprocessing = Mutex();
Future<void> finishStartedPreprocessing() async {
return exclusiveAccess(
lockName: 'preprocessing_maintenance',
mutex: _lockPreprocessing,
action: () async {
final mediaFiles = await twonlyDB.mediaFilesDao
.getAllMediaFilesPendingUpload();
for (final mediaFile in mediaFiles) {
if (mediaFile.isDraftMedia) {
Log.info('Ignoring media files as it is a draft');
continue;
}
final messages = await twonlyDB.messagesDao.getMessagesByMediaId(
mediaFile.mediaId,
);
if (messages.isEmpty) {
if (mediaFile.createdAt.isBefore(
clock.now().subtract(const Duration(hours: 1)),
)) {
Log.info(
'Deleted orphaned media file ${mediaFile.mediaId} as no messages reference it.',
);
MediaFileService(mediaFile).fullMediaRemoval();
await twonlyDB.mediaFilesDao.deleteMediaFile(mediaFile.mediaId);
} else {
Log.info(
'Media file ${mediaFile.mediaId} has no messages, but is too new to be deleted by finishStartedPreprocessing. Skipping.',
);
}
continue;
}
try {
final service = MediaFileService(mediaFile);
if (!service.originalPath.existsSync() &&
!service.uploadRequestPath.existsSync()) {
if (service.storedPath.existsSync()) {
// media file was stored, we can recover tempPath from storedPath and upload it.
try {
if (!service.tempPath.existsSync()) {
service.storedPath.copySync(service.tempPath.path);
}
} catch (e) {
Log.error('Error recovering tempPath from storedPath: $e');
continue;
}
} else {
if (mediaFile.reuploadRequestedBy != null) {
Log.warn(
'Reupload requested for ${mediaFile.mediaId} but files are missing. Cancelling reupload but keeping record.',
);
await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId,
const MediaFilesCompanion(
uploadState: Value(UploadState.uploaded),
reuploadRequestedBy: Value(null),
),
);
continue;
}
final messages = await twonlyDB.messagesDao.getMessagesByMediaId(
mediaFile.mediaId,
);
if (messages.isEmpty) {
Log.info(
'Deleted media files ${mediaFile.mediaId} as originalPath and uploadRequestPath both do not exists and no messages reference it.',
);
// the file does not exists anymore and no messages reference it.
await twonlyDB.mediaFilesDao.deleteMediaFile(mediaFile.mediaId);
} else {
Log.warn(
'Media files ${mediaFile.mediaId} missing but messages still reference it. Keeping record to avoid broken chat history.',
);
// Just mark as uploaded to stop preprocessing attempts
await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId,
const MediaFilesCompanion(
uploadState: Value(UploadState.uploaded),
),
);
}
continue;
}
}
Log.info(
'Finishing started preprocessing of ${mediaFile.mediaId} in state ${mediaFile.uploadState}.',
);
await startBackgroundMediaUpload(service);
} catch (e) {
Log.warn(e);
}
}
},
);
}
/// It can happen, that a media files is uploaded but not yet marked for been uploaded.
/// For example because the background_downloader plugin has not yet reported the finished upload.
/// In case the message receipts or a reaction was received, mark the media file as been uploaded.
Future<void> handleMediaRelatedResponseFromReceiver(String messageId) async {
final message = await twonlyDB.messagesDao
.getMessageById(messageId)
.getSingleOrNull();
if (message == null || message.mediaId == null) return;
final media = await twonlyDB.mediaFilesDao.getMediaFileById(message.mediaId!);
if (media == null) return;
if (media.uploadState != UploadState.uploaded) {
Log.info('Media was not yet marked as uploaded. Doing it now.');
await markUploadAsSuccessful(media);
}
}
Future<void> markUploadAsSuccessful(MediaFile media) async {
await twonlyDB.mediaFilesDao.updateMedia(
media.mediaId,
const MediaFilesCompanion(
uploadState: Value(UploadState.uploaded),
),
);
/// As the messages where send in a bulk acknowledge all messages.
final messages = await twonlyDB.messagesDao.getMessagesByMediaId(
media.mediaId,
);
for (final message in messages) {
final contacts = await twonlyDB.groupsDao.getGroupNonLeftMembers(
message.groupId,
);
for (final contact in contacts) {
await twonlyDB.messagesDao.handleMessageAckByServer(
contact.contactId,
message.messageId,
clock.now(),
);
await twonlyDB.receiptsDao.updateReceiptByContactAndMessageId(
contact.contactId,
message.messageId,
ReceiptsCompanion(
ackByServerAt: Value(clock.now()),
retryCount: const Value(1),
lastRetry: Value(clock.now()),
markForRetry: const Value(null),
),
);
}
}
}
Future<MediaFileService?> initializeMediaUpload(
MediaType type,
int? displayLimitInMilliseconds, {
bool isDraftMedia = false,
}) async {
if (displayLimitInMilliseconds != null && displayLimitInMilliseconds < 1000) {
// in case the time was set in seconds...
// ignore: parameter_assignments
displayLimitInMilliseconds = displayLimitInMilliseconds * 1000;
}
final chacha20 = FlutterChacha20.poly1305Aead();
final encryptionKey = await (await chacha20.newSecretKey()).extract();
final encryptionNonce = chacha20.newNonce();
await twonlyDB.mediaFilesDao.updateAllMediaFiles(
const MediaFilesCompanion(isDraftMedia: Value(false)),
);
final mediaFile = await twonlyDB.mediaFilesDao.insertOrUpdateMedia(
MediaFilesCompanion(
uploadState: const Value(UploadState.initialized),
displayLimitInMilliseconds: Value(displayLimitInMilliseconds),
encryptionKey: Value(Uint8List.fromList(encryptionKey.bytes)),
encryptionNonce: Value(Uint8List.fromList(encryptionNonce)),
isDraftMedia: Value(isDraftMedia),
type: Value(type),
),
);
if (mediaFile == null) return null;
return MediaFileService(mediaFile);
}
Future<void> insertMediaFileInMessagesTable(
MediaFileService mediaService,
List<String> groupIds, {
AdditionalMessageData? additionalData,
}) async {
await twonlyDB.transaction(() async {
await twonlyDB.mediaFilesDao.updateAllMediaFiles(
const MediaFilesCompanion(
isDraftMedia: Value(false),
),
);
for (final groupId in groupIds) {
final groupMembers = await twonlyDB.groupsDao.getGroupContact(groupId);
if (groupMembers.length == 1) {
if (groupMembers.first.accountDeleted) {
Log.warn(
'Did not send media file to $groupId because the only account has deleted his account.',
);
continue;
}
}
final message = await twonlyDB.messagesDao.insertMessage(
MessagesCompanion(
groupId: Value(groupId),
mediaId: Value(mediaService.mediaFile.mediaId),
type: Value(MessageType.media.name),
additionalMessageData: Value.absentIfNull(
additionalData?.writeToBuffer(),
),
),
);
await twonlyDB.groupsDao.increaseLastMessageExchange(
groupId,
clock.now(),
);
if (message != null) {
Log.info(
'Created message ${message.messageId} for media ${message.mediaId}',
);
// de-archive contact when sending a new message
await twonlyDB.groupsDao.updateGroup(
message.groupId,
const GroupsCompanion(
archived: Value(false),
deletedContent: Value(false),
),
);
} else {
Log.error('Error inserting media upload message in database.');
}
}
});
unawaited(startBackgroundMediaUpload(mediaService));
}
Future<void> startBackgroundMediaUpload(MediaFileService mediaService) async {
return _protectMediaUpload(
mediaService.mediaFile.mediaId,
() => _startBackgroundMediaUploadInternal(mediaService),
);
}
Future<void> _startBackgroundMediaUploadInternal(
MediaFileService mediaService,
) async {
// Refresh the media file state inside the mutex
await mediaService.updateFromDB();
if (mediaService.mediaFile.uploadState == UploadState.uploading) {
await _checkAndRecoverMissingUploadRequest(mediaService);
}
if (mediaService.mediaFile.uploadState == UploadState.initialized ||
mediaService.mediaFile.uploadState == UploadState.preprocessing) {
Log.info(
'Handling media file ${mediaService.mediaFile.mediaId} in ${mediaService.mediaFile.uploadState}',
);
await mediaService.setUploadState(UploadState.preprocessing);
if (!mediaService.tempPath.existsSync()) {
if (mediaService.storedPath.existsSync()) {
try {
mediaService.storedPath.copySync(mediaService.tempPath.path);
} catch (e) {
Log.error('Error copying storedPath to tempPath: $e');
}
} else {
await mediaService.compressMedia();
}
if (!mediaService.tempPath.existsSync()) {
final messages = await twonlyDB.messagesDao.getMessagesByMediaId(
mediaService.mediaFile.mediaId,
);
if (messages.isEmpty) {
if (mediaService.mediaFile.createdAt.isBefore(
clock.now().subtract(const Duration(hours: 1)),
)) {
Log.warn(
'Media files ${mediaService.mediaFile.mediaId} has no original, temp, or stored path. Removing it from DB as files are not existent.',
);
await twonlyDB.mediaFilesDao.deleteMediaFile(
mediaService.mediaFile.mediaId,
);
} else {
Log.warn(
'Media files ${mediaService.mediaFile.mediaId} has no paths, but is too new to be deleted. Skipping deletion.',
);
}
} else {
Log.warn(
'Media files ${mediaService.mediaFile.mediaId} has no original, temp, or stored path, but messages still reference it. Marking as uploaded to stop retries.',
);
await mediaService.setUploadState(UploadState.uploaded);
}
return;
}
}
// if the user has enabled auto storing and the file
// was send with unlimited counter not in twonly-Mode then store the file
if (userService.currentUser.autoStoreAllSendUnlimitedMediaFiles &&
!mediaService.mediaFile.requiresAuthentication &&
!mediaService.storedPath.existsSync() &&
mediaService.mediaFile.displayLimitInMilliseconds == null) {
await mediaService.storeMediaFile();
}
if (!mediaService.encryptedPath.existsSync()) {
Log.info(
'Media ${mediaService.mediaFile.mediaId}: Encrypted file not found. Starting encryption.',
);
await _encryptMediaFiles(mediaService);
if (!mediaService.encryptedPath.existsSync()) {
Log.warn(
'Media ${mediaService.mediaFile.mediaId}: Encryption failed. Encrypted file still missing.',
);
return;
}
Log.info(
'Media ${mediaService.mediaFile.mediaId}: Encryption completed successfully.',
);
} else {
Log.info(
'Media ${mediaService.mediaFile.mediaId}: Encrypted file already exists.',
);
}
if (!mediaService.uploadRequestPath.existsSync()) {
Log.info(
'Media ${mediaService.mediaFile.mediaId}: Upload request file not found. Creating it.',
);
await _createUploadRequest(mediaService);
if (!mediaService.uploadRequestPath.existsSync()) {
Log.warn(
'Media ${mediaService.mediaFile.mediaId}: Upload request file creation returned empty (e.g. no messages).',
);
}
} else {
Log.info(
'Media ${mediaService.mediaFile.mediaId}: Upload request file already exists.',
);
}
if (mediaService.uploadRequestPath.existsSync()) {
Log.info(
'Media ${mediaService.mediaFile.mediaId}: Transitioning uploadState from ${mediaService.mediaFile.uploadState} to uploading.',
);
await mediaService.setUploadState(UploadState.uploading);
// at this point the original file is not used any more, so it can be deleted
if (mediaService.originalPath.existsSync()) {
Log.info(
'Media ${mediaService.mediaFile.mediaId}: Deleting original file as it is now encrypted.',
);
mediaService.originalPath.deleteSync();
}
}
}
if (mediaService.mediaFile.uploadState == UploadState.uploading ||
mediaService.mediaFile.uploadState == UploadState.uploadLimitReached) {
await _uploadUploadRequest(mediaService);
}
}
Future<void> _encryptMediaFiles(MediaFileService mediaService) async {
/// if there is a video wait until it is finished with compression
if (!mediaService.tempPath.existsSync()) {
Log.error('Could not encrypted image as it does not exists');
return;
}
final dataToEncrypt = await mediaService.tempPath.readAsBytes();
final chacha20 = FlutterChacha20.poly1305Aead();
final secretBox = await chacha20.encrypt(
dataToEncrypt,
secretKey: SecretKey(mediaService.mediaFile.encryptionKey!),
nonce: mediaService.mediaFile.encryptionNonce,
);
await mediaService.setEncryptedMac(Uint8List.fromList(secretBox.mac.bytes));
mediaService.encryptedPath.writeAsBytesSync(
Uint8List.fromList(secretBox.cipherText),
);
}
Future<void> _createUploadRequest(MediaFileService media) async {
final downloadTokens = <Uint8List>[];
final messagesOnSuccess = <TextMessage>[];
final messages = await twonlyDB.messagesDao.getMessagesByMediaId(
media.mediaFile.mediaId,
);
if (messages.isEmpty) {
// There where no user selected who should receive the image, so waiting with this step...
Log.info(
'Media ${media.mediaFile.mediaId}: No recipient messages found, waiting to create upload request',
);
return;
}
for (final message in messages) {
final groupMembers = await twonlyDB.groupsDao.getGroupNonLeftMembers(
message.groupId,
);
if (media.mediaFile.reuploadRequestedBy == null) {
await incFlameCounter(message.groupId, false, message.createdAt);
}
for (final groupMember in groupMembers) {
/// only send the upload to the users
if (media.mediaFile.reuploadRequestedBy != null) {
if (!media.mediaFile.reuploadRequestedBy!.contains(
groupMember.contactId,
)) {
continue;
}
}
final contact = await twonlyDB.contactsDao.getContactById(
groupMember.contactId,
);
if (contact == null || contact.accountDeleted) {
continue;
}
final downloadToken = getRandomUint8List(32);
late EncryptedContent_Media_Type type;
switch (media.mediaFile.type) {
case MediaType.audio:
type = EncryptedContent_Media_Type.AUDIO;
case MediaType.image:
type = EncryptedContent_Media_Type.IMAGE;
case MediaType.gif:
type = EncryptedContent_Media_Type.GIF;
case MediaType.video:
type = EncryptedContent_Media_Type.VIDEO;
}
if (media.mediaFile.reuploadRequestedBy != null) {
// not used any more... Receiver detects automatically if it is an reupload...
// type = EncryptedContent_Media_Type.REUPLOAD;
}
final notEncryptedContent = EncryptedContent(
groupId: message.groupId,
media: EncryptedContent_Media(
senderMessageId: message.messageId,
type: type,
requiresAuthentication: media.mediaFile.requiresAuthentication,
timestamp: Int64(message.createdAt.millisecondsSinceEpoch),
downloadToken: downloadToken.toList(),
encryptionKey: media.mediaFile.encryptionKey,
encryptionNonce: media.mediaFile.encryptionNonce,
encryptionMac: media.mediaFile.encryptionMac,
additionalMessageData: message.additionalMessageData,
),
);
if (media.mediaFile.displayLimitInMilliseconds != null) {
notEncryptedContent.media.displayLimitInMilliseconds = Int64(
media.mediaFile.displayLimitInMilliseconds!,
);
}
final cipherText = await RustApi.sendEncryptedContent(
contactId: groupMember.contactId,
content: notEncryptedContent.writeToBuffer(),
messageId: message.messageId,
onlySendIfNoReceiptsAreOpen: false,
onlyReturnEncryptedData: true,
blocking: true,
);
if (cipherText == null) {
Log.error(
'Could not generate ciphertext message for ${groupMember.contactId}',
);
continue;
}
final messageOnSuccess = TextMessage()
..body = cipherText
..userId = Int64(groupMember.contactId)
// A media message is user visible, so the server may send the opaque
// FCM wake-up once the upload completes.
..wakeReceiver = true;
messagesOnSuccess.add(messageOnSuccess);
downloadTokens.add(downloadToken);
}
}
final bytesToUpload = await media.encryptedPath.readAsBytes();
final uploadRequest = UploadRequest(
messagesOnSuccess: messagesOnSuccess,
downloadTokens: downloadTokens,
encryptedData: bytesToUpload,
);
final uploadRequestBytes = uploadRequest.writeToBuffer();
if (uploadRequestBytes.length > 49_000_000) {
await media.setUploadState(UploadState.fileLimitReached);
await twonlyDB.messagesDao.updateMessagesByMediaId(
media.mediaFile.mediaId,
MessagesCompanion(
openedAt: Value(DateTime.now()),
ackByServer: Value(DateTime.now()),
),
);
return;
}
await media.uploadRequestPath.writeAsBytes(uploadRequestBytes);
}
Future<bool> _checkAndRecoverMissingUploadRequest(
MediaFileService media, {
bool triggerBackgroundUpload = false,
}) async {
if (!media.uploadRequestPath.existsSync()) {
Log.warn(
'UploadRequestPath for media ${media.mediaFile.mediaId} does not exist. Reverting to preprocessing.',
);
await media.setUploadState(UploadState.preprocessing);
if (triggerBackgroundUpload) {
unawaited(startBackgroundMediaUpload(media));
}
return true;
}
return false;
}
Future<void> _uploadUploadRequest(MediaFileService media) async {
if (await _checkAndRecoverMissingUploadRequest(
media,
triggerBackgroundUpload: true,
)) {
return;
}
final currentMedia = await twonlyDB.mediaFilesDao.getMediaFileById(
media.mediaFile.mediaId,
);
if (currentMedia == null ||
currentMedia.uploadState == UploadState.backgroundUploadTaskStarted) {
Log.info('Download for ${media.mediaFile.mediaId} already started.');
return;
}
final apiUrl = '${RustApi.apiBaseUrl(protocol: 'https')}upload';
Log.info('Starting upload from ${media.mediaFile.mediaId}');
final headers = await getAuthenticationHeader();
if (headers == null) {
Log.error('Auth headers are empty. Returning');
return;
}
final task = UploadTask.fromFile(
taskId: 'upload_${media.mediaFile.mediaId}',
displayName: media.mediaFile.type.name,
file: media.uploadRequestPath,
url: apiUrl,
priority: 0,
retries: 10,
headers: headers,
);
final connectivityResult = await Connectivity().checkConnectivity();
if (AppState.isInBackgroundTask ||
!connectivityResult.contains(ConnectivityResult.mobile) &&
!connectivityResult.contains(ConnectivityResult.wifi)) {
// no internet, directly put it into the background...
await FileDownloader().enqueue(task);
await media.setUploadState(UploadState.backgroundUploadTaskStarted);
Log.info('Enqueue upload task: ${task.taskId}');
} else {
unawaited(uploadFileFastOrEnqueue(task, media));
}
}
Future<void> uploadFileFastOrEnqueue(
UploadTask task,
MediaFileService media,
) async {
if (await _checkAndRecoverMissingUploadRequest(
media,
triggerBackgroundUpload: true,
)) {
return;
}
final requestMultipart = http.MultipartRequest(
'POST',
Uri.parse(task.url),
);
requestMultipart.headers.addAll(task.headers);
requestMultipart.files.add(
await http.MultipartFile.fromPath(
'file',
await task.filePath(),
filename: 'upload',
),
);
try {
final workmanagerUniqueName =
'progressing_finish_uploads_${media.mediaFile.mediaId}';
await Workmanager().registerOneOffTask(
workmanagerUniqueName,
'eu.twonly.processing_task',
initialDelay: const Duration(minutes: 15),
constraints: Constraints(
networkType: NetworkType.connected,
),
);
Log.info('Uploading fast: ${task.taskId}');
final response = await requestMultipart.send();
var status = TaskStatus.failed;
if (response.statusCode == 200) {
status = TaskStatus.complete;
} else if (response.statusCode == 404) {
status = TaskStatus.notFound;
}
await Workmanager().cancelByUniqueName(workmanagerUniqueName);
await handleUploadStatusUpdate(
TaskStatusUpdate(
task,
status,
null,
null,
null,
response.statusCode,
),
);
} catch (e) {
Log.info('Upload failed enqueuing task...');
await FileDownloader().enqueue(task);
await media.setUploadState(UploadState.backgroundUploadTaskStarted);
}
}

View file

@ -1,19 +0,0 @@
import 'dart:typed_data';
import 'package:twonly/core/bridge/api.dart' as rust_api;
import 'package:twonly/src/database/twonly.db.dart' show Receipt;
Future<Uint8List?> tryToSendCompleteMessage({
String? receiptId,
Receipt? receipt,
bool onlyReturnEncryptedData = false,
bool blocking = true,
}) async {
final id = receiptId ?? receipt?.receiptId;
if (id == null) return null;
if (!onlyReturnEncryptedData) {
await rust_api.RustApi.sendQueuedMessage(receiptId: id);
return null;
}
return rust_api.RustApi.prepareQueuedMessage(receiptId: id);
}

View file

@ -1,9 +1,20 @@
import 'package:twonly/src/model/error_code.dart';
import 'package:twonly/src/services/api/utils.api.dart';
import 'package:twonly/src/utils/log.dart';
export 'package:twonly/src/model/error_code.dart';
class Result<T, E> {
Result.error(this.error) : value = null, _isSuccess = false;
Result.success(this.value) : error = null, _isSuccess = true;
final T? value;
final E? error;
final bool _isSuccess;
bool get isSuccess => _isSuccess;
bool get isError => !_isSuccess;
}
Future<Result<T, ErrorCode>> rustApiResult<T>(Future<T> request) async {
try {
return Result.success(await request);

View file

@ -1,116 +0,0 @@
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:fixnum/fixnum.dart';
import 'package:twonly/core/bridge/wrapper/key_manager.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/client/generated/messages.pbserver.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/utils/secure_storage.dart';
class Result<T, E> {
Result.error(this.error) : value = null, _isSuccess = false;
Result.success(this.value) : error = null, _isSuccess = true;
final T? value;
final E? error;
final bool _isSuccess;
bool get isSuccess => _isSuccess;
bool get isError => !_isSuccess;
}
DateTime fromTimestamp(Int64 timeStamp) {
final date = DateTime.fromMillisecondsSinceEpoch(timeStamp.toInt());
final now = DateTime.now();
if (date.isAfter(now)) {
return now;
}
return date;
}
Future<void> handleMediaError(MediaFile media) async {
await twonlyDB.mediaFilesDao.updateMedia(
media.mediaId,
const MediaFilesCompanion(
downloadState: Value(DownloadState.reuploadRequested),
),
);
final messages = await twonlyDB.messagesDao.getMessagesByMediaId(
media.mediaId,
);
if (messages.length != 1) return;
final message = messages.first;
if (message.senderId == null) return;
await RustApi.sendEncryptedContent(
contactId: message.senderId!,
content: EncryptedContent(
mediaUpdate: EncryptedContent_MediaUpdate(
type: EncryptedContent_MediaUpdate_Type.DECRYPTION_ERROR,
targetMessageId: message.messageId,
),
).writeToBuffer(),
);
}
Future<bool> importSignalContactAndCreateRequest(
FrbUserData userdata,
) async {
try {
await RustApi.establishSignalSession(
contactId: userdata.userId,
expectedPublicKey: Uint8List.fromList(userdata.publicIdentityKey),
);
// 2. Then send user request
await RustApi.sendEncryptedContent(
contactId: userdata.userId,
content: EncryptedContent(
contactRequest: EncryptedContent_ContactRequest(
type: EncryptedContent_ContactRequest_Type.REQUEST,
),
).writeToBuffer(),
);
return true;
} catch (e) {
Log.error('Failed to establish session and send contact request: $e');
return false;
}
}
Future<Map<String, String>?> getAuthenticationHeader() async {
var headers = <String, String>{};
if (userService.currentUser.canUseLoginTokenForAuth) {
final loginToken = await RustKeyManager.getLoginToken();
headers = {
'x-twonly-user-id': userService.currentUser.userId
.toRadixString(16)
.padLeft(16, '0')
.toUpperCase(),
'x-twonly-login-token': uint8ListToHex(loginToken),
};
} else {
final apiAuthTokenRaw = await SecureStorage.instance.read(
key: 'api_auth_token',
);
if (apiAuthTokenRaw == null) {
Log.error('api auth token not defined.');
return null;
}
final apiAuthToken = uint8ListToHex(base64Decode(apiAuthTokenRaw));
headers = {
'x-twonly-auth-token': apiAuthToken,
};
}
return headers;
}

View file

@ -1,183 +0,0 @@
import 'dart:async';
import 'package:mutex/mutex.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/main.dart';
import 'package:twonly/src/constants/keyvalue.keys.dart';
import 'package:twonly/src/services/api/mediafiles/upload.api.dart';
import 'package:twonly/src/utils/exclusive_access.utils.dart';
import 'package:twonly/src/utils/keyvalue.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/startup_guard.dart';
import 'package:workmanager/workmanager.dart';
// ignore: unreachable_from_main
Future<void> initializeBackgroundTaskManager() async {
await Workmanager().initialize(callbackDispatcher);
await Workmanager().cancelByUniqueName('fetch_data_from_server');
// await Workmanager().registerPeriodicTask(
// 'fetch_data_from_server',
// 'eu.twonly.periodic_task',
// frequency: const Duration(minutes: 20),
// initialDelay: const Duration(minutes: 5),
// existingWorkPolicy: ExistingPeriodicWorkPolicy.update,
// constraints: Constraints(
// networkType: NetworkType.connected,
// ),
// );
}
@pragma('vm:entry-point')
void callbackDispatcher() {
Workmanager().executeTask((task, inputData) async {
SentryWidgetsFlutterBinding.ensureInitialized();
await AppEnvironment.init();
switch (task) {
case 'eu.twonly.periodic_task':
// if (await initBackgroundExecution()) {
// await handlePeriodicTask();
// }
break;
case 'eu.twonly.processing_task':
case _ when task.startsWith('progressing_finish_uploads_'):
if (await initBackgroundExecution()) {
await handleProcessingTask();
}
default:
Log.error('Unknown task was executed: $task');
}
return Future.value(true);
});
}
bool _isInitialized = false;
Future<bool> initBackgroundExecution() async {
// 1. Check startup guard IMMEDIATELY before doing ANYTHING else.
if (await StartupGuard.isAppStarting()) {
return false;
}
AppState.isInBackgroundTask = true;
if (await StartupGuard.isAppStarting()) {
Log.error('App is starting. Returning early.');
return false;
}
if (_isInitialized) {
// Reload the users, as on Android the background isolate can
// stay alive for multiple hours between task executions
return userService.tryInit();
}
await twonlyMinimumInitialization();
if (!await userService.tryInit()) {
Log.info('Early return as user is not registered yet.');
return false;
}
Log.info('Background task is initialized');
_isInitialized = true;
return true;
}
final Mutex _keyValueMutex = Mutex();
// ignore: unreachable_from_main
Future<bool> backgroundFetch({
int? lastExecutionInSecondsLimit = 120,
}) async {
if (lastExecutionInSecondsLimit != null) {
final shouldBeExecuted = await exclusiveAccess(
lockName: 'periodic_task',
mutex: _keyValueMutex,
action: () async {
final lastExecution = await KeyValueStore.get(
KeyValueKeys.lastPeriodicTaskExecution,
);
if (lastExecution != null && lastExecution.containsKey('timestamp')) {
final lastExecutionTime = lastExecution['timestamp'] as int?;
if (lastExecutionTime != null) {
final lastExecutionDate = DateTime.fromMillisecondsSinceEpoch(
lastExecutionTime,
);
if (DateTime.now().difference(lastExecutionDate).inSeconds <
lastExecutionInSecondsLimit) {
return false;
}
}
}
await KeyValueStore.put(KeyValueKeys.lastPeriodicTaskExecution, {
'timestamp': DateTime.now().millisecondsSinceEpoch,
});
return true;
},
);
if (!shouldBeExecuted) return false;
}
Log.info('Periodic task was called.');
AppState.gotMessageFromServer = false;
final stopwatch = Stopwatch()..start();
var authenticated = false;
for (var attempt = 0; attempt < 100; attempt++) {
final state = await RustApi.connectionState();
if (state == ApiConnectionState.authenticated) {
authenticated = true;
break;
}
if (state == ApiConnectionState.permanentlyRejected ||
state == ApiConnectionState.suspended) {
break;
}
await Future<void>.delayed(const Duration(milliseconds: 100));
}
if (!authenticated) {
Log.info('Api is not authenticated. Returning early.');
return false;
}
var receiveMessage = false;
try {
while (!AppState.gotMessageFromServer) {
if (stopwatch.elapsed.inSeconds >= 15) {
Log.info('No new message from the server after 15 seconds.');
break;
}
await Future.delayed(const Duration(milliseconds: 500));
}
if (AppState.gotMessageFromServer) {
receiveMessage = true;
Log.info('Received a server message from the server.');
}
await finishStartedPreprocessing();
if (lastExecutionInSecondsLimit != null) {
await Future.delayed(const Duration(milliseconds: 2000));
}
} finally {
await RustApi.close();
stopwatch.stop();
}
Log.info('Periodic task finished after ${stopwatch.elapsed}.');
return receiveMessage;
}
Future<void> handleProcessingTask() async {
Log.info('eu.twonly.processing_task was called.');
final stopwatch = Stopwatch()..start();
await finishStartedPreprocessing();
Log.info('eu.twonly.processing_task finished after ${stopwatch.elapsed}.');
}

View file

@ -1,9 +1,9 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:background_downloader/background_downloader.dart';
import 'package:clock/clock.dart' as clock;
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:mutex/mutex.dart';
import 'package:twonly/core/bridge/wrapper/backup.dart';
@ -12,7 +12,6 @@ import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/keyvalue.keys.dart';
import 'package:twonly/src/model/json/backup.model.dart';
import 'package:twonly/src/services/api/utils.api.dart';
import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/keyvalue.dart';
import 'package:twonly/src/utils/log.dart';
@ -31,6 +30,42 @@ class BackupService {
static final _backupUpdateController = StreamController<void>.broadcast();
static Stream<void> get onBackupUpdated => _backupUpdateController.stream;
static Future<void> initFileDownloader() async {
FileDownloader().updates.listen((update) async {
switch (update) {
case TaskStatusUpdate():
if (update.task.taskId.contains('backup_')) {
await handleBackupStatusUpdate(update.task.taskId, update);
}
case TaskProgressUpdate():
Log.info(
'Progress update for ${update.task} with progress ${update.progress}',
);
}
});
await FileDownloader().start();
try {
var androidConfig = [];
if (!kReleaseMode) {
androidConfig = [(Config.bypassTLSCertificateValidation, true)];
}
await FileDownloader().configure(androidConfig: androidConfig);
} catch (error) {
Log.error(error);
}
if (!kReleaseMode) {
FileDownloader().configureNotification(
running: const TaskNotification(
'Uploading/Downloading',
'{filename} ({progress}).',
),
progressBar: true,
);
}
}
static Future<CurrentBackupStatus> getData() async {
return CurrentBackupStatus.fromJson(
(await KeyValueStore.get(KeyValueKeys.currentBackupState)) ??
@ -170,9 +205,11 @@ class BackupService {
'Archive backup has a size of ${File(backupArchive).statSync().size}.',
);
final headers = await getAuthenticationHeader();
if (headers == null) {
Log.error('Auth headers are empty. Returning');
late final Map<String, String> headers;
try {
headers = await RustApi.authenticationHeaders();
} catch (error) {
Log.error('Could not load authentication headers', error: error);
return;
}

View file

@ -64,54 +64,20 @@ class KeyVerificationService {
);
}
static Future<void> handleVerificationProof(
int fromUserId,
List<int> receivedMac,
) async {
Log.info('Received a verification proof. Verifying the calculated mac...');
final contactPubKey = await RustSignal.getContactPublicKey(
contactId: fromUserId,
);
if (contactPubKey == null) {
Log.error('No public key stored..');
return;
}
final secretTokens = await twonlyDB.keyVerificationDao
.getRecentVerificationTokens();
for (final secretToken in secretTokens) {
final recalculatedMac = await _createVerificationBytes(
fromUserId,
contactPubKey,
secretToken.token,
true,
static Future<void> handleVerificationSucceeded(int fromUserId) async {
final contact = await twonlyDB.contactsDao.getContactById(fromUserId);
final context = rootNavigatorKey.currentContext;
if (context != null && context.mounted && contact != null) {
unawaited(
VerificationSuccessDialog.show(
context,
contact,
message: context.lang.secretQrTokenVerifiedSnackbar(
getContactDisplayName(contact),
),
),
);
if (recalculatedMac.equals(receivedMac)) {
await twonlyDB.keyVerificationDao.addKeyVerification(
fromUserId,
VerificationType.secretQrToken,
);
Log.info('Contact was verified via secretQrToken');
final contact = await twonlyDB.contactsDao.getContactById(fromUserId);
final context = rootNavigatorKey.currentContext;
if (context != null && context.mounted && contact != null) {
unawaited(
VerificationSuccessDialog.show(
context,
contact,
message: context.lang.secretQrTokenVerifiedSnackbar(
getContactDisplayName(contact),
),
),
);
}
return;
}
}
Log.error('No valid secret token could be found...');
}
static Future<void> verifySharedContact({
@ -119,6 +85,10 @@ class KeyVerificationService {
required List<int> sharedPublicIdentityKey,
required int senderId,
}) async {
if (sharedPublicIdentityKey.isEmpty) {
Log.info('Shared contact $contactId carries no public key');
return;
}
final publicIdentityKey = await RustSignal.getContactPublicKey(
contactId: contactId,
);

View file

@ -1,147 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:drift/drift.dart' show Value;
import 'package:flutter/foundation.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:pro_video_editor/pro_video_editor.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/channels/video_compression.channel.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/utils/log.dart';
Future<void> compressImage(
File sourceFile,
File destinationFile,
) async {
final stopwatch = Stopwatch()..start();
try {
var compressedBytes = await FlutterImageCompress.compressWithFile(
sourceFile.path,
format: CompressFormat.webp,
quality: 90,
);
if (compressedBytes == null) {
throw Exception(
'Could not compress media file: Sending original file.',
);
}
Log.info('Compressed images size in bytes: ${compressedBytes.length}');
if (compressedBytes.length >= 2 * 1000 * 1000) {
// if the media file is over 1MB compress it with 60%
final tmpCompressedBytes = await FlutterImageCompress.compressWithFile(
sourceFile.path,
format: CompressFormat.webp,
quality: 60,
);
if (tmpCompressedBytes == null) {
Log.error(
'Could not compress media file with 60%: $sourceFile. Sending original 90% compressed file.',
);
} else {
compressedBytes = tmpCompressedBytes;
}
}
await destinationFile.writeAsBytes(compressedBytes);
} catch (e) {
Log.warn('$e');
sourceFile.copySync(destinationFile.path);
}
stopwatch.stop();
Log.info(
'Compression of the image took: ${stopwatch.elapsedMilliseconds} milliseconds.',
);
}
Future<void> compressAndOverlayVideo(MediaFileService media) async {
if (media.tempPath.existsSync()) {
media.tempPath.deleteSync();
}
if (media.ffmpegOutputPath.existsSync()) {
media.ffmpegOutputPath.deleteSync();
}
final stopwatch = Stopwatch()..start();
try {
final task = VideoRenderData(
videoSegments: [
VideoSegment(video: EditorVideo.file(media.originalPath)),
],
imageLayers: [
if (media.overlayImagePath.existsSync())
ImageLayer(image: EditorLayerImage.file(media.overlayImagePath)),
],
enableAudio: !media.removeAudio,
);
await ProVideoEditor.instance.renderVideoToFile(
media.ffmpegOutputPath.path,
task,
);
if (Platform.isIOS ||
media.ffmpegOutputPath.statSync().size >= 10_000_000 ||
!kReleaseMode) {
String? compressedPath;
try {
compressedPath = await VideoCompressionChannel.compressVideo(
inputPath: media.ffmpegOutputPath.path,
outputPath: media.tempPath.path,
onProgress: (progress) async {
await twonlyDB.mediaFilesDao.updateMedia(
media.mediaFile.mediaId,
MediaFilesCompanion(
preProgressingProcess: Value((progress * 100).toInt()),
),
);
},
);
} catch (e) {
Log.warn('during video compression: $e');
}
if (compressedPath == null) {
Log.warn('Could not compress video using original video.');
// as a fall back use the non compressed version
media.ffmpegOutputPath.copySync(media.tempPath.path);
}
} else {
// In case the video is smaller than 10MB do not compress it...
media.ffmpegOutputPath.copySync(media.tempPath.path);
}
stopwatch.stop();
final sizeFrom = (media.ffmpegOutputPath.statSync().size / 1024 / 1024)
.toStringAsFixed(2);
final sizeTo = (media.tempPath.statSync().size / 1024 / 1024)
.toStringAsFixed(2);
Log.info(
'It took ${stopwatch.elapsedMilliseconds}ms to compress the video. Reduced from $sizeFrom to $sizeTo bytes.',
);
} catch (e) {
Log.error(e);
// Log.error('Compression failed for the video with exit code $returnCode.');
// Log.error(await session.getAllLogsAsString());
// This should not happen, but in case "notify" the user that the video was not send... This is absolutely bad, but
// better this way then sending an uncompressed media file which potentially is 100MB big :/
// Hopefully the user will report the strange behavior <3
await twonlyDB.messagesDao.updateMessagesByMediaId(
media.mediaFile.mediaId,
const MessagesCompanion(isDeletedFromSender: Value(true)),
);
media.fullMediaRemoval();
await media.setUploadState(UploadState.uploaded);
}
}

View file

@ -57,14 +57,3 @@ Future<void> startDownloadMedia(MediaFile media, bool force) async {
await RustApi.downloadMedia(mediaId: media.mediaId);
}
}
Future<void> requestMediaReupload(String mediaId) =>
RustApi.requestMediaReupload(mediaId: mediaId);
Future<void> makeMigrationToVersion91() async {
final mediaFiles = await twonlyDB.mediaFilesDao
.getAllMediaFilesReuploadRequested();
for (final media in mediaFiles) {
await RustApi.requestMediaReupload(mediaId: media.mediaId);
}
}

View file

@ -1,26 +1,32 @@
import 'dart:async';
import 'dart:io';
import 'package:clock/clock.dart';
import 'package:drift/drift.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:image/image.dart' as img;
import 'package:path/path.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/mediafiles/compression.service.dart';
import 'package:twonly/src/services/mediafiles/thumbnail.service.dart';
import 'package:twonly/src/services/memories/memories_cloud.service.dart'
show MemoriesCloudService;
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
class MediaFileService {
MediaFileService(this.mediaFile);
MediaFile mediaFile;
MediaFileService(this._mediaFile);
MediaFile _mediaFile;
/// Built file paths, keyed by the `_buildFilePath` arguments. Resolving a
/// path is pure string work plus a directory check, but the getters below are
/// read from `build()` methods, so the result is memoised per instance.
final Map<String, File> _pathCache = {};
MediaFile get mediaFile => _mediaFile;
set mediaFile(MediaFile value) {
if (value.mediaId != _mediaFile.mediaId || value.type != _mediaFile.type) {
_pathCache.clear();
}
_mediaFile = value;
}
static Future<MediaFileService?> fromMediaId(String mediaId) async {
final mediaFile = await twonlyDB.mediaFilesDao.getMediaFileById(mediaId);
@ -30,121 +36,9 @@ class MediaFileService {
);
}
static Future<void> purgeTempFolder() async {
try {
final tempDirectory = MediaFileService.buildDirectoryPath(
'tmp',
AppEnvironment.supportDir,
);
final files = tempDirectory.listSync();
if (files.isEmpty) return;
final mediaIdToFile = <String, List<FileSystemEntity>>{};
for (final file in files) {
final mediaId = basename(file.path).split('.').first;
mediaIdToFile.putIfAbsent(mediaId, () => []).add(file);
}
final mediaIds = mediaIdToFile.keys.toList();
// Bulk fetch media files and messages
final allMediaFiles = await twonlyDB.mediaFilesDao.getMediaFilesByIds(
mediaIds,
);
final allMessages = await twonlyDB.messagesDao.getMessagesByMediaIds(
mediaIds,
);
final mediaFileMap = {for (final m in allMediaFiles) m.mediaId: m};
final messageMap = <String, List<Message>>{};
for (final msg in allMessages) {
if (msg.mediaId != null) {
messageMap.putIfAbsent(msg.mediaId!, () => []).add(msg);
}
}
for (final mediaId in mediaIds) {
// in case the mediaID is unknown the file will be deleted
var delete = true;
final mediaFile = mediaFileMap[mediaId];
if (mediaFile != null) {
if (mediaFile.isDraftMedia) {
delete = false;
}
// Never purge temp files while an upload is still in progress.
// The temp file is actively needed for encryption/upload.
if (mediaFile.uploadState != UploadState.uploaded &&
mediaFile.uploadState != UploadState.fileLimitReached) {
delete = false;
}
final messages = messageMap[mediaId] ?? [];
// in case messages in empty the file will be deleted, as delete is true by default
for (final message in messages) {
if (mediaFile.type == MediaType.audio) {
delete = false; // do not delete voice messages
}
if (message.openedAt == null) {
// Message was not yet opened from all persons, so wait...
delete = false;
} else if (message.openedAt!.isAfter(
clock.now().subtract(const Duration(minutes: 3)),
)) {
// When the message was opened in the last two minutes, do not purge.
// Bug: When the user opens an image immediately after starting the app, there is a race condition:
// The message is marked as opened, but then purgeTempFolder is run
// (it is unawaited) and deletes the file. Thi gives a grace period:
// The image must have been opened within the last two minutes, otherwise do not delete it.
delete = false;
} else if (mediaFile.requiresAuthentication ||
mediaFile.displayLimitInMilliseconds != null) {
// Message was opened by all persons, and they can not reopen the image.
} else if (message.openedAt!.isAfter(
clock.now().subtract(const Duration(days: 2)),
)) {
// In case the image was opened, but send with unlimited time or no authentication.
if (message.senderId == null) {
delete = false;
} else {
// Check weather the image was send in a group. Then the images is preserved for two days in case another person stores the image.
// This also allows to reopen this image for two days.
final group = await twonlyDB.groupsDao.getGroup(
message.groupId,
);
if (group != null && !group.isDirectChat) {
delete = false;
}
}
// In case the app was send in a direct chat, then it can be deleted.
}
}
}
if (delete) {
Log.info('Purging media file $mediaId');
final filesToPurge = mediaIdToFile[mediaId] ?? [];
for (final file in filesToPurge) {
try {
if (file.existsSync()) {
file.deleteSync();
}
} catch (e) {
Log.error('Error deleting file ${file.path}: $e');
}
}
}
}
} catch (e) {
Log.error('Error in purgeTempFolder: $e');
}
}
/// Rust decides which temporary media a message is finished with; this only
/// triggers the sweep.
static Future<void> purgeTempFolder() => RustApi.purgeMediaTempFolder();
Future<void> updateFromDB() async {
final updated = await twonlyDB.mediaFilesDao.getMediaFileById(
@ -156,11 +50,9 @@ class MediaFileService {
}
Future<void> setDisplayLimit(int? displayLimitInMilliseconds) async {
await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId,
MediaFilesCompanion(
displayLimitInMilliseconds: Value(displayLimitInMilliseconds),
),
await RustApi.setMediaDisplayLimit(
mediaId: mediaFile.mediaId,
displayLimitInMilliseconds: displayLimitInMilliseconds,
);
await updateFromDB();
}
@ -168,124 +60,32 @@ class MediaFileService {
bool get removeAudio => mediaFile.removeAudio ?? false;
Future<void> toggleRemoveAudio() async {
// var removeAudio = false;
// if (mediaFile.removeAudio != null) {
// removeAudio = !mediaFile.removeAudio!;
// }
await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId,
MediaFilesCompanion(
removeAudio: Value(!removeAudio),
),
);
await updateFromDB();
}
Future<void> setUploadState(UploadState uploadState) async {
await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId,
MediaFilesCompanion(
uploadState: Value(uploadState),
),
);
await updateFromDB();
}
Future<void> setEncryptedMac(Uint8List encryptionMac) async {
await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId,
MediaFilesCompanion(
encryptionMac: Value(encryptionMac),
),
);
await RustApi.toggleMediaRemoveAudio(mediaId: mediaFile.mediaId);
await updateFromDB();
}
Future<void> setRequiresAuth(bool requiresAuthentication) async {
await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId,
MediaFilesCompanion(
requiresAuthentication: Value(requiresAuthentication),
displayLimitInMilliseconds: requiresAuthentication
? const Value(12000)
: const Value.absent(),
),
await RustApi.setMediaRequiresAuthentication(
mediaId: mediaFile.mediaId,
requiresAuthentication: requiresAuthentication,
);
await updateFromDB();
}
/// Rust renders thumbnails, including the video frame grab, so this only
/// asks it to refresh the one for this media file.
Future<void> createThumbnail() async {
if (!storedPath.existsSync() || storedPath.lengthSync() == 0) {
if (storedPath.existsSync() && storedPath.lengthSync() == 0) {
try {
storedPath.deleteSync();
} catch (_) {}
}
if (mediaFile.stored &&
mediaFile.cloudState == CloudState.none &&
mediaFile.createdAt.isBefore(
clock.now().subtract(const Duration(days: 30)),
)) {
// media files does not exists any more so also delete the database entry
await twonlyDB.mediaFilesDao.deleteMediaFile(mediaFile.mediaId);
fullMediaRemoval();
}
return;
}
var success = false;
switch (mediaFile.type) {
case MediaType.gif:
success = await createThumbnailsForGif(storedPath, thumbnailPath);
case MediaType.image:
success = await createThumbnailsForImage(storedPath, thumbnailPath);
case MediaType.video:
success = await createThumbnailsForVideo(storedPath, thumbnailPath);
case MediaType.audio:
break;
}
if (success) {
await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId,
const MediaFilesCompanion(hasThumbnail: Value(true)),
);
await updateFromDB();
}
await RustApi.mediaStepFinished(
mediaId: mediaFile.mediaId,
kind: 'thumbnail',
);
await updateFromDB();
}
Future<void> compressMedia() async {
if (!originalPath.existsSync()) {
Log.warn('Could not compress as original media does not exists.');
return;
}
switch (mediaFile.type) {
case MediaType.image:
await compressImage(originalPath, tempPath);
case MediaType.video:
await compressAndOverlayVideo(this);
case MediaType.audio:
case MediaType.gif:
originalPath.copySync(tempPath.path);
}
}
void fullMediaRemoval() {
final pathsToRemove = [
tempPath,
encryptedPath,
originalPath,
storedPath,
thumbnailPath,
uploadRequestPath,
];
for (final path in pathsToRemove) {
if (path.existsSync()) {
path.deleteSync();
}
}
}
/// Removes every file of this media item. The row is kept so a message that
/// still references it keeps rendering.
Future<void> fullMediaRemoval() =>
RustApi.removeMediaFiles(mediaId: mediaFile.mediaId);
// Media was send with unlimited display limit time and without auth required
// and the temp media file still exists, then the media file can be reopened again...
@ -300,79 +100,38 @@ class MediaFileService {
((mediaFile.type == MediaType.image || mediaFile.type == MediaType.gif) &&
mediaFile.stored);
/// Rust keeps the local copy, exports it to the gallery when the user asked
/// for that, and recomputes size, hash and thumbnail state from the file it
/// wrote.
Future<void> storeMediaFile() async {
Log.info('Storing media file ${mediaFile.mediaId}');
await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId,
const MediaFilesCompanion(
stored: Value(true),
),
);
if (originalPath.existsSync() && !tempPath.existsSync()) {
await compressMedia();
}
if (tempPath.existsSync()) {
await tempPath.copy(storedPath.path);
if (userService.currentUser.storeMediaFilesInGallery) {
if (mediaFile.type == MediaType.video) {
await saveVideoToGallery(
storedPath.path,
name: mediaFile.mediaId,
);
} else {
unawaited(
saveImageToGallery(
await storedPath.readAsBytes(),
createdAt: mediaFile.createdAt,
name: mediaFile.mediaId,
),
);
}
}
} else {
Log.warn(
'Could not store image locally as ${tempPath.path} does not exist.',
);
}
unawaited(createThumbnail());
await calculateAndSaveSize();
await hashMediaFile();
await RustApi.storeMedia(mediaId: mediaFile.mediaId);
await updateFromDB();
await MemoriesCloudService().checkUploads();
// updateFromDb is done in hashStoredMedia()
}
Future<void> calculateAndSaveSize() async {
if (storedPath.existsSync()) {
final size = storedPath.lengthSync();
await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId,
MediaFilesCompanion(
sizeInBytes: Value(size),
),
);
await updateFromDB();
}
}
Future<void> hashMediaFile() async {
late final List<int> checksum;
if (storedPath.existsSync()) {
checksum = await sha256File(storedPath);
} else if (tempPath.existsSync()) {
checksum = await sha256File(tempPath);
} else {
return;
}
await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId,
MediaFilesCompanion(
storedFileHash: Value(Uint8List.fromList(checksum)),
),
);
/// Recomputes the size and content hash Rust keeps for the stored file.
Future<void> refreshStoredMetadata() async {
await RustApi.mediaStepFinished(mediaId: mediaFile.mediaId, kind: 'stored');
await updateFromDB();
}
/// Exports this media file to the user's photo library.
Future<void> saveToGallery() =>
RustApi.saveMediaToGallery(mediaId: mediaFile.mediaId);
/// Trimming transparent borders, and the size, hash and preview derived from
/// the result, are all Rust-owned.
Future<void> cropTransparentBorders() async {
await RustApi.cropMediaTransparentBorders(mediaId: mediaFile.mediaId);
await updateFromDB();
}
/// Directories this isolate has already created. The media folders are only
/// ever created, never removed behind our back, so the `existsSync` probe
/// only has to run once per directory instead of on every path lookup.
static final Set<String> _ensuredDirectories = {};
static Directory buildDirectoryPath(
String directory,
String applicationSupportDirectory,
@ -384,8 +143,10 @@ class MediaFileService {
directory,
),
);
if (!mediaBaseDir.existsSync()) {
mediaBaseDir.createSync(recursive: true);
if (_ensuredDirectories.add(mediaBaseDir.path)) {
if (!mediaBaseDir.existsSync()) {
mediaBaseDir.createSync(recursive: true);
}
}
return mediaBaseDir;
}
@ -395,6 +156,9 @@ class MediaFileService {
String namePrefix = '',
String extensionParam = '',
}) {
final cacheKey = '$directory|$namePrefix|$extensionParam';
final cached = _pathCache[cacheKey];
if (cached != null) return cached;
var extension = extensionParam;
if (extension == '') {
switch (mediaFile.type) {
@ -412,7 +176,7 @@ class MediaFileService {
directory,
AppEnvironment.supportDir,
);
return File(
return _pathCache[cacheKey] = File(
join(mediaBaseDir.path, '${mediaFile.mediaId}$namePrefix.$extension'),
);
}
@ -436,172 +200,9 @@ class MediaFileService {
'tmp',
namePrefix: '.original',
);
File get ffmpegOutputPath => _buildFilePath(
'tmp',
namePrefix: '.ffmpeg',
);
File get overlayImagePath => _buildFilePath(
'tmp',
namePrefix: '.overlay',
extensionParam: 'png',
);
Future<void> cropTransparentBorders() async {
if (mediaFile.type != MediaType.image) {
await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId,
const MediaFilesCompanion(hasCropAnalyzed: Value(true)),
);
return;
}
if (!storedPath.existsSync() || storedPath.lengthSync() == 0) {
await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId,
const MediaFilesCompanion(hasCropAnalyzed: Value(true)),
);
return;
}
try {
final bytes = await storedPath.readAsBytes();
final result = await compute(_processImageCrop, bytes);
if (result.isCropped && result.pngBytes != null) {
try {
final webpBytes = await FlutterImageCompress.compressWithList(
result.pngBytes!,
format: CompressFormat.webp,
quality: 90,
);
if (webpBytes.isNotEmpty) {
await storedPath.writeAsBytes(webpBytes);
} else {
Log.warn('WebP compression returned empty, falling back to PNG');
await storedPath.writeAsBytes(result.pngBytes!);
}
} catch (e) {
Log.error('Error compressing to WebP, falling back to PNG: $e');
await storedPath.writeAsBytes(result.pngBytes!);
}
if (thumbnailPath.existsSync()) {
await thumbnailPath.delete();
}
await createThumbnail();
final checksum = await sha256File(storedPath);
await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId,
MediaFilesCompanion(
hasCropAnalyzed: const Value(true),
storedFileHash: Value(Uint8List.fromList(checksum)),
),
);
await updateFromDB();
return;
}
await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId,
const MediaFilesCompanion(hasCropAnalyzed: Value(true)),
);
await updateFromDB();
} catch (e) {
Log.warn(
'Error auto-cropping transparent borders for mediaId ${mediaFile.mediaId}: $e',
);
await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId,
const MediaFilesCompanion(hasCropAnalyzed: Value(true)),
);
await updateFromDB();
}
}
}
class _CropResult {
const _CropResult(this.pngBytes, this.isCropped);
final Uint8List? pngBytes;
final bool isCropped;
}
_CropResult _processImageCrop(Uint8List bytes) {
final image = img.decodeImage(bytes);
if (image == null) return const _CropResult(null, false);
var minY = 0;
var maxY = image.height - 1;
var minX = 0;
var maxX = image.width - 1;
var found = false;
for (var y = 0; y < image.height; y++) {
for (var x = 0; x < image.width; x++) {
if (image.getPixel(x, y).a > 10) {
minY = y;
found = true;
break;
}
}
if (found) break;
}
found = false;
for (var y = image.height - 1; y >= minY; y--) {
for (var x = 0; x < image.width; x++) {
if (image.getPixel(x, y).a > 10) {
maxY = y;
found = true;
break;
}
}
if (found) break;
}
found = false;
for (var x = 0; x < image.width; x++) {
for (var y = minY; y <= maxY; y++) {
if (image.getPixel(x, y).a > 10) {
minX = x;
found = true;
break;
}
}
if (found) break;
}
found = false;
for (var x = image.width - 1; x >= minX; x--) {
for (var y = minY; y <= maxY; y++) {
if (image.getPixel(x, y).a > 10) {
maxX = x;
found = true;
break;
}
}
if (found) break;
}
final newWidth = maxX - minX + 1;
final newHeight = maxY - minY + 1;
if (minY > 0 ||
maxY < image.height - 1 ||
minX > 0 ||
maxX < image.width - 1) {
if (newWidth > 10 && newHeight > 10) {
final cropped = img.copyCrop(
image,
x: minX,
y: minY,
width: newWidth,
height: newHeight,
);
final pngBytes = img.encodePng(cropped);
return _CropResult(pngBytes, true);
}
}
return const _CropResult(null, false);
}

View file

@ -1,217 +0,0 @@
import 'dart:io';
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:image/image.dart' as img;
import 'package:pro_video_editor/pro_video_editor.dart';
import 'package:twonly/src/utils/log.dart';
Future<bool> createThumbnailsForVideo(
File sourceFile,
File destinationFile,
) async {
final stopwatch = Stopwatch()..start();
if (!sourceFile.existsSync() || sourceFile.lengthSync() == 0) {
Log.warn('Source video file does not exist or is empty.');
try {
if (destinationFile.existsSync()) {
destinationFile.deleteSync();
}
} catch (_) {}
return false;
}
if (destinationFile.existsSync()) {
if (destinationFile.lengthSync() > 0) {
return true;
} else {
try {
destinationFile.deleteSync();
} catch (_) {}
}
}
try {
final images = await ProVideoEditor.instance.getThumbnails(
ThumbnailConfigs(
video: EditorVideo.file(sourceFile),
outputFormat: ThumbnailFormat.webp,
timestamps: const [
Duration.zero,
],
outputSize: const Size(272, 153),
),
);
if (images.isNotEmpty && images.first.isNotEmpty) {
stopwatch.stop();
await destinationFile.writeAsBytes(images.first);
if (destinationFile.existsSync() && destinationFile.lengthSync() > 0) {
Log.info(
'It took ${stopwatch.elapsedMilliseconds}ms to create the video thumbnail.',
);
return true;
}
}
} catch (e) {
Log.error('Error creating video thumbnail: $e');
}
Log.warn(
'Thumbnail creation failed for the video.',
);
try {
if (destinationFile.existsSync()) {
destinationFile.deleteSync();
}
} catch (_) {}
return false;
}
Future<bool> createThumbnailsForImage(
File sourceFile,
File destinationFile,
) async {
final stopwatch = Stopwatch()..start();
if (!sourceFile.existsSync() || sourceFile.lengthSync() == 0) {
Log.warn('Source image file does not exist or is empty.');
try {
if (destinationFile.existsSync()) {
destinationFile.deleteSync();
}
} catch (_) {}
return false;
}
if (destinationFile.existsSync()) {
if (destinationFile.lengthSync() > 0) {
return true;
} else {
try {
destinationFile.deleteSync();
} catch (_) {}
}
}
try {
await FlutterImageCompress.compressAndGetFile(
sourceFile.absolute.path,
destinationFile.absolute.path,
minWidth: 300,
minHeight: 300,
quality: 50,
format: CompressFormat.webp,
);
stopwatch.stop();
if (destinationFile.existsSync() && destinationFile.lengthSync() > 0) {
Log.info(
'It took ${stopwatch.elapsedMilliseconds}ms to create the image thumbnail.',
);
return true;
} else {
Log.warn('Compressed image thumbnail is empty or missing.');
try {
if (destinationFile.existsSync()) {
destinationFile.deleteSync();
}
} catch (_) {}
return false;
}
} catch (e) {
Log.error('Error creating image thumbnail: $e');
try {
if (destinationFile.existsSync()) {
destinationFile.deleteSync();
}
} catch (_) {}
return false;
}
}
Future<bool> createThumbnailsForGif(
File sourceFile,
File destinationFile,
) async {
final stopwatch = Stopwatch()..start();
if (!sourceFile.existsSync() || sourceFile.lengthSync() == 0) {
Log.warn('Source GIF file does not exist or is empty.');
try {
if (destinationFile.existsSync()) {
destinationFile.deleteSync();
}
} catch (_) {}
return false;
}
if (destinationFile.existsSync()) {
if (destinationFile.lengthSync() > 0) {
return true;
} else {
try {
destinationFile.deleteSync();
} catch (_) {}
}
}
try {
// For GIFs, we decode the first frame and save it as WebP
final bytes = await sourceFile.readAsBytes();
final pngBytes = await compute(_processGifThumbnail, bytes);
if (pngBytes == null || pngBytes.isEmpty) {
Log.error('Could not decode GIF for thumbnail.');
return false;
}
final webp = await FlutterImageCompress.compressWithList(
pngBytes,
format: CompressFormat.webp,
quality: 85,
);
if (webp.isEmpty) {
Log.error('GIF thumbnail compression returned empty.');
return false;
}
await destinationFile.writeAsBytes(webp);
stopwatch.stop();
if (destinationFile.existsSync() && destinationFile.lengthSync() > 0) {
Log.info(
'It took ${stopwatch.elapsedMilliseconds}ms to create the GIF thumbnail.',
);
return true;
} else {
try {
if (destinationFile.existsSync()) {
destinationFile.deleteSync();
}
} catch (_) {}
return false;
}
} catch (e) {
Log.error('Error creating GIF thumbnail: $e');
try {
if (destinationFile.existsSync()) {
destinationFile.deleteSync();
}
} catch (_) {}
return false;
}
}
Uint8List? _processGifThumbnail(Uint8List bytes) {
final image = img.decodeGif(bytes);
if (image == null) return null;
final thumbnail = img.copyResize(
image,
width: image.width > image.height ? 400 : null,
height: image.height >= image.width ? 400 : null,
);
return img.encodePng(thumbnail);
}

View file

@ -279,7 +279,7 @@ class MemoriesService {
final mediaService = MediaFileService(mediaFile);
if (mediaService.mediaFile.storedFileHash == null) {
await mediaService.hashMediaFile();
await mediaService.refreshStoredMetadata();
}
if (!mediaService.mediaFile.hasCropAnalyzed) {
@ -287,7 +287,7 @@ class MemoriesService {
}
if (mediaService.mediaFile.sizeInBytes == null) {
await mediaService.calculateAndSaveSize();
await mediaService.refreshStoredMetadata();
}
if (mediaService.mediaFile.blurhash == null) {

View file

@ -6,7 +6,6 @@ import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/tables/contacts.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/api/mediafiles/download.api.dart';
import 'package:twonly/src/services/passwordless_recovery.service.dart';
import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/services/user_discovery.service.dart';
@ -14,14 +13,15 @@ import 'package:twonly/src/visual/views/onboarding/setup.view.dart';
Future<void> runMigrations() async {
if (userService.currentUser.appVersion < 90) {
// BUG: Requested media files for reupload where not reuploaded because the wrong state...
await twonlyDB.mediaFilesDao.updateAllRetransmissionUploadingState();
// BUG: Requested media files for reupload where not reuploaded because the
// wrong state. The Rust upload loop now treats `uploading` as resumable and
// recovers these on its own, so this migration only records the version.
await UserService.update((u) => u.appVersion = 90);
}
if (userService.currentUser.appVersion < 91) {
// BUG: Requested media files for reupload where not reuploaded because the wrong state...
await makeMigrationToVersion91();
await RustApi.retryPendingMediaReuploads();
await UserService.update((u) => u.appVersion = 91);
}

View file

@ -1,147 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/services.dart';
import 'package:flutter_svg/svg.dart';
import 'package:mutex/mutex.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/utils/log.dart';
const defaultAvatarSvg = '''
<!-- Taken from: https://getavataaars.com/ -->
<svg width="264px" height="280px" viewBox="0 0 264 280" version="1.1" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<path
d="M124,144.610951 L124,163 L128,163 L128,163 C167.764502,163 200,195.235498 200,235 L200,244 L0,244 L0,235 C-4.86974701e-15,195.235498 32.235498,163 72,163 L72,163 L76,163 L76,144.610951 C58.7626345,136.422372 46.3722246,119.687011 44.3051388,99.8812385 C38.4803105,99.0577866 34,94.0521096 34,88 L34,74 C34,68.0540074 38.3245733,63.1180731 44,62.1659169 L44,56 L44,56 C44,25.072054 69.072054,5.68137151e-15 100,0 L100,0 L100,0 C130.927946,-5.68137151e-15 156,25.072054 156,56 L156,62.1659169 C161.675427,63.1180731 166,68.0540074 166,74 L166,88 C166,94.0521096 161.51969,99.0577866 155.694861,99.8812385 C153.627775,119.687011 141.237365,136.422372 124,144.610951 Z"
id="react-path-3"></path>
</defs>
<g id="Avataaar" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g transform="translate(-825.000000, -1100.000000)">
<g transform="translate(825.000000, 1100.000000)">
<g id="Avataaar" stroke-width="1" fill-rule="evenodd">
<g id="Body" transform="translate(32.000000, 36.000000)">
<mask id="react-mask-6" fill="white">
<use xlink:href="#react-path-3"></use>
</mask>
<g id="Skin/👶🏽-03-Brown" mask="url(#react-mask-6)" fill="#57CC99">
<g transform="translate(0.000000, 0.000000)" id="Color">
<rect x="0" y="0" width="264" height="280"></rect>
</g>
</g>
</g>
</g>
</g>
</g>
</g>
</svg>''';
String getAvatarSvg(Uint8List avatarSvgCompressed) {
try {
// Check for GZIP magic bytes (0x1F 0x8B)
final isGzip =
avatarSvgCompressed.length >= 2 &&
avatarSvgCompressed[0] == 0x1F &&
avatarSvgCompressed[1] == 0x8B;
final decodedBytes = isGzip
? gzip.decode(avatarSvgCompressed)
: avatarSvgCompressed;
return utf8.decode(decodedBytes);
} catch (e) {
Log.error('Failed to decode avatar SVG: $e');
return defaultAvatarSvg;
}
}
Future<void> createPushAvatars({int? forceForUserId}) async {
final contacts = await twonlyDB.contactsDao.getAllContacts();
for (final contact in contacts) {
try {
if (contact.avatarSvgCompressed == null) continue;
if (forceForUserId == null) {
if (avatarPNGFile(contact.userId).existsSync()) {
continue; // only create the avatar in case no avatar exists yet fot this user
}
} else if (contact.userId != forceForUserId) {
// only update the avatar for this specified contact
continue;
}
final avatarSvg = getAvatarSvg(contact.avatarSvgCompressed!);
final pictureInfo = await vg.loadPicture(
SvgStringLoader(avatarSvg),
null,
);
final image = await pictureInfo.picture.toImage(270, 300);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
final pngBytes = byteData!.buffer.asUint8List();
await avatarPNGFile(contact.userId).writeAsBytes(pngBytes);
pictureInfo.picture.dispose();
} catch (e) {
Log.error(e);
}
}
}
File avatarPNGFile(int contactId) {
final avatarsDirectory = Directory(
'${AppEnvironment.cacheDir}/avatars',
);
if (!avatarsDirectory.existsSync()) {
avatarsDirectory.createSync(recursive: true);
}
return File('${avatarsDirectory.path}/$contactId.png');
}
File currentUserAvatarFile(int avatarCounter) {
final avatarsDirectory = Directory(
'${AppEnvironment.cacheDir}/avatars',
);
if (!avatarsDirectory.existsSync()) {
avatarsDirectory.createSync(recursive: true);
}
return File('${avatarsDirectory.path}/user_$avatarCounter.png');
}
final _avatarMutex = Mutex();
Future<String?> getUserAvatar() async {
if (userService.currentUser.avatarSvg == null) {
return null;
}
return _avatarMutex.protect(() async {
final avatarCounter = userService.currentUser.avatarCounter;
final file = currentUserAvatarFile(avatarCounter);
if (file.existsSync()) {
return file.path;
}
final pictureInfo = await vg.loadPicture(
SvgStringLoader(userService.currentUser.avatarSvg!),
null,
);
final image = await pictureInfo.picture.toImage(270, 300);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
final pngBytes = byteData!.buffer.asUint8List();
await file.writeAsBytes(pngBytes, flush: true);
pictureInfo.picture.dispose();
return file.path;
});
}

View file

@ -1,5 +1,4 @@
import 'dart:io';
import 'dart:isolate';
import 'dart:math';
import 'package:clock/clock.dart';
@ -7,11 +6,8 @@ import 'package:convert/convert.dart';
import 'package:crypto/crypto.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:gal/gal.dart';
import 'package:image/image.dart' as img;
import 'package:intl/intl.dart';
import 'package:local_auth/local_auth.dart';
import 'package:path/path.dart';
import 'package:provider/provider.dart';
import 'package:twonly/src/localization/generated/app_localizations.dart';
import 'package:twonly/src/model/error_code.dart';
@ -35,99 +31,6 @@ extension ShortCutsExtension on BuildContext {
}
}
Future<String?> saveImageToGallery(
Uint8List imageBytes, {
DateTime? createdAt,
String? name,
}) async {
var bytesToProcess = imageBytes;
if (createdAt != null) {
try {
bytesToProcess = await Isolate.run(() {
final image = img.decodeImage(imageBytes);
if (image != null) {
final formattedDate = DateFormat(
'yyyy:MM:dd HH:mm:ss',
).format(createdAt);
image.exif.imageIfd[0x0132] = img.IfdValueAscii(
formattedDate,
); // DateTime
image.exif.exifIfd[0x9003] = img.IfdValueAscii(
formattedDate,
); // DateTimeOriginal
image.exif.exifIfd[0x9004] = img.IfdValueAscii(
formattedDate,
); // DateTimeDigitized
return img.encodeJpg(image);
}
return imageBytes;
});
} catch (e) {
Log.error(e);
}
}
final hasAccess = await Gal.hasAccess(toAlbum: true);
if (!hasAccess) {
await Gal.requestAccess(toAlbum: true);
}
try {
await Gal.putImageBytes(
bytesToProcess,
album: 'twonly',
name: name ?? 'image',
);
return null;
} on GalException catch (e) {
Log.error(e);
return e.type.message;
}
}
Future<String?> saveVideoToGallery(
String videoPath, {
String? name,
}) async {
final hasAccess = await Gal.hasAccess(toAlbum: true);
if (!hasAccess) {
await Gal.requestAccess(toAlbum: true);
}
var pathToSave = videoPath;
File? tempFile;
try {
if (name != null) {
final file = File(videoPath);
final extension = file.path.split('.').last;
final tempDir = Directory.systemTemp;
tempFile = File(join(tempDir.path, '$name.$extension'));
if (tempFile.existsSync()) {
try {
tempFile.deleteSync();
} catch (_) {}
}
file.copySync(tempFile.path);
pathToSave = tempFile.path;
}
await Gal.putVideo(pathToSave, album: 'twonly');
return null;
} on GalException catch (e) {
Log.error(e);
return e.type.message;
} finally {
if (tempFile != null && tempFile.existsSync()) {
try {
tempFile.deleteSync();
} catch (e) {
Log.error('Failed to delete temp video file: $e');
}
}
}
}
Uint8List getRandomUint8List(int length) {
final random = Random.secure();
final randomBytes = Uint8List(length);

View file

@ -10,7 +10,6 @@ import 'package:twonly/core/bridge/wrapper/signal.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/tables/contacts.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart';
import 'package:twonly/src/model/protobuf/client/generated/qr.pb.dart';
import 'package:twonly/src/services/key_verification.service.dart';
import 'package:twonly/src/utils/log.dart';
@ -116,19 +115,11 @@ class QrCodeUtils {
Future<bool> addNewContactFromPublicProfile(PublicProfile profile) async {
try {
await RustApi.establishSignalSession(
contactId: profile.userId.toInt(),
expectedPublicKey: Uint8List.fromList(profile.publicIdentityKey),
);
await RustApi.sendEncryptedContent(
contactId: profile.userId.toInt(),
content: EncryptedContent(
contactRequest: EncryptedContent_ContactRequest(
type: EncryptedContent_ContactRequest_Type.REQUEST,
),
).writeToBuffer(),
);
// The contact row has to exist before the session is established: Rust
// marks the contact as `v2` while processing the prekey bundle, and the
// contact request is queued against this row. Adding the contact
// afterwards would leave it on the `v1` default, which makes every
// message to it refetch a prekey bundle first.
final added = await twonlyDB.contactsDao.insertOnConflictUpdate(
ContactsCompanion(
username: Value(profile.username),
@ -139,6 +130,13 @@ Future<bool> addNewContactFromPublicProfile(PublicProfile profile) async {
),
);
if (!await RustApi.tryRequestContactById(
contactId: profile.userId.toInt(),
expectedPublicKey: Uint8List.fromList(profile.publicIdentityKey),
)) {
return false;
}
if (added > 0) {
// The user was added via the profile scanned from the QR code so the scanned public key was used.
await twonlyDB.keyVerificationDao.addKeyVerification(

File diff suppressed because one or more lines are too long

View file

@ -1,11 +1,12 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart' show listEquals, setEquals;
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/utils/avatars.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:vector_graphics/vector_graphics.dart';
class AvatarIcon extends StatefulWidget {
@ -31,6 +32,51 @@ class AvatarIcon extends StatefulWidget {
State<AvatarIcon> createState() => _AvatarIconState();
}
/// Avatars are only ever drawn from PNG. Rasterising the stored SVG on the UI
/// thread is what made long chat lists stutter, so a missing PNG is rendered
/// once by Rust instead of being drawn as vector graphics every frame.
/// Keyed by contact plus profile counter, so a changed avatar naturally lands
/// on a fresh key.
final Map<String, String> _avatarPngPathCache = {};
/// Avatar paths already known to exist on disk. Only positive results are
/// cached: a PNG can still appear after a miss (it is written when the avatar
/// finishes downloading, or by the render below), so a miss stays re-checkable.
final Set<String> _existingAvatarPngPaths = {};
/// Contacts whose PNG render is already in flight, so a list that shows the
/// same contact in several rows asks Rust to render it only once.
final Map<int, Future<String?>> _pendingAvatarRenders = {};
/// Avatars Rust could not turn into a PNG, keyed by contact plus profile
/// counter so a broken SVG is not re-rendered on every stream tick while a
/// later avatar still gets its own attempt.
final Set<String> _unrenderableAvatars = {};
const _avatarCacheLimit = 200;
void _putBounded<T>(Map<String, T> cache, String key, T value) {
if (cache.length >= _avatarCacheLimit) {
cache.remove(cache.keys.first);
}
cache[key] = value;
}
String _avatarCacheKey(Contact contact) =>
'${contact.userId}:${contact.senderProfileCounter}';
String _avatarPngPathFor(Contact contact) {
final key = _avatarCacheKey(contact);
final cached = _avatarPngPathCache[key];
if (cached != null) return cached;
final path = RustApi.avatarPngPath(
contactId: contact.userId,
profileCounter: contact.senderProfileCounter,
);
_putBounded(_avatarPngPathCache, key, path);
return path;
}
class _AvatarIconState extends State<AvatarIcon> {
List<Contact> _avatarContacts = [];
Set<int> _contactsWithPngAvatar = {};
@ -50,7 +96,10 @@ class _AvatarIconState extends State<AvatarIcon> {
@override
void didUpdateWidget(AvatarIcon oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.contacts != null && widget.contacts != oldWidget.contacts) {
// Compare by value: callers commonly build a fresh list on every rebuild,
// and an identity check would restart the avatar probe every frame.
if (widget.contacts != null &&
!listEquals(widget.contacts, oldWidget.contacts)) {
_setAvatarContacts(widget.contacts!);
}
}
@ -66,17 +115,62 @@ class _AvatarIconState extends State<AvatarIcon> {
Future<void> _refreshAvatarFiles() async {
final available = <int>{};
for (final contact in _avatarContacts) {
final file = avatarPNGFile(contact.userId);
// Async file access keeps avatar discovery off the UI thread.
// ignore: avoid_slow_async_io
if (await file.exists()) {
final path = _avatarPngPathFor(contact);
var exists = _existingAvatarPngPaths.contains(path);
if (!exists) {
// Async file access keeps avatar discovery off the UI thread.
// ignore: avoid_slow_async_io
exists = await File(path).exists();
if (exists) _existingAvatarPngPaths.add(path);
}
if (exists) {
available.add(contact.userId);
} else if (!_unrenderableAvatars.contains(_avatarCacheKey(contact))) {
// No PNG yet: render one rather than falling back to the SVG. The
// default avatar is shown until it lands.
unawaited(_renderAvatarPng(contact));
}
}
if (!mounted) return;
if (setEquals(_contactsWithPngAvatar, available)) return;
setState(() => _contactsWithPngAvatar = available);
}
Future<void> _renderAvatarPng(Contact contact) async {
final contactId = contact.userId;
// Every widget showing this contact awaits the same render, so each one
// still learns the path while Rust rasterises it only once.
var render = _pendingAvatarRenders[contactId];
if (render == null) {
// Block body on purpose: returning `remove`'s value would hand
// `whenComplete` the future it is completing and hang it.
render = RustApi.ensureAvatarPng(contactId: contactId).whenComplete(() {
_pendingAvatarRenders.remove(contactId);
});
_pendingAvatarRenders[contactId] = render;
}
String? path;
try {
path = await render;
} catch (e) {
Log.error('Failed to render avatar for $contactId: $e');
_unrenderableAvatars.add(_avatarCacheKey(contact));
return;
}
if (path == null) {
_unrenderableAvatars.add(_avatarCacheKey(contact));
return;
}
_existingAvatarPngPaths.add(path);
if (!mounted) return;
if (_contactsWithPngAvatar.contains(contactId)) return;
if (!_avatarContacts.any((entry) => entry.userId == contactId)) return;
setState(() {
_contactsWithPngAvatar = {..._contactsWithPngAvatar, contactId};
});
}
@override
void dispose() {
groupStream?.cancel();
@ -93,20 +187,14 @@ class _AvatarIconState extends State<AvatarIcon> {
}
Widget getAvatarForContact(Contact contact) {
final avatarFile = avatarPNGFile(contact.userId);
if (_contactsWithPngAvatar.contains(contact.userId)) {
return Image.file(
avatarFile,
errorBuilder: errorBuilder,
);
}
if (contact.avatarSvgCompressed != null) {
return SvgPicture.string(
getAvatarSvg(contact.avatarSvgCompressed!),
File(_avatarPngPathFor(contact)),
errorBuilder: errorBuilder,
);
}
// Deliberately no SVG fallback: the render kicked off by
// `_refreshAvatarFiles` swaps this placeholder for the PNG when it is done.
return errorBuilder(null, null, null);
}
@ -163,7 +251,7 @@ class _AvatarIconState extends State<AvatarIcon> {
return;
}
final path = await getUserAvatar();
final path = await RustApi.currentUserAvatarPath();
if (mounted) {
setState(() {
@ -178,12 +266,7 @@ class _AvatarIconState extends State<AvatarIcon> {
Widget avatars = Container();
if (widget.svg != null) {
avatars = SvgPicture.string(
widget.svg!,
errorBuilder: errorBuilder,
);
} else if (widget.myAvatar) {
if (widget.myAvatar) {
if (_myAvatarPath != null) {
avatars = Image.file(
File(_myAvatarPath!),
@ -234,6 +317,14 @@ class _AvatarIconState extends State<AvatarIcon> {
],
);
}
} else if (widget.svg != null) {
// Last resort for callers with no contact behind the avatar (the
// passwordless recovery flow renders friends straight from a payload).
// Anything backed by a contact has already been served as PNG above.
avatars = SvgPicture.string(
widget.svg!,
errorBuilder: errorBuilder,
);
} else {
avatars = const SvgPicture(
AssetBytesLoader('assets/images/default_avatar.svg.vec'),

View file

@ -3,10 +3,10 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/key_verification.service.dart';
import 'package:twonly/src/utils/avatars.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/utils/qr.utils.dart';
@ -114,7 +114,7 @@ class _ProfileQrCodeCompState extends State<ProfileQrCodeComp> {
final qr = await QrCodeUtils.publicProfileLink();
Uint8List? avatarBytes;
if (widget.showAvatar) {
final avatarPath = await getUserAvatar();
final avatarPath = await RustApi.currentUserAvatarPath();
if (avatarPath != null) {
avatarBytes = await File(avatarPath).readAsBytes();
} else {

View file

@ -13,7 +13,10 @@ class ContextMenu extends StatefulWidget {
super.key,
});
final List<ContextMenuItem> items;
/// Built on long press rather than eagerly: a context menu exists for every
/// row in a list, and assembling the items can be costly (localized titles,
/// closures, and in the message menu a filesystem probe).
final List<ContextMenuItem> Function() items;
final Widget child;
final double? minWidth;
@ -29,15 +32,12 @@ class _ContextMenuState extends State<ContextMenu>
@override
void initState() {
super.initState();
_controller =
AnimationController(
vsync: this,
lowerBound: double.negativeInfinity,
upperBound: double.infinity,
value: 0,
)..addListener(() {
setState(() {});
});
_controller = AnimationController(
vsync: this,
lowerBound: double.negativeInfinity,
upperBound: double.infinity,
value: 0,
);
}
@override
@ -117,7 +117,7 @@ class _ContextMenuState extends State<ContextMenu>
curve: Curves.fastOutSlowIn,
),
items: <PopupMenuEntry<int>>[
...widget.items.map(
...widget.items().map(
(item) {
Widget child = ListTile(
title: Text(item.title),
@ -149,15 +149,20 @@ class _ContextMenuState extends State<ContextMenu>
@override
Widget build(BuildContext context) {
final scale = 1.0 - (_controller.value * 0.02);
return GestureDetector(
onLongPress: _showCustomMenu,
onTapDown: _onTapDown,
onTapUp: _onTapUp,
onTapCancel: _onTapCancel,
child: Transform.scale(
scale: scale,
// AnimatedBuilder keeps the press animation from rebuilding the wrapped
// row; only the Transform is re-evaluated per tick.
child: AnimatedBuilder(
animation: _controller,
child: widget.child,
builder: (context, child) => Transform.scale(
scale: 1.0 - (_controller.value * 0.02),
child: child,
),
),
);
}

View file

@ -20,9 +20,11 @@ class GroupContextMenu extends StatelessWidget {
@override
Widget build(BuildContext context) {
final navigator = Navigator.of(context);
// `late` so the ancestor lookup only runs if the menu is actually
// opened, rather than once per row on every rebuild.
late final navigator = Navigator.of(context);
return ContextMenu(
items: [
items: () => [
if (!group.archived)
ContextMenuItem(
title: context.lang.contextMenuArchiveUser,

View file

@ -17,10 +17,12 @@ class UserContextMenu extends StatelessWidget {
@override
Widget build(BuildContext context) {
final navigator = Navigator.of(context);
// `late` so the ancestor lookup only runs if the menu is actually
// opened, rather than once per row on every rebuild.
late final navigator = Navigator.of(context);
return ContextMenu(
minWidth: 150,
items: [
items: () => [
ContextMenuItem(
title: context.lang.contextMenuUserProfile,
onTap: () =>

View file

@ -4,21 +4,70 @@ import 'package:twonly/src/utils/log.dart';
import 'package:url_launcher/url_launcher.dart';
class BetterText extends StatelessWidget {
// Regular expression to find URLs and domains.
final _urlRegExp = RegExp(
r'''(?:(?:https?://|www\.)(?:[^\s()<>]+|\([^\s()<>]+\))+(?:\([^\s()<>]+\)|[^\s`!()\[\]{};:'".,<>?«»“”‘’]))|(?:(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,})''',
caseSensitive: false,
);
Future<void> _openUrl(String url) async {
final lUrl = Uri.parse(url.startsWith('http') ? url : 'http://$url');
try {
await launchUrl(lUrl, mode: LaunchMode.externalApplication);
} catch (e) {
Log.error('Could not launch $e');
}
}
class BetterText extends StatefulWidget {
const BetterText({required this.text, required this.textColor, super.key});
final String text;
final Color textColor;
@override
Widget build(BuildContext context) {
// Regular expression to find URLs and domains
final urlRegExp = RegExp(
r'''(?:(?:https?://|www\.)(?:[^\s()<>]+|\([^\s()<>]+\))+(?:\([^\s()<>]+\)|[^\s`!()\[\]{};:'".,<>?«»“”‘’]))|(?:(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,})''',
caseSensitive: false,
);
State<BetterText> createState() => _BetterTextState();
}
class _BetterTextState extends State<BetterText> {
/// Link detection only depends on the message text, so the spans and their
/// gesture recognizers are built once instead of on every rebuild. The
/// recognizers also need disposing, which the previous per-build version
/// never did.
late List<TextSpan> _spans;
final List<TapGestureRecognizer> _recognizers = [];
@override
void initState() {
super.initState();
_buildSpans();
}
@override
void didUpdateWidget(BetterText oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.text != widget.text) {
_disposeRecognizers();
_buildSpans();
}
}
@override
void dispose() {
_disposeRecognizers();
super.dispose();
}
void _disposeRecognizers() {
for (final recognizer in _recognizers) {
recognizer.dispose();
}
_recognizers.clear();
}
void _buildSpans() {
final text = widget.text;
final spans = <TextSpan>[];
final matches = urlRegExp.allMatches(text);
final matches = _urlRegExp.allMatches(text);
var lastMatchEnd = 0;
@ -27,7 +76,12 @@ class BetterText extends StatelessWidget {
spans.add(TextSpan(text: text.substring(lastMatchEnd, match.start)));
}
final url = match.group(0);
final url = match.group(0)!;
final recognizer = TapGestureRecognizer()
..onTap = () async {
await _openUrl(url);
};
_recognizers.add(recognizer);
spans.add(
TextSpan(
text: url,
@ -35,17 +89,7 @@ class BetterText extends StatelessWidget {
decoration: TextDecoration.underline,
decorationColor: Colors.white,
),
recognizer: TapGestureRecognizer()
..onTap = () async {
final lUrl = Uri.parse(
url!.startsWith('http') ? url : 'http://$url',
);
try {
await launchUrl(lUrl, mode: LaunchMode.externalApplication);
} catch (e) {
Log.error('Could not launch $e');
}
},
recognizer: recognizer,
),
);
@ -60,15 +104,20 @@ class BetterText extends StatelessWidget {
);
}
_spans = spans;
}
@override
Widget build(BuildContext context) {
return Text.rich(
TextSpan(
children: spans,
children: _spans,
),
softWrap: true,
textAlign: TextAlign.start,
overflow: TextOverflow.visible,
style: TextStyle(
color: textColor,
color: widget.textColor,
fontSize: 17,
decoration: TextDecoration.none,
fontWeight: FontWeight.normal,

View file

@ -1,10 +1,8 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/utils/avatars.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
import 'package:twonly/src/visual/components/verification_badge.comp.dart';
@ -49,7 +47,7 @@ class ContactChip extends StatelessWidget {
contactId: contact?.userId,
fontSize: 10,
svg: avatarSvg != null
? getAvatarSvg(Uint8List.fromList(avatarSvg!))
? RustApi.decodeAvatarSvg(avatarSvgCompressed: avatarSvg!)
: null,
),
label: Row(

View file

@ -15,7 +15,7 @@ import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/api/mediafiles/upload.api.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
@ -393,11 +393,12 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
final type =
mediaType ??
((videoFilePath != null) ? MediaType.video : MediaType.image);
final mediaFileService = await initializeMediaUpload(
type,
userService.currentUser.defaultShowTime,
final mediaId = await RustApi.initializeMediaUpload(
mediaType: type.name,
displayLimitInMilliseconds: userService.currentUser.defaultShowTime,
isDraftMedia: true,
);
final mediaFileService = await MediaFileService.fromMediaId(mediaId);
if (!mounted) return true;
if (mediaFileService == null) {

View file

@ -567,8 +567,8 @@ class MainCameraController {
} else {
await showAlertDialog(
context,
context.lang.groupNetworkIssue,
context.lang.recoverErrorNoInternet,
context.lang.addFriendTitle,
context.lang.additionalUserAddError(profile.username),
customCancel: '',
);
}

View file

@ -8,7 +8,6 @@ import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart';
import 'package:twonly/src/services/api/mediafiles/upload.api.dart';
import 'package:twonly/src/services/flame.service.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/utils/misc.dart';
@ -311,10 +310,12 @@ class _ShareImageView extends State<ShareImageView> {
// in case mediaStoreFutureReady is ready, the image is stored in the originalPath
unawaited(
insertMediaFileInMessagesTable(
widget.mediaFileService,
widget.selectedGroupIds.toList(),
additionalData: widget.additionalData,
RustApi.sendMediaToGroups(
mediaId:
widget.mediaFileService.mediaFile.mediaId,
groupIds: widget.selectedGroupIds.toList(),
additionalMessageData: widget.additionalData
?.writeToBuffer(),
),
);

View file

@ -12,7 +12,6 @@ import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart';
import 'package:twonly/src/services/api/mediafiles/upload.api.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/log.dart';
@ -655,10 +654,10 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
await storeImageAsOriginal();
// Insert media file into the messages database and start uploading process in the background
await insertMediaFileInMessagesTable(
mediaService,
[widget.sendToGroup!.groupId],
additionalData: getAdditionalData(),
await RustApi.sendMediaToGroups(
mediaId: mediaService.mediaFile.mediaId,
groupIds: [widget.sendToGroup!.groupId],
additionalMessageData: getAdditionalData()?.writeToBuffer(),
);
if (mounted) {

View file

@ -10,7 +10,7 @@ import 'package:twonly/src/database/daos/key_verification.dao.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/tables/messages.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/api/mediafiles/download.api.dart';
import 'package:twonly/src/services/mediafiles/media_download_policy.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
import 'package:twonly/src/visual/components/contact_labels.comp.dart';

View file

@ -112,6 +112,9 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
Timer? _nextTypingIndicator;
/// Set by the composer while it is announcing that the user is typing.
final ValueNotifier<bool> _composing = ValueNotifier(false);
@override
void initState() {
super.initState();
@ -141,6 +144,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
_messageDataVersion.dispose();
itemPositionsListener.itemPositions.removeListener(_loadOlderWhenNeeded);
_nextTypingIndicator?.cancel();
_composing.dispose();
try {
textFieldFocus?.dispose();
// ignore: empty_catches
@ -193,10 +197,11 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
if (userService.currentUser.typingIndicators) {
unawaited(RustApi.sendTyping(groupId: widget.groupId, isTyping: false));
_nextTypingIndicator = Timer.periodic(const Duration(seconds: 2), (
_,
) async {
if (_isViewActive()) {
_nextTypingIndicator = Timer.periodic(chatOpenPingInterval, (_) async {
// A typing announcement refreshes the contact's chat-open state as
// well, so pinging while the composer is active would spend a second
// message only to clear the typing flag that composer just set.
if (_isViewActive() && !_composing.value) {
await RustApi.sendTyping(groupId: widget.groupId, isTyping: false);
}
});
@ -746,6 +751,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
group: group,
quotesMessage: quotesMessage,
textFieldFocus: textFieldFocus!,
composing: _composing,
onMessageSend: () {
setState(() {
quotesMessage = null;

View file

@ -87,9 +87,18 @@ class _ChatListEntryState extends State<ChatListEntry> {
void _applySharedData() {
if (!widget.useSharedData) return;
reactions = widget.reactions ?? const [];
mediaService = widget.mediaFile == null
? null
: MediaFileService(widget.mediaFile!);
final mediaFile = widget.mediaFile;
if (mediaFile == null) {
mediaService = null;
return;
}
// Reuse the existing service when the row is unchanged: it memoises the
// media paths, and a fresh instance would also force the thumbnail below to
// re-resolve its image.
final current = mediaService;
if (current == null || current.mediaFile != mediaFile) {
mediaService = MediaFileService(mediaFile);
}
}
@override

View file

@ -8,7 +8,6 @@ import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart';
import 'package:twonly/src/services/api/utils.api.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
@ -139,7 +138,10 @@ class _ChatAskAFriendEntryState extends State<ChatAskAFriendEntry> {
deletedByUser: const Value(false),
),
);
await importSignalContactAndCreateRequest(userdata);
await RustApi.tryRequestContactById(
contactId: userdata.userId,
expectedPublicKey: userdata.publicIdentityKey,
);
}
} catch (e) {
Log.error(e);
@ -162,7 +164,7 @@ class _ChatAskAFriendEntryState extends State<ChatAskAFriendEntry> {
return Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.8,
maxWidth: MediaQuery.sizeOf(context).width * 0.8,
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(

View file

@ -79,7 +79,7 @@ class _ChatAudioEntryState extends State<ChatAudioEntry> {
return IntrinsicWidth(
child: Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.8,
maxWidth: MediaQuery.sizeOf(context).width * 0.8,
minWidth: 280,
),
padding: info.padding,

View file

@ -8,11 +8,11 @@ import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart';
import 'package:twonly/src/services/api/utils.api.dart';
import 'package:twonly/src/services/key_verification.service.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/visual/components/add_contact_dialog.comp.dart';
import 'package:twonly/src/visual/elements/better_text.element.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/chat_unknown.entry.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/common.dart';
class ChatContactsEntry extends StatefulWidget {
@ -34,27 +34,52 @@ class ChatContactsEntry extends StatefulWidget {
}
class _ChatContactsEntryState extends State<ChatContactsEntry> {
/// Decoded once per message rather than on every rebuild.
AdditionalMessageData? _data;
@override
void initState() {
super.initState();
_decode();
}
@override
void didUpdateWidget(ChatContactsEntry oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.message.additionalMessageData !=
widget.message.additionalMessageData) {
_decode();
}
}
void _decode() {
if (widget.message.additionalMessageData == null) {
_data = null;
return;
}
try {
_data = AdditionalMessageData.fromBuffer(
widget.message.additionalMessageData!,
);
} catch (e) {
_data = null;
}
}
@override
Widget build(BuildContext context) {
AdditionalMessageData? data;
if (widget.message.additionalMessageData != null) {
try {
data = AdditionalMessageData.fromBuffer(
widget.message.additionalMessageData!,
);
} catch (e) {
data = null;
}
}
final data = _data;
// Never collapse to nothing: a message row exists either way, so an
// unreadable payload has to stay visible instead of leaving a phantom
// bubble in the chat.
if (data == null || data.contacts.isEmpty) {
return const SizedBox.shrink();
return const ChatUnknownEntry();
}
return Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.8,
maxWidth: MediaQuery.sizeOf(context).width * 0.8,
),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
@ -145,7 +170,12 @@ class _ContactRowState extends State<_ContactRow> {
),
);
if (added > 0) await importSignalContactAndCreateRequest(userdata);
if (added > 0) {
await RustApi.tryRequestContactById(
contactId: userdata.userId,
expectedPublicKey: userdata.publicIdentityKey,
);
}
await KeyVerificationService.verifySharedContact(
contactId: userdata.userId,

View file

@ -39,7 +39,7 @@ class ChatFlameRestoredEntry extends StatelessWidget {
return Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.8,
maxWidth: MediaQuery.sizeOf(context).width * 0.8,
),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(

View file

@ -9,7 +9,7 @@ import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/memory_item.model.dart';
import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart';
import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart';
import 'package:twonly/src/services/api/mediafiles/download.api.dart'
import 'package:twonly/src/services/mediafiles/media_download_policy.dart'
as received;
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/utils/misc.dart';
@ -47,12 +47,36 @@ class _ChatMediaEntryState extends State<ChatMediaEntry> {
GlobalKey reopenMediaFile = GlobalKey();
bool _canBeReopened = false;
/// Decoded once per message instead of on every rebuild; the buffer only
/// changes when the message itself does.
String? _link;
@override
void initState() {
super.initState();
_decodeAdditionalData();
unawaited(initAsync());
}
@override
void didUpdateWidget(ChatMediaEntry oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.message.additionalMessageData !=
widget.message.additionalMessageData) {
_decodeAdditionalData();
}
}
void _decodeAdditionalData() {
final addData = widget.message.additionalMessageData;
if (addData == null) {
_link = null;
return;
}
final data = AdditionalMessageData.fromBuffer(addData);
_link = data.hasLink() ? data.link : null;
}
Future<void> initAsync() async {
if (widget.message.senderId == null || widget.message.mediaStored) {
return;
@ -120,34 +144,31 @@ class _ChatMediaEntryState extends State<ChatMediaEntry> {
Widget additionalMessageData = Container();
final addData = widget.message.additionalMessageData;
if (addData != null) {
final data = AdditionalMessageData.fromBuffer(addData);
if (data.hasLink() && widget.message.mediaStored) {
imageBorderRadius = widget.borderRadius.copyWith(
bottomLeft: const Radius.circular(5),
bottomRight: const Radius.circular(5),
);
final link = _link;
if (link != null && widget.message.mediaStored) {
imageBorderRadius = widget.borderRadius.copyWith(
bottomLeft: const Radius.circular(5),
bottomRight: const Radius.circular(5),
);
additionalMessageData = Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.8,
additionalMessageData = Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.sizeOf(context).width * 0.8,
),
padding: widget.info.padding,
decoration: BoxDecoration(
color: widget.info.color,
borderRadius: widget.borderRadius.copyWith(
topLeft: const Radius.circular(5),
),
padding: widget.info.padding,
decoration: BoxDecoration(
color: widget.info.color,
borderRadius: widget.borderRadius.copyWith(
topLeft: const Radius.circular(5),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
BetterText(text: data.link, textColor: widget.info.textColor),
],
),
);
}
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
BetterText(text: link, textColor: widget.info.textColor),
],
),
);
}
return Column(

View file

@ -39,7 +39,7 @@ class ChatTextEntry extends StatelessWidget {
return IntrinsicWidth(
child: Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.8,
maxWidth: MediaQuery.sizeOf(context).width * 0.8,
minWidth: info.minWidth,
),
padding: info.padding,

View file

@ -11,7 +11,7 @@ class ChatUnknownEntry extends StatelessWidget {
Widget build(BuildContext context) {
return Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.8,
maxWidth: MediaQuery.sizeOf(context).width * 0.8,
),
padding: const EdgeInsets.only(left: 10, top: 6, bottom: 6, right: 10),
decoration: BoxDecoration(

View file

@ -53,7 +53,8 @@ BubbleInfo getBubbleInfo(
}
}
info.spacerWidth = minWidth - measureTextWidth(info.text) - 53;
final textWidth = measureTextWidth(info.text);
info.spacerWidth = minWidth - textWidth - 53;
if (info.spacerWidth < 0) info.spacerWidth = 0;
info
@ -65,7 +66,7 @@ BubbleInfo getBubbleInfo(
info
..color = context.color.surfaceBright
..displayTime = false;
} else if (measureTextWidth(info.text) > 270) {
} else if (textWidth > 270) {
info.expanded = true;
}
@ -80,15 +81,32 @@ BubbleInfo getBubbleInfo(
return info;
}
/// Laying text out is expensive and `getBubbleInfo` runs for every visible
/// bubble on every rebuild, while the same message content is measured over and
/// over. Keep the last few hundred results around, in insertion order, so the
/// cache stays bounded as the user scrolls through a long conversation.
const _measuredTextCacheLimit = 500;
final Map<String, double> _measuredTextCache = <String, double>{};
double measureTextWidth(
String text,
) {
final cached = _measuredTextCache[text];
if (cached != null) return cached;
final tp = TextPainter(
text: TextSpan(text: text, style: const TextStyle(fontSize: 17)),
textDirection: TextDirection.ltr,
maxLines: 1,
)..layout();
return tp.size.width;
final width = tp.size.width;
tp.dispose();
if (_measuredTextCache.length >= _measuredTextCacheLimit) {
_measuredTextCache.remove(_measuredTextCache.keys.first);
}
_measuredTextCache[text] = width;
return width;
}
bool combineTextMessageWithNext(Message message, Message? nextMessage) {

View file

@ -143,15 +143,16 @@ String friendlyTime(BuildContext context, DateTime dt) {
}
// Determine 24h vs 12h from system/local settings
final use24Hour = MediaQuery.of(context).alwaysUse24HourFormat;
final use24Hour = MediaQuery.alwaysUse24HourFormatOf(context);
if (!use24Hour) {
// 12-hour format with locale-aware AM/PM
final format = DateFormat.jm(Localizations.localeOf(context).toString());
return format.format(dt);
} else {
// 24-hour HH:mm, locale-aware
final format = DateFormat.Hm(Localizations.localeOf(context).toString());
return format.format(dt);
}
final locale = Localizations.localeOf(context).toString();
// Building a DateFormat parses a pattern and looks up locale data, which is
// wasted work when every visible message asks for the same two formats.
final format = _timeFormats.putIfAbsent(
'$locale|$use24Hour',
() => use24Hour ? DateFormat.Hm(locale) : DateFormat.jm(locale),
);
return format.format(dt);
}
final Map<String, DateFormat> _timeFormats = {};

View file

@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:twonly/core/bridge/api.dart';
import 'package:twonly/core/services/media_upload.dart';
import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/providers/purchases.provider.dart';
import 'package:twonly/src/services/subscription.service.dart';
import 'package:twonly/src/utils/misc.dart';
/// Explains a send that stopped because the media exceeded the largest single
/// object the user's plan accepts.
///
/// Only the free plan is offered an upgrade: every paid plan shares the same
/// per-file limit, so changing plan would not make this file sendable.
Future<void> showFileLimitReachedDialog(
BuildContext context,
String mediaId,
) async {
MediaSizeReport? report;
try {
report = await RustApi.mediaSizeLimitReport(mediaId: mediaId);
} catch (_) {
// The numbers are what make the warning specific, but the warning is
// still worth showing without them.
}
if (!context.mounted) return;
final isFreePlan =
context.read<PurchasesProvider>().plan == SubscriptionPlan.Free;
final limit = report?.limitBytes;
final size = report?.mediaBytes;
String? detail;
if (limit != null) {
detail = size != null
? context.lang.fileLimitReachedDetail(
formatBytes(size),
formatBytes(limit),
)
: context.lang.fileLimitReachedDetailNoSize(formatBytes(limit));
}
await showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(dialogContext.lang.fileLimitReachedTitle),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (detail != null) ...[
Text(detail),
const SizedBox(height: 12),
],
Text(
isFreePlan
? dialogContext.lang.fileLimitReachedHintFree
: dialogContext.lang.fileLimitReachedHint,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text(dialogContext.lang.close),
),
if (isFreePlan)
FilledButton(
onPressed: () {
Navigator.of(dialogContext).pop();
dialogContext.push(Routes.settingsSubscription);
},
child: Text(dialogContext.lang.fileLimitReachedUpgrade),
),
],
),
);
}

View file

@ -103,9 +103,11 @@ class MessageContextMenu extends StatelessWidget {
@override
Widget build(BuildContext context) {
final navigator = Navigator.of(context);
// `late` so the ancestor lookup only runs if the menu is actually
// opened, rather than once per row on every rebuild.
late final navigator = Navigator.of(context);
return ContextMenu(
items: [
items: () => [
if (!message.isDeletedFromSender)
ContextMenuItem(
title: context.lang.react,

View file

@ -13,7 +13,7 @@ import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/api/mediafiles/upload.api.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/views/camera/camera_send_to.view.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/bottom_sheets/share_additional.bottom_sheet.dart';
@ -21,6 +21,7 @@ import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/c
import 'package:twonly/src/visual/views/chats/chat_messages_components/message_input_components/ask_for_friend_promotions.comp.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/message_input_components/sparks.comp.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/message_input_components/user_discovery_manual_approval.comp.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/typing_indicator.dart';
import 'package:twonly/src/visual/views/contact/contact_components/restore_flame.comp.dart';
class MessageInput extends StatefulWidget {
@ -29,6 +30,7 @@ class MessageInput extends StatefulWidget {
required this.quotesMessage,
required this.textFieldFocus,
required this.onMessageSend,
required this.composing,
super.key,
});
@ -37,10 +39,19 @@ class MessageInput extends StatefulWidget {
final Message? quotesMessage;
final VoidCallback onMessageSend;
/// Published so the chat-open heartbeat can stay quiet while the typing
/// announcement is already covering this conversation.
final ValueNotifier<bool> composing;
@override
State<MessageInput> createState() => _MessageInputState();
}
/// How long after the last keystroke the composer still counts as being typed
/// in. Longer than [typingIndicatorInterval] so the indicator survives the gap
/// between two announcements.
const _composingIdleTimeout = Duration(seconds: 6);
enum RecordingState { none, recording, finished }
class _MessageInputState extends State<MessageInput> {
@ -85,14 +96,14 @@ class _MessageInputState extends State<MessageInput> {
}
widget.textFieldFocus.addListener(_handleTextFocusChange);
if (userService.currentUser.typingIndicators) {
_nextTypingIndicator = Timer.periodic(const Duration(seconds: 1), (
_,
) async {
if (widget.textFieldFocus.hasFocus &&
_lastTextChangeTime != null &&
DateTime.now().difference(_lastTextChangeTime!) <=
const Duration(seconds: 6)) {
await RustApi.sendTyping(groupId: widget.group.groupId, isTyping: true);
_nextTypingIndicator = Timer.periodic(typingIndicatorInterval, (_) async {
final composing = _isComposing;
widget.composing.value = composing;
if (composing) {
await RustApi.sendTyping(
groupId: widget.group.groupId,
isTyping: true,
);
}
});
}
@ -108,6 +119,7 @@ class _MessageInputState extends State<MessageInput> {
_recordingTimer?.cancel();
recorderController.dispose();
_nextTypingIndicator?.cancel();
widget.composing.value = false;
// Persist draft message on close
final draftText = _textFieldController.text;
@ -128,8 +140,31 @@ class _MessageInputState extends State<MessageInput> {
recorderController = RecorderController();
}
/// Whether the composer counts as being typed in right now.
///
/// The idle window outlives one announcement interval, so a pause to think
/// mid-sentence does not drop the indicator on the other side.
bool get _isComposing =>
widget.textFieldFocus.hasFocus &&
_lastTextChangeTime != null &&
clock.now().difference(_lastTextChangeTime!) <= _composingIdleTimeout;
void _handleTextChange() {
_lastTextChangeTime = clock.now();
final now = clock.now();
// The periodic announcement is what keeps the indicator alive, but at its
// cadence the first keystroke would take seconds to reach the other side.
// That one is announced directly and the timer carries it from there.
final wasIdle = _lastTextChangeTime == null ||
now.difference(_lastTextChangeTime!) > typingIndicatorInterval;
_lastTextChangeTime = now;
if (wasIdle &&
userService.currentUser.typingIndicators &&
widget.textFieldFocus.hasFocus) {
widget.composing.value = true;
unawaited(
RustApi.sendTyping(groupId: widget.group.groupId, isTyping: true),
);
}
}
void _handleTextFocusChange() {
@ -195,10 +230,11 @@ class _MessageInputState extends State<MessageInput> {
if (audioTmpPath == null) return;
final mediaFileService = await initializeMediaUpload(
MediaType.audio,
null,
final mediaId = await RustApi.initializeMediaUpload(
mediaType: MediaType.audio.name,
isDraftMedia: false,
);
final mediaFileService = await MediaFileService.fromMediaId(mediaId);
if (mediaFileService == null) return;
@ -206,9 +242,9 @@ class _MessageInputState extends State<MessageInput> {
..copySync(mediaFileService.originalPath.path)
..deleteSync();
await insertMediaFileInMessagesTable(
mediaFileService,
[widget.group.groupId],
await RustApi.sendMediaToGroups(
mediaId: mediaFileService.mediaFile.mediaId,
groupIds: [widget.group.groupId],
);
}

View file

@ -12,6 +12,7 @@ import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/animate_icon.comp.dart';
import 'package:twonly/src/visual/themes/colors.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/file_limit_reached.dialog.dart';
enum MessageSendState {
received,
@ -225,6 +226,10 @@ class _MessageSendStateIconState extends State<MessageSendStateIcon> {
context.lang.fileLimitReached,
style: const TextStyle(fontSize: 9),
);
// The warning has no room for the sizes that explain it, so they are
// one tap away instead.
final mediaId = mediaFile.mediaId;
onTap = () => showFileLimitReachedDialog(context, mediaId);
}
}

View file

@ -49,7 +49,7 @@ class ResponseContainer extends StatelessWidget {
: () => scrollToMessage!(msg.quotesMessageId!),
child: Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.8,
maxWidth: MediaQuery.sizeOf(context).width * 0.8,
),
decoration: BoxDecoration(
color: getMessageColor(msg.senderId != null),
@ -258,7 +258,7 @@ class _ResponsePreviewState extends State<ResponsePreview> {
),
constraints: BoxConstraints(
minWidth: 60,
maxWidth: MediaQuery.of(context).size.width * 0.7,
maxWidth: MediaQuery.sizeOf(context).width * 0.7,
),
decoration: widget.showLeftBorder
? BoxDecoration(

View file

@ -9,26 +9,36 @@ import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
import 'package:twonly/src/visual/views/chats/chat_messages.view.dart';
/// How often a composing user re-announces that it is typing.
///
/// Every announcement is a message, and a message sent sealed costs a Privacy
/// Pass token out of a daily quota. At the one-second cadence this used to run
/// at, a few minutes of typing spent a whole day's worth, after which every
/// later message real ones included fell back to the named transport.
const typingIndicatorInterval = Duration(seconds: 4);
/// How long a received typing announcement counts for. Comfortably longer than
/// the send cadence so a single delayed message does not blink the indicator
/// off between announcements.
const typingIndicatorLifetime = Duration(seconds: 9);
/// How often an open chat announces itself. This heartbeat runs for as long as
/// the conversation is on screen rather than only while someone types, so it is
/// the heavier of the two.
const chatOpenPingInterval = Duration(seconds: 6);
/// How long a received chat-open announcement counts for.
const chatOpenLifetime = Duration(seconds: 14);
bool isTyping(GroupMember member) {
return member.lastTypeIndicator != null &&
clock
.now()
.difference(
member.lastTypeIndicator!,
)
.inSeconds <=
2;
clock.now().difference(member.lastTypeIndicator!) <=
typingIndicatorLifetime;
}
bool hasChatOpen(GroupMember member) {
return member.lastChatOpened != null &&
clock
.now()
.difference(
member.lastChatOpened!,
)
.inSeconds <=
3;
clock.now().difference(member.lastChatOpened!) <= chatOpenLifetime;
}
class TypingIndicator extends StatefulWidget {

View file

@ -15,8 +15,7 @@ import 'package:twonly/src/database/tables/mediafiles.table.dart'
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart'
as pb;
import 'package:twonly/src/services/api/mediafiles/download.api.dart';
import 'package:twonly/src/services/api/utils.api.dart';
import 'package:twonly/src/services/mediafiles/media_download_policy.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/services/notifications/native.notifications.dart';
import 'package:twonly/src/utils/log.dart';
@ -194,7 +193,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
if (currentMedia != null) {
if (!imageSaved &&
currentMedia!.mediaFile.displayLimitInMilliseconds != null) {
currentMedia!.fullMediaRemoval();
await currentMedia!.fullMediaRemoval();
}
}
@ -348,7 +347,9 @@ class _MediaViewerViewState extends State<MediaViewerView> {
Log.warn(
'Temp media file not found for media ID: ${currentMediaLocal.mediaFile.mediaId}',
);
await handleMediaError(currentMediaLocal.mediaFile);
await RustApi.requestMediaReupload(
mediaId: currentMediaLocal.mediaFile.mediaId,
);
return advanceToNextMediaOrExit();
}

View file

@ -163,20 +163,25 @@ class _MessageInfoViewState extends State<MessageInfoView> {
style: const TextStyle(fontSize: 12),
),
Text(actionTypeText),
// The transport is decided per recipient, so it is only known
// once this member's copy has actually left the device.
if (ackByServer != null)
Text(
sealedSender != null
? context.lang.sealedSenderTransportSealed
: context.lang.sealedSenderTransportStandard,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).hintColor,
),
),
],
),
// The transport is decided per recipient, so it is only known
// once this member's copy has actually left the device.
if (ackByServer != null) ...[
const SizedBox(width: 10),
Tooltip(
message: sealedSender != null
? context.lang.sealedSenderTransportSealed
: context.lang.sealedSenderTransportStandard,
child: FaIcon(
sealedSender != null
? FontAwesomeIcons.solidEnvelope
: FontAwesomeIcons.envelope,
size: 13,
color: Theme.of(context).hintColor,
),
),
],
],
),
),

View file

@ -7,7 +7,6 @@ import 'package:go_router/go_router.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/client/generated/qr.pb.dart';
import 'package:twonly/src/services/api/utils.api.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/utils/qr.utils.dart';
@ -54,7 +53,10 @@ class _AddContactViaQrLinkViewState extends State<AddContactViaQrLinkView> {
);
if (added > 0) {
await importSignalContactAndCreateRequest(userData);
await RustApi.tryRequestContactById(
contactId: userData.userId,
expectedPublicKey: userData.publicIdentityKey,
);
if (widget.qrCodeLink != null) {
// As the user does now exist he can now be marked as verified
await QrCodeUtils.handleQrCodeLink(widget.qrCodeLink!);

View file

@ -14,7 +14,6 @@ import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/database/daos/user_discovery.dao.dart';
import 'package:twonly/src/database/tables/contacts.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/api/utils.api.dart';
import 'package:twonly/src/services/notifications/native.notifications.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/alert.dialog.dart';
@ -179,7 +178,12 @@ class _SearchUsernameView extends State<AddNewUserView> {
}
}
if (added > 0) await importSignalContactAndCreateRequest(userdata);
if (added > 0) {
await RustApi.tryRequestContactById(
contactId: userdata.userId,
expectedPublicKey: userdata.publicIdentityKey,
);
}
}
@override

View file

@ -5,7 +5,6 @@ import 'package:twonly/locator.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/database/daos/user_discovery.dao.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/api/utils.api.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
@ -72,7 +71,12 @@ class FriendSuggestionsComp extends StatelessWidget {
),
);
if (added > 0) await importSignalContactAndCreateRequest(userdata);
if (added > 0) {
await RustApi.tryRequestContactById(
contactId: userdata.userId,
expectedPublicKey: userdata.publicIdentityKey,
);
}
}
Future<void> _hideAnnouncedUser(int userId) async {

View file

@ -117,9 +117,11 @@ class GroupMemberContextMenu extends StatelessWidget {
@override
Widget build(BuildContext context) {
final navigator = Navigator.of(context);
// `late` so the ancestor lookup only runs if the menu is actually
// opened, rather than once per row on every rebuild.
late final navigator = Navigator.of(context);
return ContextMenu(
items: [
items: () => [
if (contact.accepted)
ContextMenuItem(
title: context.lang.contextMenuOpenChat,

View file

@ -12,7 +12,6 @@ import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/providers/routing.provider.dart';
import 'package:twonly/src/services/api/mediafiles/upload.api.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/services/notifications/native.notifications.dart';
import 'package:twonly/src/services/notifications/setup.notifications.dart';
@ -134,10 +133,12 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
final type = media.$2;
final filePath = media.$1;
final newMediaService = await initializeMediaUpload(
type,
userService.currentUser.defaultShowTime,
final mediaId = await RustApi.initializeMediaUpload(
mediaType: type.name,
displayLimitInMilliseconds: userService.currentUser.defaultShowTime,
isDraftMedia: false,
);
final newMediaService = await MediaFileService.fromMediaId(mediaId);
if (newMediaService == null) {
Log.error('Could not create new media file for intent shared file');
return;
@ -207,7 +208,8 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
}
Future<void> _initAsync() async {
final initialNativeTap = await NativeNotificationService.consumeInitialTap();
final initialNativeTap =
await NativeNotificationService.consumeInitialTap();
if (initialNativeTap != null) {
_openNativeNotification(initialNativeTap.conversationId);
}

View file

@ -41,6 +41,7 @@ class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp> {
ImageStream? _imageStream;
ImageInfo? _imageInfo;
int _retries = 0;
bool _hasStoredFile = false;
late final ImageStreamListener _listener;
@override
@ -76,6 +77,7 @@ class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp> {
media.thumbnailPath.lengthSync() > 0;
final hasStored =
media.storedPath.existsSync() && media.storedPath.lengthSync() > 0;
_hasStoredFile = hasStored;
final isImageOrGif =
media.mediaFile.type == MediaType.image ||
media.mediaFile.type == MediaType.gif;
@ -263,9 +265,7 @@ class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp> {
),
Builder(
builder: (context) {
final hasStored =
media.storedPath.existsSync() &&
media.storedPath.lengthSync() > 0;
final hasStored = _hasStoredFile;
final IconData iconData;
final Color color;

View file

@ -322,7 +322,7 @@ class MemoriesViewState extends State<MemoriesView>
.firstOrNull;
if (item != null) {
if (isCompletely) {
item.mediaService.fullMediaRemoval();
await item.mediaService.fullMediaRemoval();
await RustApi.deleteMemory(mediaId: mediaId);
await twonlyDB.mediaFilesDao.deleteMediaFile(mediaId);
} else {
@ -361,21 +361,7 @@ class MemoriesViewState extends State<MemoriesView>
.where((e) => e.mediaService.mediaFile.mediaId == mediaId)
.firstOrNull;
if (item != null) {
final media = item.mediaService;
if (media.mediaFile.type == MediaType.video) {
await saveVideoToGallery(
media.storedPath.path,
name: media.mediaFile.mediaId,
);
} else if (media.mediaFile.type == MediaType.image ||
media.mediaFile.type == MediaType.gif) {
final imageBytes = await media.storedPath.readAsBytes();
await saveImageToGallery(
imageBytes,
createdAt: media.mediaFile.createdAt,
name: media.mediaFile.mediaId,
);
}
await item.mediaService.saveToGallery();
}
setProgress((i + 1) / selectedList.length);
}

View file

@ -6,7 +6,7 @@ import 'package:twonly/locator.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/memory_item.model.dart';
import 'package:twonly/src/services/api/mediafiles/upload.api.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/delete_memories_dialog.comp.dart';
@ -176,7 +176,7 @@ class _SynchronizedImageViewerScreenState
if (deleteCompletely == null) return;
if (deleteCompletely) {
item.mediaService.fullMediaRemoval();
await item.mediaService.fullMediaRemoval();
await RustApi.deleteMemory(mediaId: mediaId);
await twonlyDB.mediaFilesDao.deleteMediaFile(mediaId);
@ -221,20 +221,7 @@ class _SynchronizedImageViewerScreenState
}
try {
if (item.mediaFile.type == MediaType.video) {
await saveVideoToGallery(
item.storedPath.path,
name: item.mediaFile.mediaId,
);
} else if (item.mediaFile.type == MediaType.image ||
item.mediaFile.type == MediaType.gif) {
final imageBytes = await item.storedPath.readAsBytes();
await saveImageToGallery(
imageBytes,
createdAt: item.mediaFile.createdAt,
name: item.mediaFile.mediaId,
);
}
await item.saveToGallery();
if (!mounted) return;
showSnackbar(
context,
@ -254,10 +241,12 @@ class _SynchronizedImageViewerScreenState
Future<void> _shareMediaFile() async {
final orgMediaService = widget.galleryItems[_currentIndex].mediaService;
final newMediaService = await initializeMediaUpload(
orgMediaService.mediaFile.type,
userService.currentUser.defaultShowTime,
final mediaId = await RustApi.initializeMediaUpload(
mediaType: orgMediaService.mediaFile.type.name,
displayLimitInMilliseconds: userService.currentUser.defaultShowTime,
isDraftMedia: false,
);
final newMediaService = await MediaFileService.fromMediaId(mediaId);
if (newMediaService == null) {
Log.error('Could not create new mediaFile');
return;

View file

@ -18,7 +18,6 @@ import 'package:twonly/src/model/json/onboarding_state.model.dart';
import 'package:twonly/src/model/protobuf/client/generated/passwordless_recovery.pb.dart';
import 'package:twonly/src/services/backup.service.dart';
import 'package:twonly/src/services/passwordless_recovery.service.dart';
import 'package:twonly/src/utils/avatars.dart';
import 'package:twonly/src/utils/keyvalue.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
@ -496,7 +495,9 @@ class _RecoverPasswordlessState extends State<RecoverPasswordless> {
children: [
AvatarIcon(
svg: first.myAvatarSvg != null
? getAvatarSvg(Uint8List.fromList(first.myAvatarSvg!))
? RustApi.decodeAvatarSvg(
avatarSvgCompressed: first.myAvatarSvg!,
)
: null,
fontSize: 60,
),

View file

@ -89,7 +89,7 @@ Future<bool> promptAndDisableMemoriesBackup(BuildContext context) async {
for (final media in allMedias) {
final ms = MediaFileService(media);
if (!ms.storedPath.existsSync()) {
ms.fullMediaRemoval();
await ms.fullMediaRemoval();
await twonlyDB.mediaFilesDao.deleteMediaFile(media.mediaId);
}
}

View file

@ -5,7 +5,7 @@ import 'package:go_router/go_router.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/services/api/mediafiles/download.api.dart';
import 'package:twonly/src/services/mediafiles/media_download_policy.dart';
import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/misc.dart';

View file

@ -114,7 +114,7 @@ class _ImportFromGalleryViewState extends State<ImportFromGalleryView> {
await mediaService.storedPath.parent.create(recursive: true);
await File(mediaService.storedPath.path).writeAsBytes(bytes);
await mediaService.calculateAndSaveSize();
await mediaService.refreshStoredMetadata();
await mediaService.createThumbnail();
unawaited(mediaService.cropTransparentBorders());
@ -425,7 +425,7 @@ class _ImportFromGalleryViewState extends State<ImportFromGalleryView> {
await mediaService.storedPath.parent.create(recursive: true);
await file.copy(mediaService.storedPath.path);
await mediaService.calculateAndSaveSize();
await mediaService.refreshStoredMetadata();
await mediaService.createThumbnail();
unawaited(mediaService.cropTransparentBorders());

View file

@ -336,7 +336,7 @@ class _StorageContentsViewState extends State<StorageContentsView> {
if (deleteCompletely) {
await twonlyDB.mediaFilesDao.deleteMediaFile(file.mediaId);
unawaited(RustApi.deleteMemory(mediaId: file.mediaId));
MediaFileService(file).fullMediaRemoval();
await MediaFileService(file).fullMediaRemoval();
} else {
MediaFileService(file).storedPath.deleteSync();
}
@ -385,7 +385,7 @@ class _StorageContentsViewState extends State<StorageContentsView> {
if (deleteCompletely) {
await twonlyDB.mediaFilesDao.deleteMediaFile(file.mediaId);
unawaited(RustApi.deleteMemory(mediaId: file.mediaId));
MediaFileService(file).fullMediaRemoval();
await MediaFileService(file).fullMediaRemoval();
} else {
MediaFileService(file).storedPath.deleteSync();
}

View file

@ -7,7 +7,6 @@ import 'package:hashlib/random.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/api/messages.api.dart';
import 'package:twonly/src/visual/components/alert.dialog.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
@ -199,9 +198,7 @@ class _RetransmissionDataViewState extends State<RetransmissionDataView> {
ackByServerAt: const Value(null),
),
);
await tryToSendCompleteMessage(
receiptId: newReceiptId,
);
await RustApi.sendQueuedMessage(receiptId: newReceiptId);
},
label: const FaIcon(FontAwesomeIcons.arrowRotateRight),
),

Some files were not shown because too many files have changed in this diff Show more