diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt index 1e4f4f74e..ccb6b49cb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt @@ -1,60 +1,75 @@ package com.vitorpamplona.amethyst.ui.actions -import android.graphics.Bitmap +import android.content.ContentResolver +import android.net.Uri import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import java.io.ByteArrayOutputStream -import java.io.IOException -import java.util.UUID -import okhttp3.Call -import okhttp3.Callback +import okhttp3.* import okhttp3.MediaType.Companion.toMediaType -import okhttp3.MultipartBody -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody -import okhttp3.RequestBody.Companion.toRequestBody -import okhttp3.Response +import okio.BufferedSink +import okio.source +import java.io.IOException +import java.util.* object ImageUploader { - private fun encodeImage(bitmap: Bitmap): ByteArray { - val byteArrayOutPutStream = ByteArrayOutputStream() - bitmap.compress(Bitmap.CompressFormat.JPEG, 100, byteArrayOutPutStream) - return byteArrayOutPutStream.toByteArray() - } + fun uploadImage( + uri: Uri, + contentResolver: ContentResolver, + onSuccess: (String) -> Unit, + onError: (Throwable) -> Unit, +) { + val contentType = contentResolver.getType(uri) - fun uploadImage(bitmap: Bitmap, onSuccess: (String) -> Unit) { val client = OkHttpClient.Builder().build() - val body: RequestBody = MultipartBody.Builder() + val requestBody: RequestBody = MultipartBody.Builder() .setType(MultipartBody.FORM) .addFormDataPart( "image", - "${UUID.randomUUID()}.png", - encodeImage(bitmap).toRequestBody("image/png".toMediaType()) + "${UUID.randomUUID()}", + object : RequestBody() { + override fun contentType(): MediaType? = + contentType?.toMediaType() + + override fun writeTo(sink: BufferedSink) { + val imageInputStream = contentResolver.openInputStream(uri) + checkNotNull(imageInputStream) { + "Can't open the image input stream" + } + + imageInputStream.source().use(sink::writeAll) + } + } ) .build() val request: Request = Request.Builder() .url("https://api.imgur.com/3/image") .header("Authorization", "Client-ID e6aea87296f3f96") - .post(body) + .post(requestBody) .build() client.newCall(request).enqueue(object : Callback { override fun onResponse(call: Call, response: Response) { - response.use { - val body = response.body - if (body != null) { + try { + check(response.isSuccessful) + response.body.use { body -> val tree = jacksonObjectMapper().readTree(body.string()) val url = tree?.get("data")?.get("link")?.asText() - if (url != null) - onSuccess(url) + checkNotNull(url) { + "There must be an uploaded image URL in the response" + } + + onSuccess(url) } + } catch (e: Exception) { + e.printStackTrace() + onError(e) } } override fun onFailure(call: Call, e: IOException) { e.printStackTrace() + onError(e) } }) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt index e4ae2a48c..169bf826c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt @@ -1,27 +1,13 @@ package com.vitorpamplona.amethyst.ui.actions +import android.widget.Toast import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.Button -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.Icon -import androidx.compose.material.LocalTextStyle -import androidx.compose.material.MaterialTheme -import androidx.compose.material.OutlinedTextField -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.TextFieldDefaults +import androidx.compose.material.* import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember @@ -37,11 +23,9 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.viewmodel.compose.viewModel @@ -49,12 +33,7 @@ import coil.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.components.UrlPreview -import com.vitorpamplona.amethyst.ui.components.VideoView -import com.vitorpamplona.amethyst.ui.components.imageExtension -import com.vitorpamplona.amethyst.ui.components.isValidURL -import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator -import com.vitorpamplona.amethyst.ui.components.videoExtension +import com.vitorpamplona.amethyst.ui.components.* import com.vitorpamplona.amethyst.ui.navigation.UploadFromGallery import com.vitorpamplona.amethyst.ui.note.ReplyInformation import com.vitorpamplona.amethyst.ui.screen.UserLine @@ -78,6 +57,10 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, account: Account LaunchedEffect(Unit) { delay(100) focusRequester.requestFocus() + + postViewModel.imageUploadingError.collect { error -> + Toast.makeText(context, error, Toast.LENGTH_SHORT).show() + } } Dialog( @@ -106,7 +89,9 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, account: Account onClose() }) - UploadFromGallery { + UploadFromGallery( + isUploading = postViewModel.isUploadingImage, + ) { postViewModel.upload(it, context) } @@ -115,7 +100,8 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, account: Account postViewModel.sendPost() onClose() }, - postViewModel.message.text.isNotBlank() + isActive = postViewModel.message.text.isNotBlank() + && !postViewModel.isUploadingImage ) } 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 9c151483f..82762eab6 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 @@ -1,24 +1,19 @@ package com.vitorpamplona.amethyst.ui.actions import android.content.Context -import android.graphics.ImageDecoder import android.net.Uri -import android.os.Build -import android.provider.MediaStore import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.model.decodePublicKey -import com.vitorpamplona.amethyst.model.toHexKey +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.model.* import com.vitorpamplona.amethyst.ui.components.isValidURL import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch import nostr.postr.toNpub class NewPostViewModel: ViewModel() { @@ -30,6 +25,8 @@ class NewPostViewModel: ViewModel() { var message by mutableStateOf(TextFieldValue("")) var urlPreview by mutableStateOf(null) + var isUploadingImage by mutableStateOf(false) + val imageUploadingError = MutableSharedFlow() var userSuggestions by mutableStateOf>(emptyList()) var userSuggestionAnchor: TextRange? = null @@ -104,26 +101,33 @@ class NewPostViewModel: ViewModel() { message = TextFieldValue("") urlPreview = null + isUploadingImage = false } fun upload(it: Uri, context: Context) { - val img = if (Build.VERSION.SDK_INT < 28) { - MediaStore.Images.Media.getBitmap(context.contentResolver, it) - } else { - ImageDecoder.decodeBitmap(ImageDecoder.createSource(context.contentResolver, it)) - } + isUploadingImage = true - img?.let { - ImageUploader.uploadImage(img) { - message = TextFieldValue(message.text + "\n\n" + it) + ImageUploader.uploadImage( + uri = it, + contentResolver = context.contentResolver, + onSuccess = { imageUrl -> + isUploadingImage = false + message = TextFieldValue(message.text + "\n\n" + imageUrl) urlPreview = findUrlInMessage() + }, + onError = { + isUploadingImage = false + viewModelScope.launch { + imageUploadingError.emit("Failed to upload the image") + } } - } + ) } fun cancel() { message = TextFieldValue("") urlPreview = null + isUploadingImage = false } fun findUrlInMessage(): String? { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UploadFromGallery.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UploadFromGallery.kt index 64d4bc0ea..2d4237f11 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UploadFromGallery.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UploadFromGallery.kt @@ -9,12 +9,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.material.Button import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -24,7 +19,10 @@ import com.google.accompanist.permissions.rememberPermissionState @OptIn(ExperimentalPermissionsApi::class) @Composable -fun UploadFromGallery(onImageChosen: (Uri) -> Unit) { +fun UploadFromGallery( + isUploading: Boolean, + onImageChosen: (Uri) -> Unit, +) { val cameraPermissionState = rememberPermissionState( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { @@ -50,18 +48,30 @@ fun UploadFromGallery(onImageChosen: (Uri) -> Unit) { modifier = Modifier .align(Alignment.TopCenter) .padding(4.dp), + enabled = !isUploading, onClick = { showGallerySelect = true } ) { - Text("Upload Image") + if (!isUploading) { + Text("Upload Image") + } else { + Text("Uploading…") + } } } } } else { Column { - Button(onClick = { cameraPermissionState.launchPermissionRequest() }) { - Text("Upload Image") + Button( + onClick = { cameraPermissionState.launchPermissionRequest() }, + enabled = !isUploading, + ) { + if (!isUploading) { + Text("Upload Image") + } else { + Text("Uploading…") + } } } }