Adds support for video and image descriptions for accessibility use cases

This commit is contained in:
Vitor Pamplona 2023-04-24 10:21:01 -04:00
parent f1affc2dbb
commit 655cd20a01
10 changed files with 260 additions and 45 deletions

View File

@ -137,7 +137,7 @@ dependencies {
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha05' implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha05'
// view videos // view videos
implementation 'com.google.android.exoplayer:exoplayer:2.18.5' implementation 'com.google.android.exoplayer:exoplayer:2.18.6'
// Load images from the web. // Load images from the web.
implementation "io.coil-kt:coil-compose:$coil_version" implementation "io.coil-kt:coil-compose:$coil_version"

View File

@ -6,6 +6,7 @@
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />

View File

@ -18,7 +18,7 @@ class FileHeader(
val description: String? = null val description: String? = null
) { ) {
companion object { companion object {
fun prepare(fileUrl: String, mimeType: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) { fun prepare(fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
try { try {
val imageData = URL(fileUrl).readBytes() val imageData = URL(fileUrl).readBytes()
val sha256 = MessageDigest.getInstance("SHA-256") val sha256 = MessageDigest.getInstance("SHA-256")
@ -55,7 +55,7 @@ class FileHeader(
null null
} }
onReady(FileHeader(fileUrl, mimeType, hash, size, blurHash, "")) onReady(FileHeader(fileUrl, mimeType, hash, size, blurHash, description))
} catch (e: Exception) { } catch (e: Exception) {
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}") Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
onError() onError()

View File

@ -1,5 +1,9 @@
package com.vitorpamplona.amethyst.ui.actions package com.vitorpamplona.amethyst.ui.actions
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.util.Size
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
@ -13,10 +17,15 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.CurrencyBitcoin import androidx.compose.material.icons.filled.CurrencyBitcoin
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -25,15 +34,18 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale 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.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
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
@ -48,7 +60,9 @@ import com.vitorpamplona.amethyst.ui.note.ReplyInformation
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@OptIn(ExperimentalComposeUiApi::class) @OptIn(ExperimentalComposeUiApi::class)
@Composable @Composable
@ -188,6 +202,19 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
} }
} }
val url = postViewModel.contentToAddUrl
if (url != null) {
ImageVideoDescription(
url,
onAdd = { description ->
postViewModel.upload(url, description, context)
},
onCancel = {
postViewModel.contentToAddUrl = null
}
)
}
val user = postViewModel.account?.userProfile() val user = postViewModel.account?.userProfile()
val lud16 = user?.info?.lnAddress() val lud16 = user?.info?.lnAddress()
@ -277,7 +304,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
tint = MaterialTheme.colors.onBackground, tint = MaterialTheme.colors.onBackground,
modifier = Modifier.padding(bottom = 10.dp) modifier = Modifier.padding(bottom = 10.dp)
) { ) {
postViewModel.upload(it, context) postViewModel.selectImage(it)
} }
if (postViewModel.canUsePoll) { if (postViewModel.canUsePoll) {
@ -457,3 +484,149 @@ fun SearchButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier
) )
} }
} }
@Composable
fun ImageVideoDescription(
uri: Uri,
onAdd: (String) -> Unit,
onCancel: () -> Unit
) {
val resolver = LocalContext.current.contentResolver
val mediaType = resolver.getType(uri) ?: ""
val scope = rememberCoroutineScope()
val isImage = mediaType.startsWith("image")
val isVideo = mediaType.startsWith("video")
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 30.dp, end = 30.dp)
.clip(shape = RoundedCornerShape(10.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(30.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 10.dp)
) {
Text(
text = stringResource(
if (isImage) {
R.string.content_description_add_image
} else {
if (isVideo) {
R.string.content_description_add_video
} else {
R.string.content_description_add_document
}
}
),
fontSize = 20.sp,
fontWeight = FontWeight.W500,
modifier = Modifier
.padding(start = 10.dp)
.weight(1.0f)
)
IconButton(
modifier = Modifier.size(30.dp),
onClick = onCancel
) {
Icon(
imageVector = Icons.Default.Cancel,
null,
modifier = Modifier
.padding(end = 5.dp)
.size(30.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
}
Divider()
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 10.dp)
) {
if (mediaType.startsWith("image")) {
AsyncImage(
model = uri.toString(),
contentDescription = uri.toString(),
contentScale = ContentScale.FillWidth,
modifier = Modifier
.padding(top = 4.dp)
.fillMaxWidth()
)
} else if (mediaType.startsWith("video") && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
LaunchedEffect(key1 = uri) {
scope.launch(Dispatchers.IO) {
bitmap = resolver.loadThumbnail(uri, Size(1200, 1000), null)
}
}
bitmap?.let {
Image(
bitmap = it.asImageBitmap(),
contentDescription = "some useful description",
contentScale = ContentScale.FillWidth,
modifier = Modifier
.padding(top = 4.dp)
.fillMaxWidth()
)
}
} else {
VideoView(uri)
}
}
var message by remember { mutableStateOf("") }
OutlinedTextField(
label = { Text(text = stringResource(R.string.content_description)) },
modifier = Modifier.fillMaxWidth(),
value = message,
onValueChange = { message = it },
placeholder = {
Text(
text = stringResource(R.string.content_description_example),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
)
)
Button(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 10.dp),
onClick = {
onAdd(message)
},
shape = RoundedCornerShape(15.dp),
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(text = stringResource(R.string.add_content), color = Color.White, fontSize = 20.sp)
}
}
}
}

