Merge pull request #118 from Radiokot/fix/gif_image_upload

Fix GIF image upload #115
This commit is contained in:
Vitor Pamplona
2023-02-13 09:40:46 -05:00
committed by GitHub
4 changed files with 97 additions and 82 deletions
@@ -1,60 +1,75 @@
package com.vitorpamplona.amethyst.ui.actions 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 com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import java.io.ByteArrayOutputStream import okhttp3.*
import java.io.IOException
import java.util.UUID
import okhttp3.Call
import okhttp3.Callback
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody import okio.BufferedSink
import okhttp3.OkHttpClient import okio.source
import okhttp3.Request import java.io.IOException
import okhttp3.RequestBody import java.util.*
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
object ImageUploader { object ImageUploader {
private fun encodeImage(bitmap: Bitmap): ByteArray { fun uploadImage(
val byteArrayOutPutStream = ByteArrayOutputStream() uri: Uri,
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, byteArrayOutPutStream) contentResolver: ContentResolver,
return byteArrayOutPutStream.toByteArray() onSuccess: (String) -> Unit,
} onError: (Throwable) -> Unit,
) {
val contentType = contentResolver.getType(uri)
fun uploadImage(bitmap: Bitmap, onSuccess: (String) -> Unit) {
val client = OkHttpClient.Builder().build() val client = OkHttpClient.Builder().build()
val body: RequestBody = MultipartBody.Builder() val requestBody: RequestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM) .setType(MultipartBody.FORM)
.addFormDataPart( .addFormDataPart(
"image", "image",
"${UUID.randomUUID()}.png", "${UUID.randomUUID()}",
encodeImage(bitmap).toRequestBody("image/png".toMediaType()) 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() .build()
val request: Request = Request.Builder() val request: Request = Request.Builder()
.url("https://api.imgur.com/3/image") .url("https://api.imgur.com/3/image")
.header("Authorization", "Client-ID e6aea87296f3f96") .header("Authorization", "Client-ID e6aea87296f3f96")
.post(body) .post(requestBody)
.build() .build()
client.newCall(request).enqueue(object : Callback { client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) { override fun onResponse(call: Call, response: Response) {
response.use { try {
val body = response.body check(response.isSuccessful)
if (body != null) { response.body.use { body ->
val tree = jacksonObjectMapper().readTree(body.string()) val tree = jacksonObjectMapper().readTree(body.string())
val url = tree?.get("data")?.get("link")?.asText() val url = tree?.get("data")?.get("link")?.asText()
if (url != null) checkNotNull(url) {
onSuccess(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) { override fun onFailure(call: Call, e: IOException) {
e.printStackTrace() e.printStackTrace()
onError(e)
} }
}) })
} }
@@ -1,27 +1,13 @@
package com.vitorpamplona.amethyst.ui.actions package com.vitorpamplona.amethyst.ui.actions
import android.widget.Toast
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.*
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.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button import androidx.compose.material.*
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.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember 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.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@@ -49,12 +33,7 @@ import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.UrlPreview import com.vitorpamplona.amethyst.ui.components.*
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.navigation.UploadFromGallery import com.vitorpamplona.amethyst.ui.navigation.UploadFromGallery
import com.vitorpamplona.amethyst.ui.note.ReplyInformation import com.vitorpamplona.amethyst.ui.note.ReplyInformation
import com.vitorpamplona.amethyst.ui.screen.UserLine import com.vitorpamplona.amethyst.ui.screen.UserLine
@@ -78,6 +57,10 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, account: Account
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
delay(100) delay(100)
focusRequester.requestFocus() focusRequester.requestFocus()
postViewModel.imageUploadingError.collect { error ->
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
}
} }
Dialog( Dialog(
@@ -106,7 +89,9 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, account: Account
onClose() onClose()
}) })
UploadFromGallery { UploadFromGallery(
isUploading = postViewModel.isUploadingImage,
) {
postViewModel.upload(it, context) postViewModel.upload(it, context)
} }
@@ -115,7 +100,8 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, account: Account
postViewModel.sendPost() postViewModel.sendPost()
onClose() onClose()
}, },
postViewModel.message.text.isNotBlank() isActive = postViewModel.message.text.isNotBlank()
&& !postViewModel.isUploadingImage
) )
} }
@@ -1,24 +1,19 @@
package com.vitorpamplona.amethyst.ui.actions package com.vitorpamplona.amethyst.ui.actions
import android.content.Context import android.content.Context
import android.graphics.ImageDecoder
import android.net.Uri import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.model.Account import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.*
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 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.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import nostr.postr.toNpub import nostr.postr.toNpub
class NewPostViewModel: ViewModel() { class NewPostViewModel: ViewModel() {
@@ -30,6 +25,8 @@ class NewPostViewModel: ViewModel() {
var message by mutableStateOf(TextFieldValue("")) var message by mutableStateOf(TextFieldValue(""))
var urlPreview by mutableStateOf<String?>(null) var urlPreview by mutableStateOf<String?>(null)
var isUploadingImage by mutableStateOf(false)
val imageUploadingError = MutableSharedFlow<String?>()
var userSuggestions by mutableStateOf<List<User>>(emptyList()) var userSuggestions by mutableStateOf<List<User>>(emptyList())
var userSuggestionAnchor: TextRange? = null var userSuggestionAnchor: TextRange? = null
@@ -104,26 +101,33 @@ class NewPostViewModel: ViewModel() {
message = TextFieldValue("") message = TextFieldValue("")
urlPreview = null urlPreview = null
isUploadingImage = false
} }
fun upload(it: Uri, context: Context) { fun upload(it: Uri, context: Context) {
val img = if (Build.VERSION.SDK_INT < 28) { isUploadingImage = true
MediaStore.Images.Media.getBitmap(context.contentResolver, it)
} else {
ImageDecoder.decodeBitmap(ImageDecoder.createSource(context.contentResolver, it))
}
img?.let { ImageUploader.uploadImage(
ImageUploader.uploadImage(img) { uri = it,
message = TextFieldValue(message.text + "\n\n" + it) contentResolver = context.contentResolver,
onSuccess = { imageUrl ->
isUploadingImage = false
message = TextFieldValue(message.text + "\n\n" + imageUrl)
urlPreview = findUrlInMessage() urlPreview = findUrlInMessage()
},
onError = {
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image")
}
} }
} )
} }
fun cancel() { fun cancel() {
message = TextFieldValue("") message = TextFieldValue("")
urlPreview = null urlPreview = null
isUploadingImage = false
} }
fun findUrlInMessage(): String? { fun findUrlInMessage(): String? {
@@ -9,12 +9,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.Button import androidx.compose.material.Button
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
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.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -24,7 +19,10 @@ import com.google.accompanist.permissions.rememberPermissionState
@OptIn(ExperimentalPermissionsApi::class) @OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
fun UploadFromGallery(onImageChosen: (Uri) -> Unit) { fun UploadFromGallery(
isUploading: Boolean,
onImageChosen: (Uri) -> Unit,
) {
val cameraPermissionState = val cameraPermissionState =
rememberPermissionState( rememberPermissionState(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@@ -50,18 +48,30 @@ fun UploadFromGallery(onImageChosen: (Uri) -> Unit) {
modifier = Modifier modifier = Modifier
.align(Alignment.TopCenter) .align(Alignment.TopCenter)
.padding(4.dp), .padding(4.dp),
enabled = !isUploading,
onClick = { onClick = {
showGallerySelect = true showGallerySelect = true
} }
) { ) {
Text("Upload Image") if (!isUploading) {
Text("Upload Image")
} else {
Text("Uploading…")
}
} }
} }
} }
} else { } else {
Column { Column {
Button(onClick = { cameraPermissionState.launchPermissionRequest() }) { Button(
Text("Upload Image") onClick = { cameraPermissionState.launchPermissionRequest() },
enabled = !isUploading,
) {
if (!isUploading) {
Text("Upload Image")
} else {
Text("Uploading…")
}
} }
} }
} }