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,