commit 3d2dcec9857def6282341500117ce4f7e0c4970c parent b1860ed29f1a862b2aac91ebb94a5ab102598993 Author: rhunk <101876869+rhunk@users.noreply.github.com> Date: Mon, 1 Apr 2024 19:47:09 +0200 fix(app/logged_stories): coil preview decoder Diffstat:
10 files changed, 226 insertions(+), 215 deletions(-)
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt @@ -186,20 +186,16 @@ class DownloadProcessor ( fun handleInputStream(inputStream: InputStream, estimatedSize: Long = 0L) { createMediaTempFile().apply { val decryptedInputStream = (inputMedia.encryption?.decryptInputStream(inputStream) ?: inputStream).buffered() - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + val buffer = ByteArray(1024 * 1024 * 2) // 2MB var read: Int var totalRead = 0L - var lastTotalRead = 0L outputStream().use { outputStream -> while (decryptedInputStream.read(buffer).also { read = it } != -1) { outputStream.write(buffer, 0, read) totalRead += read inputMediaDownloadedBytes[inputMedia] = totalRead - if (totalRead - lastTotalRead > 1024 * 1024) { - setProgress("${totalRead / 1024}KB/${estimatedSize / 1024}KB") - lastTotalRead = totalRead - } + setProgress("${totalRead / 1024}KB/${estimatedSize / 1024}KB") } } }.also { downloadedMedias[inputMedia] = it } @@ -228,12 +224,14 @@ class DownloadProcessor ( } DownloadMediaType.DIRECT_MEDIA -> { val decoded = Base64.UrlSafe.decode(inputMedia.content) - createMediaTempFile().apply { - writeBytes(decoded) - }.also { downloadedMedias[inputMedia] = it } + totalSize += decoded.size.toLong() + handleInputStream(decoded.inputStream(), estimatedSize = decoded.size.toLong()) } else -> { - downloadedMedias[inputMedia] = File(inputMedia.content) + File(inputMedia.content).inputStream().use { + totalSize += it.available().toLong() + handleInputStream(it, estimatedSize = it.available().toLong()) + } } } }.also { jobs.add(it) } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/messaging/StreaksReminder.kt b/app/src/main/kotlin/me/rhunk/snapenhance/messaging/StreaksReminder.kt @@ -15,7 +15,7 @@ import me.rhunk.snapenhance.RemoteSideContext import me.rhunk.snapenhance.SharedContextHolder import me.rhunk.snapenhance.bridge.ForceStartActivity import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie -import me.rhunk.snapenhance.ui.util.ImageRequestHelper +import me.rhunk.snapenhance.ui.util.coil.ImageRequestHelper import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/LoggedStories.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/LoggedStories.kt @@ -1,56 +1,42 @@ package me.rhunk.snapenhance.ui.manager.pages.social import android.content.Intent -import android.graphics.Bitmap -import android.graphics.BitmapFactory import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ErrorOutline import androidx.compose.material3.Button import androidx.compose.material3.Card -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.core.content.FileProvider import androidx.navigation.NavBackStackEntry import coil.annotation.ExperimentalCoilApi -import coil.disk.DiskCache -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout +import coil.compose.rememberAsyncImagePainter import me.rhunk.snapenhance.bridge.DownloadCallback import me.rhunk.snapenhance.common.data.FileType import me.rhunk.snapenhance.common.data.StoryData import me.rhunk.snapenhance.common.data.download.* import me.rhunk.snapenhance.common.util.ktx.longHashCode -import me.rhunk.snapenhance.common.util.snap.MediaDownloaderHelper -import me.rhunk.snapenhance.core.util.media.PreviewUtils import me.rhunk.snapenhance.download.DownloadProcessor import me.rhunk.snapenhance.ui.manager.Routes import me.rhunk.snapenhance.ui.util.Dialog -import okhttp3.HttpUrl.Companion.toHttpUrl +import me.rhunk.snapenhance.ui.util.coil.ImageRequestHelper import okhttp3.OkHttpClient -import okhttp3.Request import java.io.File import java.text.DateFormat import java.util.Date import java.util.UUID -import javax.crypto.Cipher -import javax.crypto.CipherInputStream -import javax.crypto.spec.IvParameterSpec -import javax.crypto.spec.SecretKeySpec import kotlin.math.absoluteValue class LoggedStories : Routes.Route() { @@ -58,24 +44,18 @@ class LoggedStories : Routes.Route() { override val content: @Composable (NavBackStackEntry) -> Unit = content@{ navBackStackEntry -> val userId = navBackStackEntry.arguments?.getString("id") ?: return@content - val stories = remember { - mutableStateListOf<StoryData>() - } - val friendInfo = remember { - context.modDatabase.getFriendInfo(userId) - } - val httpClient = remember { OkHttpClient() } + val stories = remember { mutableStateListOf<StoryData>() } + val friendInfo = remember { context.modDatabase.getFriendInfo(userId) } var lastStoryTimestamp by remember { mutableLongStateOf(Long.MAX_VALUE) } var selectedStory by remember { mutableStateOf<StoryData?>(null) } - var coilCachedFile by remember { mutableStateOf<File?>(null) } selectedStory?.let { story -> fun downloadSelectedStory( inputMedia: InputMedia, ) { val mediaAuthor = friendInfo?.mutableUsername ?: userId - val uniqueHash = (selectedStory?.url ?: UUID.randomUUID().toString()).longHashCode().absoluteValue.toString(16) + val uniqueHash = UUID.randomUUID().toString().longHashCode().absoluteValue.toString(16) DownloadProcessor( remoteSideContext = context, @@ -131,24 +111,41 @@ class LoggedStories : Routes.Route() { ) { Button(onClick = { context.androidContext.externalCacheDir?.let { cacheDir -> - val cacheFile = coilCachedFile ?: run { + context.imageLoader.diskCache?.openSnapshot(story.url)?.use { diskCacheSnapshot -> + val cacheFile = diskCacheSnapshot.data.toFile() + val targetFile = File(cacheDir, cacheFile.name).also { + it.deleteOnExit() + } + + runCatching { + cacheFile.inputStream().let { + story.getEncryptionKeyPair()?.decryptInputStream(it) ?: it + }.use { inputStream -> + targetFile.outputStream().use { outputStream -> + inputStream.copyTo(outputStream) + } + } + + context.androidContext.startActivity(Intent().apply { + action = Intent.ACTION_VIEW + setDataAndType( + FileProvider.getUriForFile( + context.androidContext, + "me.rhunk.snapenhance.fileprovider", + targetFile + ), + FileType.fromFile(targetFile).mimeType + ) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) + }) + }.onFailure { + context.shortToast("Failed to open file. Check logs for more info") + context.log.error("Failed to open file", it) + } + } ?: run { context.shortToast("Failed to get file") return@Button } - val targetFile = File(cacheDir, cacheFile.name) - cacheFile.copyTo(targetFile, overwrite = true) - context.androidContext.startActivity(Intent().apply { - action = Intent.ACTION_VIEW - setDataAndType( - FileProvider.getUriForFile( - context.androidContext, - "me.rhunk.snapenhance.fileprovider", - targetFile - ), - FileType.fromFile(targetFile).mimeType - ) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) - }) } }) { Text(text = "Open") @@ -159,22 +156,27 @@ class LoggedStories : Routes.Route() { InputMedia( content = story.url, type = DownloadMediaType.REMOTE_MEDIA, - encryption = story.key?.let { it to story.iv!! }?.toKeyPair() + encryption = story.getEncryptionKeyPair() ) ) }) { Text(text = "Download") } - if (coilCachedFile != null) { + if (remember { + context.imageLoader.diskCache?.openSnapshot(story.url)?.also { it.close() } != null + }) { Button(onClick = { downloadSelectedStory( InputMedia( - content = coilCachedFile?.absolutePath ?: run { - context.shortToast("Failed to get file from cache") + content = context.imageLoader.diskCache?.openSnapshot(story.url)?.use { + it.data.toFile().absolutePath + } ?: run { + context.shortToast("Failed to get file") return@Button }, - type = DownloadMediaType.LOCAL_MEDIA + type = DownloadMediaType.LOCAL_MEDIA, + encryption = story.getEncryptionKeyPair() ) ) }) { @@ -195,107 +197,41 @@ class LoggedStories : Routes.Route() { columns = GridCells.Adaptive(100.dp), contentPadding = PaddingValues(8.dp), ) { - items(stories) { story -> - var isFailed by remember { mutableStateOf(false) } - var imageBitmap by remember { mutableStateOf<ImageBitmap?>(null) } - val uniqueHash = remember { story.url.hashCode().absoluteValue.toString(16) } - - fun openDiskCacheSnapshot(snapshot: DiskCache.Snapshot): Boolean { - runCatching { - val mediaList = mutableMapOf<SplitMediaAssetType, ByteArray>() - - snapshot.data.toFile().inputStream().use { inputStream -> - MediaDownloaderHelper.getSplitElements(inputStream) { type, splitInputStream -> - mediaList[type] = splitInputStream.readBytes() - } - } - - val originalMedia = mediaList[SplitMediaAssetType.ORIGINAL] ?: return@runCatching false - val overlay = mediaList[SplitMediaAssetType.OVERLAY] - - var bitmap: Bitmap? = PreviewUtils.createPreview(originalMedia, isVideo = FileType.fromByteArray(originalMedia).isVideo) - - overlay?.also { - bitmap = PreviewUtils.mergeBitmapOverlay(bitmap!!, BitmapFactory.decodeByteArray(it, 0, it.size)) - } - - imageBitmap = bitmap?.asImageBitmap() - return true - }.onFailure { - context.log.error("Failed to open disk cache snapshot", it) - } - return false - } - - LaunchedEffect(Unit) { - withContext(Dispatchers.IO) { - withTimeout(10000L) { - context.imageLoader.diskCache?.openSnapshot(uniqueHash)?.use { - if (!openDiskCacheSnapshot(it)) isFailed = true - return@withTimeout - } - - runCatching { - val response = httpClient.newCall(Request( - url = story.url.toHttpUrl() - )).execute() - response.body.byteStream().use { - val decrypted = story.key?.let { _ -> - val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") - cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(story.key, "AES"), IvParameterSpec(story.iv)) - CipherInputStream(it, cipher) - } ?: it - - context.imageLoader.diskCache?.openEditor(uniqueHash)?.apply { - data.toFile().outputStream().use { fos -> - decrypted.copyTo(fos) - } - commitAndOpenSnapshot()?.use { snapshot -> - if (!openDiskCacheSnapshot(snapshot)) isFailed = true - } - } - } - }.onFailure { - isFailed = true - context.log.error("Failed to load story", it) - } - } - } - } + items(stories, key = { it.url }) { story -> + var hasFailed by remember(story.url) { mutableStateOf(false) } Column( modifier = Modifier .padding(8.dp) .clickable { selectedStory = story - coilCachedFile = context.imageLoader.diskCache - ?.openSnapshot(uniqueHash) - .use { - it?.data?.toFile() - } } + .clip(MaterialTheme.shapes.medium) .heightIn(min = 128.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - if (isFailed) { - Icon( - imageVector = Icons.Default.ErrorOutline, - contentDescription = "", - modifier = Modifier.size(48.dp) - ) + if (hasFailed) { + Text(text = "Failed to load", Modifier.padding(8.dp), fontSize = 10.sp) } else { - imageBitmap?.let { - Card { - Image( - bitmap = it, - modifier = Modifier.fillMaxSize(), - contentDescription = null, - ) - } - } ?: run { - CircularProgressIndicator() - } + Image( + painter = rememberAsyncImagePainter( + model = ImageRequestHelper.newPreviewImageRequest( + context.androidContext, + story.url, + story.getEncryptionKeyPair(), + ), + imageLoader = context.imageLoader, + onError = { + hasFailed = true + } + ), + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = Modifier + .fillMaxSize() + .height(128.dp) + ) } } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/ManageScope.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/ManageScope.kt @@ -22,7 +22,7 @@ import me.rhunk.snapenhance.common.data.SocialScope import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie import me.rhunk.snapenhance.ui.manager.Routes import me.rhunk.snapenhance.ui.util.AlertDialogs -import me.rhunk.snapenhance.ui.util.BitmojiImage +import me.rhunk.snapenhance.ui.util.coil.BitmojiImage import me.rhunk.snapenhance.ui.util.Dialog import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/SocialRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/SocialRoot.kt @@ -31,7 +31,7 @@ import me.rhunk.snapenhance.common.data.MessagingGroupInfo import me.rhunk.snapenhance.common.data.SocialScope import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie import me.rhunk.snapenhance.ui.manager.Routes -import me.rhunk.snapenhance.ui.util.BitmojiImage +import me.rhunk.snapenhance.ui.util.coil.BitmojiImage import me.rhunk.snapenhance.ui.util.pagerTabIndicatorOffset class SocialRoot : Routes.Route() { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/ComposeImageHelper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/ComposeImageHelper.kt @@ -1,60 +0,0 @@ -package me.rhunk.snapenhance.ui.util - -import android.content.Context -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.requiredWidthIn -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.unit.dp -import coil.compose.rememberAsyncImagePainter -import coil.request.ImageRequest -import coil.size.Precision -import me.rhunk.snapenhance.R -import me.rhunk.snapenhance.RemoteSideContext - -@Composable -fun BitmojiImage(context: RemoteSideContext, modifier: Modifier = Modifier, size: Int = 48, url: String?) { - Image( - painter = rememberAsyncImagePainter( - model = ImageRequestHelper.newBitmojiImageRequest( - context.androidContext, - url - ), - imageLoader = context.imageLoader - ), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .requiredWidthIn(min = 0.dp, max = size.dp) - .height(size.dp) - .clip(MaterialTheme.shapes.medium) - .then(modifier) - ) -} - -fun ImageRequest.Builder.cacheKey(key: String?) = apply { - memoryCacheKey(key) - diskCacheKey(key) -} - -object ImageRequestHelper { - fun newBitmojiImageRequest(context: Context, url: String?) = ImageRequest.Builder(context) - .data(url) - .fallback(R.drawable.bitmoji_blank) - .precision(Precision.INEXACT) - .crossfade(true) - .cacheKey(url) - .build() - - fun newDownloadPreviewImageRequest(context: Context, filePath: String?) = ImageRequest.Builder(context) - .data(filePath) - .cacheKey(filePath) - .memoryCacheKey(filePath) - .crossfade(true) - .crossfade(200) - .build() -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/coil/CoilPreviewDecoder.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/coil/CoilPreviewDecoder.kt @@ -0,0 +1,61 @@ +package me.rhunk.snapenhance.ui.util.coil + +import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import coil.decode.DecodeResult +import coil.decode.Decoder +import coil.fetch.SourceResult +import me.rhunk.snapenhance.common.data.FileType +import me.rhunk.snapenhance.common.data.download.MediaEncryptionKeyPair +import me.rhunk.snapenhance.common.data.download.SplitMediaAssetType +import me.rhunk.snapenhance.common.logger.AbstractLogger +import me.rhunk.snapenhance.common.util.snap.MediaDownloaderHelper +import me.rhunk.snapenhance.core.util.media.PreviewUtils + +class CoilPreviewDecoder( + private val resources: Resources, + private val sourceResult: SourceResult, + private val encryptionKeyPair: MediaEncryptionKeyPair? = null, + private val mergeOverlay: Boolean = false +): Decoder { + override suspend fun decode(): DecodeResult { + return sourceResult.source.file().toFile().inputStream().use { fileInputStream -> + val cipherInputStream = encryptionKeyPair?.decryptInputStream(fileInputStream) ?: fileInputStream + + var bitmap: Bitmap? = null + var overlayBitmap: Bitmap? = null + + MediaDownloaderHelper.getSplitElements(cipherInputStream) { type, inputStream -> + if (inputStream.available() > 50 * 1024 * 1024) { + return@getSplitElements + } + if (type == SplitMediaAssetType.ORIGINAL || (mergeOverlay && type == SplitMediaAssetType.OVERLAY)) { + runCatching { + val bytes = inputStream.readBytes() + PreviewUtils.createPreview(bytes, isVideo = FileType.fromByteArray(bytes).isVideo)?.let { + if (type == SplitMediaAssetType.ORIGINAL) { + bitmap = it + } else { + overlayBitmap = it + } + } + }.onFailure { + AbstractLogger.directError("CoilPreviewDecoder", it) + } + } + } + + if (mergeOverlay && overlayBitmap != null) { + bitmap = PreviewUtils.mergeBitmapOverlay(bitmap!!, overlayBitmap!!) + } + + cipherInputStream.close() + + DecodeResult( + drawable = BitmapDrawable(resources, bitmap!!), + isSampled = true + ) + } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/coil/ComposeImageHelper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/coil/ComposeImageHelper.kt @@ -0,0 +1,71 @@ +package me.rhunk.snapenhance.ui.util.coil + +import android.content.Context +import android.graphics.drawable.ColorDrawable +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.requiredWidthIn +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil.compose.rememberAsyncImagePainter +import coil.request.ImageRequest +import coil.size.Precision +import me.rhunk.snapenhance.R +import me.rhunk.snapenhance.RemoteSideContext +import me.rhunk.snapenhance.common.data.download.MediaEncryptionKeyPair + +@Composable +fun BitmojiImage(context: RemoteSideContext, modifier: Modifier = Modifier, size: Int = 48, url: String?) { + Image( + painter = rememberAsyncImagePainter( + model = ImageRequestHelper.newBitmojiImageRequest( + context.androidContext, + url + ), + imageLoader = context.imageLoader + ), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .requiredWidthIn(min = 0.dp, max = size.dp) + .height(size.dp) + .clip(MaterialTheme.shapes.medium) + .then(modifier) + ) +} + +fun ImageRequest.Builder.cacheKey(key: String?) = apply { + memoryCacheKey(key) + diskCacheKey(key) +} + +object ImageRequestHelper { + fun newBitmojiImageRequest(context: Context, url: String?) = ImageRequest.Builder(context) + .data(url) + .fallback(R.drawable.bitmoji_blank) + .precision(Precision.INEXACT) + .crossfade(true) + .cacheKey(url) + .build() + + fun newPreviewImageRequest(context: Context, url: String, mediaEncryptionKeyPair: MediaEncryptionKeyPair? = null) = ImageRequest.Builder(context) + .cacheKey(url) + .precision(Precision.INEXACT) + .crossfade(true) + .placeholder(ColorDrawable(0x1EFFFFFF)) + .crossfade(200) + .data(url) + .decoderFactory { result, _, _ -> + CoilPreviewDecoder( + context.resources, + result, + mediaEncryptionKeyPair, + mergeOverlay = true + ) + } + .build() +}+ \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/MessagingCoreObjects.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/MessagingCoreObjects.kt @@ -4,6 +4,7 @@ import android.database.Cursor import android.os.Parcelable import kotlinx.parcelize.Parcelize import me.rhunk.snapenhance.common.config.FeatureNotice +import me.rhunk.snapenhance.common.data.download.toKeyPair import me.rhunk.snapenhance.common.util.ktx.getIntOrNull import me.rhunk.snapenhance.common.util.ktx.getInteger import me.rhunk.snapenhance.common.util.ktx.getLongOrNull @@ -125,4 +126,6 @@ class StoryData( val createdAt: Long, val key: ByteArray?, val iv: ByteArray? -)- \ No newline at end of file +) { + fun getEncryptionKeyPair() = key?.let { (it to (iv ?: return@let null)) }?.toKeyPair() +}+ \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/MediaEncryptionKeyPair.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/MediaEncryptionKeyPair.kt @@ -1,4 +1,3 @@ -@file:OptIn(ExperimentalEncodingApi::class) package me.rhunk.snapenhance.common.data.download @@ -15,6 +14,7 @@ data class MediaEncryptionKeyPair( val key: String, val iv: String ) { + @OptIn(ExperimentalEncodingApi::class) fun decryptInputStream(inputStream: InputStream): InputStream { val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(Base64.UrlSafe.decode(key), "AES"), IvParameterSpec(Base64.UrlSafe.decode(iv))) @@ -22,5 +22,6 @@ data class MediaEncryptionKeyPair( } } +@OptIn(ExperimentalEncodingApi::class) fun Pair<ByteArray, ByteArray>.toKeyPair() = MediaEncryptionKeyPair(Base64.UrlSafe.encode(this.first), Base64.UrlSafe.encode(this.second))