Image and Video Compression

This commit is contained in:
Vitor Pamplona
2023-06-08 12:14:26 -04:00
parent b964c6a7fa
commit 784f983746
10 changed files with 295 additions and 85 deletions
+5
View File
@@ -188,6 +188,11 @@ dependencies {
// immutable collections to avoid recomposition // immutable collections to avoid recomposition
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.5") 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 // Automatic memory leak detection
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.11' debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.11'
+4 -2
View File
@@ -4,6 +4,7 @@
<queries> <queries>
<package android:name="org.torproject.android"/> <package android:name="org.torproject.android"/>
</queries> </queries>
<uses-feature android:name="android.hardware.camera" android:required="false" /> <uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
@@ -13,8 +14,9 @@
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.NFC" /> <uses-permission android:name="android.permission.NFC" />
<!-- Used for SDK < 29 --> <uses-permission
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28"
tools:ignore="ScopedStorage" /> tools:ignore="ScopedStorage" />
<application <application
@@ -4,6 +4,7 @@ import android.content.ContentResolver
import android.net.Uri import android.net.Uri
import android.provider.OpenableColumns import android.provider.OpenableColumns
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import androidx.core.net.toFile
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.HttpClient import com.vitorpamplona.amethyst.service.HttpClient
@@ -22,19 +23,24 @@ object ImageUploader {
fun uploadImage( fun uploadImage(
uri: Uri, uri: Uri,
contentType: String?,
size: Long?,
server: ServersAvailable, server: ServersAvailable,
contentResolver: ContentResolver, contentResolver: ContentResolver,
onSuccess: (String, String?) -> Unit, onSuccess: (String, String?) -> Unit,
onError: (Throwable) -> Unit onError: (Throwable) -> Unit
) { ) {
val contentType = contentResolver.getType(uri) val myContentType = contentType ?: contentResolver.getType(uri)
val imageInputStream = contentResolver.openInputStream(uri) val imageInputStream = contentResolver.openInputStream(uri)
val length = contentResolver.query(uri, null, null, null, null)?.use { val length = size
it.moveToFirst() ?: contentResolver.query(uri, null, null, null, null)?.use {
val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE) it.moveToFirst()
it.getLong(sizeIndex) val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
} ?: 0 it.getLong(sizeIndex)
} ?: kotlin.runCatching {
uri.toFile().length()
}.getOrNull() ?: 0
checkNotNull(imageInputStream) { checkNotNull(imageInputStream) {
"Can't open the image input stream" "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( fun uploadImage(
@@ -11,6 +11,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.* import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.service.FileHeader import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -56,33 +57,56 @@ open class NewMediaModel : ViewModel() {
isUploadingImage = true isUploadingImage = true
val contentResolver = context.contentResolver val contentResolver = context.contentResolver
val uri = galleryUri ?: return val myGalleryUri = galleryUri ?: return
val serverToUse = selectedServer ?: return val serverToUse = selectedServer ?: return
if (selectedServer == ServersAvailable.NIP95) { val contentType = contentResolver.getType(myGalleryUri)
viewModelScope.launch(Dispatchers.IO) {
uploadingPercentage.value = 0.1f uploadingPercentage.value = 0.1f
uploadingDescription.value = "Loading" uploadingDescription.value = "Compress"
val contentType = contentResolver.getType(uri) MediaCompressor().compress(
contentResolver.openInputStream(uri)?.use { myGalleryUri,
createNIP95Record(it.readBytes(), contentType, description) contentType,
} context.applicationContext,
?: run { onReady = { fileUri, contentType, size ->
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video") if (selectedServer == ServersAvailable.NIP95) {
isUploadingImage = false uploadingPercentage.value = 0.2f
uploadingPercentage.value = 0.00f uploadingDescription.value = "Loading"
uploadingDescription.value = null 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 = { onError = {
isUploadingImage = false isUploadingImage = false
@@ -164,7 +188,7 @@ open class NewMediaModel : ViewModel() {
} }
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String) { fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String) {
uploadingPercentage.value = 0.20f uploadingPercentage.value = 0.30f
uploadingDescription.value = "Hashing" uploadingDescription.value = "Hashing"
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
@@ -175,7 +199,7 @@ open class NewMediaModel : ViewModel() {
description, description,
onReady = { onReady = {
uploadingDescription.value = "Signing" uploadingDescription.value = "Signing"
uploadingPercentage.value = 0.30f uploadingPercentage.value = 0.40f
val nip95 = account?.createNip95(bytes, headerInfo = it) val nip95 = account?.createNip95(bytes, headerInfo = it)
if (nip95 != null) { if (nip95 != null) {
@@ -19,6 +19,7 @@ import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.model.BaseTextNoteEvent import com.vitorpamplona.amethyst.service.model.BaseTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent 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.isValidURL
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -142,35 +143,52 @@ open class NewPostViewModel() : ViewModel() {
cancel() cancel()
} }
fun upload(it: Uri, description: String, server: ServersAvailable, context: Context) { fun upload(galleryUri: Uri, description: String, server: ServersAvailable, context: Context) {
isUploadingImage = true isUploadingImage = true
contentToAddUrl = null contentToAddUrl = null
val contentResolver = context.contentResolver val contentResolver = context.contentResolver
val contentType = contentResolver.getType(galleryUri)
if (server == ServersAvailable.NIP95) { viewModelScope.launch(Dispatchers.IO) {
val contentType = contentResolver.getType(it) MediaCompressor().compress(
contentResolver.openInputStream(it)?.use { galleryUri,
createNIP95Record(it.readBytes(), contentType, description) contentType,
} context.applicationContext,
} else { onReady = { fileUri, contentType, size ->
ImageUploader.uploadImage( if (server == ServersAvailable.NIP95) {
uri = it, contentResolver.openInputStream(fileUri)?.use {
server = server, createNIP95Record(it.readBytes(), contentType, description)
contentResolver = contentResolver, }
onSuccess = { imageUrl, mimeType ->
if (isNIP94Server(server)) {
createNIP94Record(imageUrl, mimeType, description)
} else { } else {
isUploadingImage = false ImageUploader.uploadImage(
message = TextFieldValue(message.text + "\n\n" + imageUrl) uri = fileUri,
urlPreview = findUrlInMessage() 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 = { onError = {
isUploadingImage = false isUploadingImage = false
viewModelScope.launch { viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video") imageUploadingError.emit(it)
} }
} }
) )
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.actions package com.vitorpamplona.amethyst.ui.actions
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.RelaySetupInfo import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.model.ContactListEvent 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.FeedType
import com.vitorpamplona.amethyst.service.relays.RelayPool import com.vitorpamplona.amethyst.service.relays.RelayPool
import kotlinx.collections.immutable.toImmutableSet import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class NewRelayListViewModel : ViewModel() { class NewRelayListViewModel : ViewModel() {
private lateinit var account: Account private lateinit var account: Account
@@ -25,7 +28,9 @@ class NewRelayListViewModel : ViewModel() {
fun create() { fun create() {
relays.let { relays.let {
account.saveRelayList(it.value) viewModelScope.launch(Dispatchers.IO) {
account.saveRelayList(it.value)
}
} }
clear() clear()
@@ -13,6 +13,8 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.model.GitHubIdentity import com.vitorpamplona.amethyst.service.model.GitHubIdentity
import com.vitorpamplona.amethyst.service.model.MastodonIdentity import com.vitorpamplona.amethyst.service.model.MastodonIdentity
import com.vitorpamplona.amethyst.service.model.TwitterIdentity 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.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -143,46 +145,67 @@ class NewUserMetadataViewModel : ViewModel() {
} }
fun uploadForPicture(uri: Uri, context: Context) { fun uploadForPicture(uri: Uri, context: Context) {
upload( viewModelScope.launch(Dispatchers.IO) {
uri, upload(
context, uri,
onUploading = { context,
isUploadingImageForPicture = it onUploading = {
}, isUploadingImageForPicture = it
onUploaded = { },
picture.value = it onUploaded = {
} picture.value = it
) }
)
}
} }
fun uploadForBanner(uri: Uri, context: Context) { fun uploadForBanner(uri: Uri, context: Context) {
upload( viewModelScope.launch(Dispatchers.IO) {
uri, upload(
context, uri,
onUploading = { context,
isUploadingImageForBanner = it onUploading = {
}, isUploadingImageForBanner = it
onUploaded = { },
banner.value = 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) onUploading(true)
ImageUploader.uploadImage( val contentResolver = context.contentResolver
uri = it,
server = account.defaultFileServer, MediaCompressor().compress(
contentResolver = context.contentResolver, galleryUri,
onSuccess = { imageUrl, mimeType -> contentResolver.getType(galleryUri),
onUploading(false) context.applicationContext,
onUploaded(imageUrl) 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 = { onError = {
onUploading(false) onUploading(false)
viewModelScope.launch { viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video") imageUploadingError.emit(it)
} }
} }
) )
@@ -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<String> = 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<String> {
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)
}
}
@@ -71,6 +71,7 @@ import com.vitorpamplona.amethyst.ui.actions.NewChannelView
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.actions.PostButton 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.actions.UploadFromGallery
import com.vitorpamplona.amethyst.ui.components.ResizeImage import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
@@ -219,7 +220,7 @@ fun ChannelScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// LAST ROW // LAST ROW
EditFieldRow(newPostModel, accountViewModel) { EditFieldRow(newPostModel, isPrivate = false, accountViewModel = accountViewModel) {
scope.launch { scope.launch {
val tagger = NewMessageTagger( val tagger = NewMessageTagger(
channelHex = channel.idHex, channelHex = channel.idHex,
@@ -290,6 +291,7 @@ fun DisplayReplyingToNote(
@Composable @Composable
fun EditFieldRow( fun EditFieldRow(
channelScreenModel: NewPostViewModel, channelScreenModel: NewPostViewModel,
isPrivate: Boolean,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
onSendNewMessage: () -> Unit onSendNewMessage: () -> Unit
) { ) {
@@ -334,7 +336,22 @@ fun EditFieldRow(
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
modifier = Modifier.padding(start = 5.dp) 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( colors = TextFieldDefaults.textFieldColors(
@@ -169,7 +169,7 @@ fun ChatroomScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// LAST ROW // LAST ROW
EditFieldRow(newPostModel, accountViewModel) { EditFieldRow(newPostModel, isPrivate = true, accountViewModel) {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
accountViewModel.account.sendPrivateMessage( accountViewModel.account.sendPrivateMessage(
message = newPostModel.message.text, message = newPostModel.message.text,