From 784f983746df4616414dfbd334aa4090aa3bec76 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 8 Jun 2023 12:14:26 -0400 Subject: [PATCH] Image and Video Compression --- app/build.gradle | 5 + app/src/main/AndroidManifest.xml | 6 +- .../amethyst/ui/actions/ImageUploader.kt | 20 ++-- .../amethyst/ui/actions/NewMediaModel.kt | 74 ++++++++---- .../amethyst/ui/actions/NewPostViewModel.kt | 54 ++++++--- .../ui/actions/NewRelayListViewModel.kt | 7 +- .../ui/actions/NewUserMetadataViewModel.kt | 81 ++++++++----- .../amethyst/ui/components/MediaCompressor.kt | 110 ++++++++++++++++++ .../ui/screen/loggedIn/ChannelScreen.kt | 21 +++- .../ui/screen/loggedIn/ChatroomScreen.kt | 2 +- 10 files changed, 295 insertions(+), 85 deletions(-) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/components/MediaCompressor.kt diff --git a/app/build.gradle b/app/build.gradle index 693f1ffb9..a46fbb7ff 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -188,6 +188,11 @@ dependencies { // immutable collections to avoid recomposition implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.5") + // Video compression lib + implementation 'com.github.AbedElazizShe:LightCompressor:1.2.3' + // Image compression lib + implementation 'id.zelory:compressor:3.0.1' + // Automatic memory leak detection debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.11' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 05f62cbf6..0420434f4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ + @@ -13,8 +14,9 @@ - - Unit, onError: (Throwable) -> Unit ) { - val contentType = contentResolver.getType(uri) + val myContentType = contentType ?: contentResolver.getType(uri) val imageInputStream = contentResolver.openInputStream(uri) - val length = contentResolver.query(uri, null, null, null, null)?.use { - it.moveToFirst() - val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE) - it.getLong(sizeIndex) - } ?: 0 + val length = size + ?: contentResolver.query(uri, null, null, null, null)?.use { + it.moveToFirst() + val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE) + it.getLong(sizeIndex) + } ?: kotlin.runCatching { + uri.toFile().length() + }.getOrNull() ?: 0 checkNotNull(imageInputStream) { "Can't open the image input stream" @@ -57,7 +63,7 @@ object ImageUploader { } } - uploadImage(imageInputStream, length, contentType, myServer, onSuccess, onError) + uploadImage(imageInputStream, length, myContentType, myServer, onSuccess, onError) } fun uploadImage( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt index 5d500b6d1..ac3a7b403 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt @@ -11,6 +11,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.* import com.vitorpamplona.amethyst.service.FileHeader +import com.vitorpamplona.amethyst.ui.components.MediaCompressor import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch @@ -56,33 +57,56 @@ open class NewMediaModel : ViewModel() { isUploadingImage = true val contentResolver = context.contentResolver - val uri = galleryUri ?: return + val myGalleryUri = galleryUri ?: return val serverToUse = selectedServer ?: return - if (selectedServer == ServersAvailable.NIP95) { + val contentType = contentResolver.getType(myGalleryUri) + + viewModelScope.launch(Dispatchers.IO) { uploadingPercentage.value = 0.1f - uploadingDescription.value = "Loading" - val contentType = contentResolver.getType(uri) - contentResolver.openInputStream(uri)?.use { - createNIP95Record(it.readBytes(), contentType, description) - } - ?: run { - viewModelScope.launch { - imageUploadingError.emit("Failed to upload the image / video") - isUploadingImage = false - uploadingPercentage.value = 0.00f - uploadingDescription.value = null + uploadingDescription.value = "Compress" + MediaCompressor().compress( + myGalleryUri, + contentType, + context.applicationContext, + onReady = { fileUri, contentType, size -> + + if (selectedServer == ServersAvailable.NIP95) { + uploadingPercentage.value = 0.2f + uploadingDescription.value = "Loading" + contentResolver.openInputStream(fileUri)?.use { + createNIP95Record(it.readBytes(), contentType, description) + } + ?: run { + viewModelScope.launch { + imageUploadingError.emit("Failed to upload the image / video") + isUploadingImage = false + uploadingPercentage.value = 0.00f + uploadingDescription.value = null + } + } + } else { + uploadingPercentage.value = 0.2f + uploadingDescription.value = "Uploading" + ImageUploader.uploadImage( + uri = fileUri, + contentType = contentType, + size = size, + server = serverToUse, + contentResolver = contentResolver, + onSuccess = { imageUrl, mimeType -> + createNIP94Record(imageUrl, mimeType, description) + }, + onError = { + isUploadingImage = false + uploadingPercentage.value = 0.00f + uploadingDescription.value = null + viewModelScope.launch { + imageUploadingError.emit("Failed to upload the image / video") + } + } + ) } - } - } else { - uploadingPercentage.value = 0.1f - uploadingDescription.value = "Uploading" - ImageUploader.uploadImage( - uri = uri, - server = serverToUse, - contentResolver = contentResolver, - onSuccess = { imageUrl, mimeType -> - createNIP94Record(imageUrl, mimeType, description) }, onError = { isUploadingImage = false @@ -164,7 +188,7 @@ open class NewMediaModel : ViewModel() { } fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String) { - uploadingPercentage.value = 0.20f + uploadingPercentage.value = 0.30f uploadingDescription.value = "Hashing" viewModelScope.launch(Dispatchers.IO) { @@ -175,7 +199,7 @@ open class NewMediaModel : ViewModel() { description, onReady = { uploadingDescription.value = "Signing" - uploadingPercentage.value = 0.30f + uploadingPercentage.value = 0.40f val nip95 = account?.createNip95(bytes, headerInfo = it) if (nip95 != null) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index 8554ec2f2..02dfbb5c0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -19,6 +19,7 @@ import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource import com.vitorpamplona.amethyst.service.model.BaseTextNoteEvent import com.vitorpamplona.amethyst.service.model.PrivateDmEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent +import com.vitorpamplona.amethyst.ui.components.MediaCompressor import com.vitorpamplona.amethyst.ui.components.isValidURL import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator import kotlinx.coroutines.Dispatchers @@ -142,35 +143,52 @@ open class NewPostViewModel() : ViewModel() { cancel() } - fun upload(it: Uri, description: String, server: ServersAvailable, context: Context) { + fun upload(galleryUri: Uri, description: String, server: ServersAvailable, context: Context) { isUploadingImage = true contentToAddUrl = null val contentResolver = context.contentResolver + val contentType = contentResolver.getType(galleryUri) - if (server == ServersAvailable.NIP95) { - val contentType = contentResolver.getType(it) - contentResolver.openInputStream(it)?.use { - createNIP95Record(it.readBytes(), contentType, description) - } - } else { - ImageUploader.uploadImage( - uri = it, - server = server, - contentResolver = contentResolver, - onSuccess = { imageUrl, mimeType -> - if (isNIP94Server(server)) { - createNIP94Record(imageUrl, mimeType, description) + viewModelScope.launch(Dispatchers.IO) { + MediaCompressor().compress( + galleryUri, + contentType, + context.applicationContext, + onReady = { fileUri, contentType, size -> + if (server == ServersAvailable.NIP95) { + contentResolver.openInputStream(fileUri)?.use { + createNIP95Record(it.readBytes(), contentType, description) + } } else { - isUploadingImage = false - message = TextFieldValue(message.text + "\n\n" + imageUrl) - urlPreview = findUrlInMessage() + ImageUploader.uploadImage( + uri = fileUri, + contentType = contentType, + size = size, + server = server, + contentResolver = contentResolver, + onSuccess = { imageUrl, mimeType -> + if (isNIP94Server(server)) { + createNIP94Record(imageUrl, mimeType, description) + } else { + isUploadingImage = false + message = TextFieldValue(message.text + "\n\n" + imageUrl) + urlPreview = findUrlInMessage() + } + }, + onError = { + isUploadingImage = false + viewModelScope.launch { + imageUploadingError.emit("Failed to upload the image / video") + } + } + ) } }, onError = { isUploadingImage = false viewModelScope.launch { - imageUploadingError.emit("Failed to upload the image / video") + imageUploadingError.emit(it) } } ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListViewModel.kt index 3601a26f9..687a71cb7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListViewModel.kt @@ -1,6 +1,7 @@ package com.vitorpamplona.amethyst.ui.actions import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.RelaySetupInfo import com.vitorpamplona.amethyst.service.model.ContactListEvent @@ -8,9 +9,11 @@ import com.vitorpamplona.amethyst.service.relays.Constants import com.vitorpamplona.amethyst.service.relays.FeedType import com.vitorpamplona.amethyst.service.relays.RelayPool import kotlinx.collections.immutable.toImmutableSet +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch class NewRelayListViewModel : ViewModel() { private lateinit var account: Account @@ -25,7 +28,9 @@ class NewRelayListViewModel : ViewModel() { fun create() { relays.let { - account.saveRelayList(it.value) + viewModelScope.launch(Dispatchers.IO) { + account.saveRelayList(it.value) + } } clear() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt index 2675a6857..40820cef5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt @@ -13,6 +13,8 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.model.GitHubIdentity import com.vitorpamplona.amethyst.service.model.MastodonIdentity import com.vitorpamplona.amethyst.service.model.TwitterIdentity +import com.vitorpamplona.amethyst.ui.components.MediaCompressor +import id.zelory.compressor.Compressor.compress import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch @@ -143,46 +145,67 @@ class NewUserMetadataViewModel : ViewModel() { } fun uploadForPicture(uri: Uri, context: Context) { - upload( - uri, - context, - onUploading = { - isUploadingImageForPicture = it - }, - onUploaded = { - picture.value = it - } - ) + viewModelScope.launch(Dispatchers.IO) { + upload( + uri, + context, + onUploading = { + isUploadingImageForPicture = it + }, + onUploaded = { + picture.value = it + } + ) + } } fun uploadForBanner(uri: Uri, context: Context) { - upload( - uri, - context, - onUploading = { - isUploadingImageForBanner = it - }, - onUploaded = { - banner.value = it - } - ) + viewModelScope.launch(Dispatchers.IO) { + upload( + uri, + context, + onUploading = { + isUploadingImageForBanner = it + }, + onUploaded = { + banner.value = it + } + ) + } } - fun upload(it: Uri, context: Context, onUploading: (Boolean) -> Unit, onUploaded: (String) -> Unit) { + private suspend fun upload(galleryUri: Uri, context: Context, onUploading: (Boolean) -> Unit, onUploaded: (String) -> Unit) { onUploading(true) - ImageUploader.uploadImage( - uri = it, - server = account.defaultFileServer, - contentResolver = context.contentResolver, - onSuccess = { imageUrl, mimeType -> - onUploading(false) - onUploaded(imageUrl) + val contentResolver = context.contentResolver + + MediaCompressor().compress( + galleryUri, + contentResolver.getType(galleryUri), + context.applicationContext, + onReady = { fileUri, contentType, size -> + ImageUploader.uploadImage( + uri = fileUri, + contentType = contentType, + size = size, + server = account.defaultFileServer, + contentResolver = contentResolver, + onSuccess = { imageUrl, mimeType -> + onUploading(false) + onUploaded(imageUrl) + }, + onError = { + onUploading(false) + viewModelScope.launch { + imageUploadingError.emit("Failed to upload the image / video") + } + } + ) }, onError = { onUploading(false) viewModelScope.launch { - imageUploadingError.emit("Failed to upload the image / video") + imageUploadingError.emit(it) } } ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/MediaCompressor.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/MediaCompressor.kt new file mode 100644 index 000000000..dc3d2d27b --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/MediaCompressor.kt @@ -0,0 +1,110 @@ +package com.vitorpamplona.amethyst.ui.components + +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.webkit.MimeTypeMap +import androidx.core.net.toUri +import com.abedelazizshe.lightcompressorlibrary.CompressionListener +import com.abedelazizshe.lightcompressorlibrary.VideoCompressor +import com.abedelazizshe.lightcompressorlibrary.VideoQuality +import com.abedelazizshe.lightcompressorlibrary.config.AppSpecificStorageConfiguration +import com.abedelazizshe.lightcompressorlibrary.config.Configuration +import id.zelory.compressor.Compressor +import id.zelory.compressor.constraint.default +import java.io.File +import java.io.FileOutputStream +import java.util.UUID + +class MediaCompressor { + suspend fun compress( + uri: Uri, + contentType: String?, + applicationContext: Context, + onReady: (Uri, String?, Long?) -> Unit, + onError: (String) -> Unit + ) { + if (contentType?.startsWith("video", true) == true) { + VideoCompressor.start( + context = applicationContext, // => This is required + uris = listOf(uri), // => Source can be provided as content uris + isStreamable = false, + // THIS STORAGE + // sharedStorageConfiguration = SharedStorageConfiguration( + // saveAt = SaveLocation.movies, // => default is movies + // videoName = "compressed_video" // => required name + // ), + // OR AND NOT BOTH + appSpecificStorageConfiguration = AppSpecificStorageConfiguration( + videoName = UUID.randomUUID().toString() // => required name + ), + configureWith = Configuration( + quality = VideoQuality.LOW + ), + listener = object : CompressionListener { + override fun onProgress(index: Int, percent: Float) { + } + + override fun onStart(index: Int) { + // Compression start + } + + override fun onSuccess(index: Int, size: Long, path: String?) { + if (path != null) { + onReady(Uri.fromFile(File(path)), contentType, size) + } else { + onError("Compression Returned null") + } + } + + override fun onFailure(index: Int, failureMessage: String) { + onError(failureMessage) + } + + override fun onCancelled(index: Int) { + onError("Compression Cancelled") + } + } + ) + } else if (contentType?.startsWith("image", true) == true && !contentType.contains("gif")) { + val compressedImageFile = Compressor.compress(applicationContext, from(uri, contentType, applicationContext)) { + default(width = 640, format = Bitmap.CompressFormat.JPEG) + } + onReady(compressedImageFile.toUri(), contentType, compressedImageFile.length()) + } else { + onReady(uri, contentType, null) + } + } + + fun from(uri: Uri?, contentType: String?, context: Context): File { + val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" + + val inputStream = context.contentResolver.openInputStream(uri!!) + val fileName: String = UUID.randomUUID().toString() + ".$extension" + val splitName: Array = splitFileName(fileName) + val tempFile = File.createTempFile(splitName[0], splitName[1]) + inputStream?.use { input -> + FileOutputStream(tempFile).use { output -> + val buffer = ByteArray(1024 * 50) + var read: Int = input.read(buffer) + while (read != -1) { + output.write(buffer, 0, read) + read = input.read(buffer) + } + } + } + + return tempFile + } + + private fun splitFileName(fileName: String): Array { + var name = fileName + var extension = "" + val i = fileName.lastIndexOf(".") + if (i != -1) { + name = fileName.substring(0, i) + extension = fileName.substring(i) + } + return arrayOf(name, extension) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt index 9394b7a71..9963547af 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt @@ -71,6 +71,7 @@ import com.vitorpamplona.amethyst.ui.actions.NewChannelView import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel import com.vitorpamplona.amethyst.ui.actions.PostButton +import com.vitorpamplona.amethyst.ui.actions.ServersAvailable import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery import com.vitorpamplona.amethyst.ui.components.ResizeImage import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy @@ -219,7 +220,7 @@ fun ChannelScreen( val scope = rememberCoroutineScope() // LAST ROW - EditFieldRow(newPostModel, accountViewModel) { + EditFieldRow(newPostModel, isPrivate = false, accountViewModel = accountViewModel) { scope.launch { val tagger = NewMessageTagger( channelHex = channel.idHex, @@ -290,6 +291,7 @@ fun DisplayReplyingToNote( @Composable fun EditFieldRow( channelScreenModel: NewPostViewModel, + isPrivate: Boolean, accountViewModel: AccountViewModel, onSendNewMessage: () -> Unit ) { @@ -334,7 +336,22 @@ fun EditFieldRow( tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), modifier = Modifier.padding(start = 5.dp) ) { - channelScreenModel.upload(it, "", accountViewModel.account.defaultFileServer, context) + val fileServer = if (isPrivate) { + // TODO: Make private servers + when (accountViewModel.account.defaultFileServer) { + ServersAvailable.NOSTR_BUILD -> ServersAvailable.NOSTR_BUILD + ServersAvailable.NOSTRIMG -> ServersAvailable.NOSTRIMG + ServersAvailable.NOSTRFILES_DEV -> ServersAvailable.NOSTRFILES_DEV + ServersAvailable.NOSTR_BUILD_NIP_94 -> ServersAvailable.NOSTR_BUILD + ServersAvailable.NOSTRIMG_NIP_94 -> ServersAvailable.NOSTRIMG + ServersAvailable.NOSTRFILES_DEV_NIP_94 -> ServersAvailable.NOSTRFILES_DEV + ServersAvailable.NIP95 -> ServersAvailable.NOSTR_BUILD + } + } else { + accountViewModel.account.defaultFileServer + } + + channelScreenModel.upload(it, "", fileServer, context) } }, colors = TextFieldDefaults.textFieldColors( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt index e60c5d6e5..80262fce7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt @@ -169,7 +169,7 @@ fun ChatroomScreen( val scope = rememberCoroutineScope() // LAST ROW - EditFieldRow(newPostModel, accountViewModel) { + EditFieldRow(newPostModel, isPrivate = true, accountViewModel) { scope.launch(Dispatchers.IO) { accountViewModel.account.sendPrivateMessage( message = newPostModel.message.text,