View File

@ -38,6 +38,9 @@ open class NewPostViewModel : ViewModel() {
var userSuggestions by mutableStateOf<List<User>>(emptyList()) var userSuggestions by mutableStateOf<List<User>>(emptyList())
var userSuggestionAnchor: TextRange? = null var userSuggestionAnchor: TextRange? = null
// Images and Videos
var contentToAddUrl by mutableStateOf<Uri?>(null)
// Polls // Polls
var canUsePoll by mutableStateOf(false) var canUsePoll by mutableStateOf(false)
var wantsPoll by mutableStateOf(false) var wantsPoll by mutableStateOf(false)
@ -84,6 +87,7 @@ open class NewPostViewModel : ViewModel() {
canAddInvoice = account.userProfile().info?.lnAddress() != null canAddInvoice = account.userProfile().info?.lnAddress() != null
canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channel() == null canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channel() == null
contentToAddUrl = null
this.account = account this.account = account
} }
@ -105,46 +109,15 @@ open class NewPostViewModel : ViewModel() {
cancel() cancel()
} }
fun upload(it: Uri, context: Context) { fun upload(it: Uri, description: String, context: Context) {
isUploadingImage = true isUploadingImage = true
contentToAddUrl = null
ImageUploader.uploadImage( ImageUploader.uploadImage(
uri = it, uri = it,
contentResolver = context.contentResolver, contentResolver = context.contentResolver,
onSuccess = { imageUrl, mimeType -> onSuccess = { imageUrl, mimeType ->
viewModelScope.launch(Dispatchers.IO) { createNIP97Record(imageUrl, mimeType, description)
// Images don't seem to be ready immediately after upload
if (mimeType?.startsWith("image/") == true) {
delay(2000)
} else {
delay(5000)
}
FileHeader.prepare(
imageUrl,
mimeType,
onReady = {
val note = account?.sendHeader(it)
isUploadingImage = false
if (note == null) {
message = TextFieldValue(message.text + "\n\n" + imageUrl)
} else {
message = TextFieldValue(message.text + "\n\nnostr:" + note.idNote())
}
urlPreview = findUrlInMessage()
},
onError = {
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
)
}
}, },
onError = { onError = {
isUploadingImage = false isUploadingImage = false
@ -157,6 +130,7 @@ open class NewPostViewModel : ViewModel() {
open fun cancel() { open fun cancel() {
message = TextFieldValue("") message = TextFieldValue("")
contentToAddUrl = null
urlPreview = null urlPreview = null
isUploadingImage = false isUploadingImage = false
mentions = null mentions = null
@ -220,7 +194,7 @@ open class NewPostViewModel : ViewModel() {
fun canPost(): Boolean { fun canPost(): Boolean {
return message.text.isNotBlank() && !isUploadingImage && !wantsInvoice && return message.text.isNotBlank() && !isUploadingImage && !wantsInvoice &&
(!wantsPoll || pollOptions.values.all { it.isNotEmpty() }) (!wantsPoll || pollOptions.values.all { it.isNotEmpty() }) && contentToAddUrl == null
} }
fun includePollHashtagInMessage(include: Boolean, hashtag: String) { fun includePollHashtagInMessage(include: Boolean, hashtag: String) {
@ -235,4 +209,45 @@ open class NewPostViewModel : ViewModel() {
) )
} }
} }
fun createNIP97Record(imageUrl: String, mimeType: String?, description: String) {
viewModelScope.launch(Dispatchers.IO) {
// Images don't seem to be ready immediately after upload
if (mimeType?.startsWith("image/") == true) {
delay(2000)
} else {
delay(5000)
}
FileHeader.prepare(
imageUrl,
mimeType,
description,
onReady = {
val note = account?.sendHeader(it)
isUploadingImage = false
if (note == null) {
message = TextFieldValue(message.text + "\n\n" + imageUrl)
} else {
message = TextFieldValue(message.text + "\n\nnostr:" + note.idNote())
}
urlPreview = findUrlInMessage()
},
onError = {
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
)
}
}
fun selectImage(uri: Uri) {
contentToAddUrl = uri
}
} }

View File

@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.components package com.vitorpamplona.amethyst.ui.components
import android.net.Uri
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@ -16,6 +17,7 @@ import androidx.lifecycle.LifecycleEventObserver
import com.google.android.exoplayer2.C import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.ExoPlayer import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.MediaMetadata
import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.source.ProgressiveMediaSource import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
@ -23,16 +25,33 @@ import com.google.android.exoplayer2.ui.StyledPlayerView
import com.vitorpamplona.amethyst.VideoCache import com.vitorpamplona.amethyst.VideoCache
@Composable @Composable
fun VideoView(videoUri: String, onDialog: ((Boolean) -> Unit)? = null) { fun VideoView(videoUri: String, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
VideoView(Uri.parse(videoUri), description, onDialog)
}
@Composable
fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current) val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current)
val exoPlayer = remember(videoUri) { val exoPlayer = remember(videoUri) {
val mediaBuilder = MediaItem.Builder().setUri(videoUri)
description?.let {
mediaBuilder.setMediaMetadata(
MediaMetadata.Builder().setDisplayTitle(it).build()
)
}
val media = mediaBuilder.build()
ExoPlayer.Builder(context).build().apply { ExoPlayer.Builder(context).build().apply {
repeatMode = Player.REPEAT_MODE_ALL repeatMode = Player.REPEAT_MODE_ALL
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
setMediaSource( setMediaSource(
ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource(MediaItem.fromUri(videoUri)) ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource(
media
)
) )
prepare() prepare()
} }

View File

@ -175,7 +175,7 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
} }
} }
} else { } else {
VideoView(content.url) { dialogOpen = true } VideoView(content.url, content.description) { dialogOpen = true }
} }
if (dialogOpen) { if (dialogOpen) {
@ -322,7 +322,7 @@ private fun RenderImageOrVideo(content: ZoomableContent) {
} }
} else { } else {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
VideoView(content.url) VideoView(content.url, content.description)
} }
} }
} }

View File

@ -233,7 +233,7 @@ fun ChannelScreen(
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, context) channelScreenModel.upload(it, "", context)
} }
}, },
colors = TextFieldDefaults.textFieldColors( colors = TextFieldDefaults.textFieldColors(

View File

@ -194,7 +194,7 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
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)
) { ) {
chatRoomScreenModel.upload(it, context) chatRoomScreenModel.upload(it, "", context)
} }
}, },
colors = TextFieldDefaults.textFieldColors( colors = TextFieldDefaults.textFieldColors(

View File

@ -297,4 +297,11 @@
<string name="hash_verification_passed">Image is the same since the post</string> <string name="hash_verification_passed">Image is the same since the post</string>
<string name="hash_verification_failed">Image has changed. The author might not have seen the change</string> <string name="hash_verification_failed">Image has changed. The author might not have seen the change</string>
<string name="content_description_add_image">Add Image</string>
<string name="content_description_add_video">Add Video</string>
<string name="content_description_add_document">Add Document</string>
<string name="add_content">Create and Add</string>
<string name="content_description">Description of the contents</string>
<string name="content_description_example">A blue boat in a white sandy beach at sunset</string>
</resources> </resources>