mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-09-01 08:54:08 +00:00
direct media upload
This commit is contained in:
parent
da751cbded
commit
bd198acd0c
185 changed files with 10330 additions and 4393 deletions
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"/>
|
||||
|
|
|
|||
|
|
@ -68,7 +68,6 @@ class MainActivity : FlutterFragmentActivity() {
|
|||
|
||||
Keyring.initializeNdkContext(applicationContext)
|
||||
|
||||
VideoCompressionChannel.configure(flutterEngine, applicationContext)
|
||||
|
||||
NotificationTapChannel.configure(flutterEngine, applicationContext)
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
79
android/app/src/main/kotlin/eu/twonly/media/NativeGallery.kt
Normal file
79
android/app/src/main/kotlin/eu/twonly/media/NativeGallery.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
331
android/app/src/main/kotlin/eu/twonly/media/NativeVideoCodec.kt
Normal file
331
android/app/src/main/kotlin/eu/twonly/media/NativeVideoCodec.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
155
ios/Runner/DirectMediaTransfer.swift
Normal file
155
ios/Runner/DirectMediaTransfer.swift
Normal 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))
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
48
ios/Runner/NativeGallery.swift
Normal file
48
ios/Runner/NativeGallery.swift
Normal 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
|
||||
}
|
||||
38
ios/Runner/NativeImageCodec.swift
Normal file
38
ios/Runner/NativeImageCodec.swift
Normal 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)
|
||||
}
|
||||
320
ios/Runner/NativeVideoCodec.swift
Normal file
320
ios/Runner/NativeVideoCodec.swift
Normal 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)
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
32
lib/core/services/media_upload.dart
Normal file
32
lib/core/services/media_upload.dart
Normal 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;
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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}.');
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -64,36 +64,7 @@ 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,
|
||||
);
|
||||
if (recalculatedMac.equals(receivedMac)) {
|
||||
await twonlyDB.keyVerificationDao.addKeyVerification(
|
||||
fromUserId,
|
||||
VerificationType.secretQrToken,
|
||||
);
|
||||
Log.info('Contact was verified via secretQrToken');
|
||||
|
||||
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) {
|
||||
|
|
@ -107,11 +78,6 @@ class KeyVerificationService {
|
|||
),
|
||||
);
|
||||
}
|
||||
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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 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),
|
||||
),
|
||||
);
|
||||
/// 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();
|
||||
}
|
||||
}
|
||||
|
||||
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)),
|
||||
),
|
||||
);
|
||||
/// 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,9 +143,11 @@ class MediaFileService {
|
|||
directory,
|
||||
),
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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);
|
||||
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
|
||||
if (await file.exists()) {
|
||||
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'),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
lowerBound: double.negativeInfinity,
|
||||
upperBound: double.infinity,
|
||||
value: 0,
|
||||
)..addListener(() {
|
||||
setState(() {});
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
@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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: () =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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: '',
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
AdditionalMessageData? data;
|
||||
/// Decoded once per message rather than on every rebuild.
|
||||
AdditionalMessageData? _data;
|
||||
|
||||
if (widget.message.additionalMessageData != null) {
|
||||
@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(
|
||||
_data = AdditionalMessageData.fromBuffer(
|
||||
widget.message.additionalMessageData!,
|
||||
);
|
||||
} catch (e) {
|
||||
data = null;
|
||||
_data = null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,10 +144,8 @@ 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) {
|
||||
final link = _link;
|
||||
if (link != null && widget.message.mediaStored) {
|
||||
imageBorderRadius = widget.borderRadius.copyWith(
|
||||
bottomLeft: const Radius.circular(5),
|
||||
bottomRight: const Radius.circular(5),
|
||||
|
|
@ -131,7 +153,7 @@ class _ChatMediaEntryState extends State<ChatMediaEntry> {
|
|||
|
||||
additionalMessageData = Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.8,
|
||||
maxWidth: MediaQuery.sizeOf(context).width * 0.8,
|
||||
),
|
||||
padding: widget.info.padding,
|
||||
decoration: BoxDecoration(
|
||||
|
|
@ -143,12 +165,11 @@ class _ChatMediaEntryState extends State<ChatMediaEntry> {
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
BetterText(text: data.link, textColor: widget.info.textColor),
|
||||
BetterText(text: link, textColor: widget.info.textColor),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: widget.message.senderId == null
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
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);
|
||||
} else {
|
||||
// 24-hour HH:mm, locale-aware
|
||||
final format = DateFormat.Hm(Localizations.localeOf(context).toString());
|
||||
return format.format(dt);
|
||||
}
|
||||
}
|
||||
|
||||
final Map<String, DateFormat> _timeFormats = {};
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
if (ackByServer != null) ...[
|
||||
const SizedBox(width: 10),
|
||||
Tooltip(
|
||||
message: sealedSender != null
|
||||
? context.lang.sealedSenderTransportSealed
|
||||
: context.lang.sealedSenderTransportStandard,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
child: FaIcon(
|
||||
sealedSender != null
|
||||
? FontAwesomeIcons.solidEnvelope
|
||||
: FontAwesomeIcons.envelope,
|
||||
size: 13,
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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!);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue