Split chat overlays to fix ART VerifyError
Some checks failed
Android CI / android (push) Failing after 5m57s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled

This commit is contained in:
2026-03-11 23:00:19 +03:00
parent e6f1727800
commit 9af7597f8b

View File

@@ -1958,318 +1958,362 @@ fun ChatScreen(
} }
} }
if (viewerVideoUrl != null) { if (viewerVideoUrl != null) {
val videoUrl = viewerVideoUrl.orEmpty() VideoViewerOverlay(
var videoViewRef by remember(videoUrl) { mutableStateOf<VideoView?>(null) } videoUrl = viewerVideoUrl.orEmpty(),
var videoPrepared by remember(videoUrl) { mutableStateOf(false) } onDismiss = { viewerVideoUrl = null },
var videoDurationMs by remember(videoUrl) { mutableStateOf(0) } )
var videoPositionMs by remember(videoUrl) { mutableStateOf(0) } }
var videoPlaying by remember(videoUrl) { mutableStateOf(true) } if (showEmojiPicker) {
var isVideoSeeking by remember(videoUrl) { mutableStateOf(false) } EmojiPickerSheet(
var videoSeekFraction by remember(videoUrl) { mutableStateOf(0f) } sheetState = actionSheetState,
emojiPickerTab = emojiPickerTab,
onEmojiPickerTabChange = { emojiPickerTab = it },
gifSearchQuery = gifSearchQuery,
onGifSearchQueryChange = { gifSearchQuery = it },
giphySearchItems = giphySearchItems,
isGiphyLoading = isGiphyLoading,
giphyErrorMessage = giphyErrorMessage,
onDismiss = { showEmojiPicker = false },
onEmojiSelected = { emoji ->
composerValue = composerValue.insertAtCursor(emoji)
onInputChanged(composerValue.text)
},
onSendPresetMediaUrl = onSendPresetMediaUrl,
onSendRemoteMedia = onSendRemoteMedia,
)
}
}
}
DisposableEffect(videoUrl) { @Composable
onDispose { private fun VideoViewerOverlay(
runCatching { videoViewRef?.stopPlayback() } videoUrl: String,
videoViewRef = null onDismiss: () -> Unit,
} ) {
} var videoViewRef by remember(videoUrl) { mutableStateOf<VideoView?>(null) }
var videoPrepared by remember(videoUrl) { mutableStateOf(false) }
var videoDurationMs by remember(videoUrl) { mutableStateOf(0) }
var videoPositionMs by remember(videoUrl) { mutableStateOf(0) }
var videoPlaying by remember(videoUrl) { mutableStateOf(true) }
var isVideoSeeking by remember(videoUrl) { mutableStateOf(false) }
var videoSeekFraction by remember(videoUrl) { mutableStateOf(0f) }
LaunchedEffect(videoPlaying, videoPrepared, isVideoSeeking, videoUrl) { DisposableEffect(videoUrl) {
if (!videoPlaying || !videoPrepared || isVideoSeeking) return@LaunchedEffect onDispose {
while (videoPlaying && viewerVideoUrl == videoUrl && !isVideoSeeking) { runCatching { videoViewRef?.stopPlayback() }
videoPositionMs = runCatching { videoViewRef?.currentPosition ?: videoPositionMs } videoViewRef = null
.getOrDefault(videoPositionMs) }
delay(250) }
}
}
Surface( LaunchedEffect(videoPlaying, videoPrepared, isVideoSeeking, videoUrl) {
if (!videoPlaying || !videoPrepared || isVideoSeeking) return@LaunchedEffect
while (videoPlaying && !isVideoSeeking) {
videoPositionMs = runCatching { videoViewRef?.currentPosition ?: videoPositionMs }
.getOrDefault(videoPositionMs)
delay(250)
}
}
Surface(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.78f)),
color = Color.Black.copy(alpha = 0.9f),
) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.SpaceBetween,
) {
Row(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxWidth()
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.78f)), .padding(horizontal = 8.dp, vertical = 8.dp),
color = Color.Black.copy(alpha = 0.9f), horizontalArrangement = Arrangement.End,
) { ) {
Column( IconButton(onClick = onDismiss) {
modifier = Modifier.fillMaxSize(), Icon(imageVector = Icons.Filled.Close, contentDescription = "Close video viewer")
verticalArrangement = Arrangement.SpaceBetween, }
}
AndroidView(
factory = { context ->
VideoView(context).apply {
setVideoPath(videoUrl)
videoViewRef = this
setOnPreparedListener { player ->
player.isLooping = false
videoPrepared = true
videoDurationMs = runCatching { duration }.getOrDefault(player.duration.coerceAtLeast(0))
start()
videoPlaying = true
}
setOnCompletionListener {
videoPlaying = false
videoPositionMs = videoDurationMs
}
}
},
update = { view ->
videoViewRef = view
if (videoPlaying && videoPrepared && !isVideoSeeking && !view.isPlaying) {
runCatching { view.start() }
}
},
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.padding(horizontal = 10.dp),
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) { ) {
Row( IconButton(
modifier = Modifier onClick = {
.fillMaxWidth() val view = videoViewRef ?: return@IconButton
.padding(horizontal = 8.dp, vertical = 8.dp), if (videoPlaying) {
horizontalArrangement = Arrangement.End, runCatching { view.pause() }
) { videoPlaying = false
IconButton(onClick = { viewerVideoUrl = null }) { } else {
Icon(imageVector = Icons.Filled.Close, contentDescription = "Close video viewer")
}
}
AndroidView(
factory = { context ->
VideoView(context).apply {
setVideoPath(videoUrl)
videoViewRef = this
setOnPreparedListener { player ->
player.isLooping = false
videoPrepared = true
videoDurationMs = runCatching { duration }.getOrDefault(player.duration.coerceAtLeast(0))
start()
videoPlaying = true
}
setOnCompletionListener {
videoPlaying = false
videoPositionMs = videoDurationMs
}
}
},
update = { view ->
videoViewRef = view
if (videoPlaying && videoPrepared && !isVideoSeeking && !view.isPlaying) {
runCatching { view.start() } runCatching { view.start() }
videoPlaying = true
} }
}, },
modifier = Modifier enabled = videoPrepared,
.fillMaxWidth()
.weight(1f)
.padding(horizontal = 10.dp),
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Row( Icon(
modifier = Modifier.fillMaxWidth(), imageVector = if (videoPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
horizontalArrangement = Arrangement.spacedBy(8.dp), contentDescription = if (videoPlaying) "Pause video" else "Play video",
verticalAlignment = Alignment.CenterVertically, tint = Color.White,
) {
IconButton(
onClick = {
val view = videoViewRef ?: return@IconButton
if (videoPlaying) {
runCatching { view.pause() }
videoPlaying = false
} else {
runCatching { view.start() }
videoPlaying = true
}
},
enabled = videoPrepared,
) {
Icon(
imageVector = if (videoPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = if (videoPlaying) "Pause video" else "Play video",
tint = Color.White,
)
}
Text(
text = extractFileName(videoUrl),
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium,
color = Color.White,
maxLines = 1,
)
}
val videoProgress = if (videoDurationMs <= 0) 0f else {
(videoPositionMs.toFloat() / videoDurationMs.toFloat()).coerceIn(0f, 1f)
}
Slider(
value = if (isVideoSeeking) videoSeekFraction else videoProgress,
onValueChange = { fraction ->
isVideoSeeking = true
videoSeekFraction = fraction.coerceIn(0f, 1f)
},
onValueChangeFinished = {
val targetMs = (videoDurationMs * videoSeekFraction).roundToInt()
runCatching { videoViewRef?.seekTo(targetMs) }
videoPositionMs = targetMs
isVideoSeeking = false
},
enabled = videoPrepared && videoDurationMs > 0,
) )
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = formatDuration(videoPositionMs),
style = MaterialTheme.typography.labelSmall,
color = Color.White.copy(alpha = 0.85f),
)
Text(
text = if (videoDurationMs > 0) formatDuration(videoDurationMs) else "--:--",
style = MaterialTheme.typography.labelSmall,
color = Color.White.copy(alpha = 0.85f),
)
}
} }
Text(
text = extractFileName(videoUrl),
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium,
color = Color.White,
maxLines = 1,
)
}
val videoProgress = if (videoDurationMs <= 0) 0f else {
(videoPositionMs.toFloat() / videoDurationMs.toFloat()).coerceIn(0f, 1f)
}
Slider(
value = if (isVideoSeeking) videoSeekFraction else videoProgress,
onValueChange = { fraction ->
isVideoSeeking = true
videoSeekFraction = fraction.coerceIn(0f, 1f)
},
onValueChangeFinished = {
val targetMs = (videoDurationMs * videoSeekFraction).roundToInt()
runCatching { videoViewRef?.seekTo(targetMs) }
videoPositionMs = targetMs
isVideoSeeking = false
},
enabled = videoPrepared && videoDurationMs > 0,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = formatDuration(videoPositionMs),
style = MaterialTheme.typography.labelSmall,
color = Color.White.copy(alpha = 0.85f),
)
Text(
text = if (videoDurationMs > 0) formatDuration(videoDurationMs) else "--:--",
style = MaterialTheme.typography.labelSmall,
color = Color.White.copy(alpha = 0.85f),
)
} }
} }
} }
if (showEmojiPicker) { }
ModalBottomSheet( }
onDismissRequest = { if (!isPickerSending) showEmojiPicker = false },
sheetState = actionSheetState, @OptIn(ExperimentalMaterial3Api::class)
) { @Composable
Column( private fun EmojiPickerSheet(
modifier = Modifier sheetState: androidx.compose.material3.SheetState,
.fillMaxWidth() emojiPickerTab: ComposerPickerTab,
.padding(horizontal = 12.dp, vertical = 8.dp), onEmojiPickerTabChange: (ComposerPickerTab) -> Unit,
verticalArrangement = Arrangement.spacedBy(10.dp), gifSearchQuery: String,
) { onGifSearchQueryChange: (String) -> Unit,
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { giphySearchItems: List<RemotePickerItem>,
ComposerPickerTab.entries.forEach { tab -> isGiphyLoading: Boolean,
val selected = emojiPickerTab == tab giphyErrorMessage: String?,
onDismiss: () -> Unit,
onEmojiSelected: (String) -> Unit,
onSendPresetMediaUrl: (String) -> Unit,
onSendRemoteMedia: (String, String, ByteArray) -> Unit,
) {
val scope = rememberCoroutineScope()
var isPickerSending by remember { mutableStateOf(false) }
ModalBottomSheet(
onDismissRequest = { if (!isPickerSending) onDismiss() },
sheetState = sheetState,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ComposerPickerTab.entries.forEach { tab ->
val selected = emojiPickerTab == tab
Surface(
shape = RoundedCornerShape(16.dp),
color = if (selected) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.22f)
} else {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.52f)
},
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.clickable { onEmojiPickerTabChange(tab) },
) {
Text(
text = stringResource(id = tab.titleRes),
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
style = MaterialTheme.typography.labelLarge,
)
}
}
}
when (emojiPickerTab) {
ComposerPickerTab.Emoji -> {
val emojiSet = listOf(
"😀", "😁", "😂", "🤣", "😊", "😍", "😘", "😎",
"🤔", "🙃", "😴", "😡", "🥳", "😭", "👍", "👎",
"🔥", "❤️", "💔", "🙏", "🎉", "🤝", "💯", "",
)
LazyVerticalGrid(
columns = GridCells.Fixed(8),
modifier = Modifier
.fillMaxWidth()
.height(240.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
items(emojiSet) { emoji ->
Surface( Surface(
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(10.dp),
color = if (selected) { color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
MaterialTheme.colorScheme.primary.copy(alpha = 0.22f)
} else {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.52f)
},
modifier = Modifier modifier = Modifier
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(10.dp))
.clickable { emojiPickerTab = tab }, .clickable { onEmojiSelected(emoji) },
) { ) {
Text( Box(modifier = Modifier.padding(vertical = 8.dp), contentAlignment = Alignment.Center) {
text = stringResource(id = tab.titleRes), Text(text = emoji)
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
style = MaterialTheme.typography.labelLarge,
)
}
}
}
when (emojiPickerTab) {
ComposerPickerTab.Emoji -> {
val emojiSet = listOf(
"😀", "😁", "😂", "🤣", "😊", "😍", "😘", "😎",
"🤔", "🙃", "😴", "😡", "🥳", "😭", "👍", "👎",
"🔥", "❤️", "💔", "🙏", "🎉", "🤝", "💯", "",
)
LazyVerticalGrid(
columns = GridCells.Fixed(8),
modifier = Modifier
.fillMaxWidth()
.height(240.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
items(emojiSet) { emoji ->
Surface(
shape = RoundedCornerShape(10.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.clickable {
composerValue = composerValue.insertAtCursor(emoji)
onInputChanged(composerValue.text)
},
) {
Box(modifier = Modifier.padding(vertical = 8.dp), contentAlignment = Alignment.Center) {
Text(text = emoji)
}
}
} }
} }
} }
}
}
ComposerPickerTab.Gif, ComposerPickerTab.Gif,
ComposerPickerTab.Sticker, ComposerPickerTab.Sticker,
-> { -> {
val remoteItems = if (emojiPickerTab == ComposerPickerTab.Gif) { val remoteItems = if (emojiPickerTab == ComposerPickerTab.Gif) {
if (gifSearchQuery.isBlank()) defaultGifItems else giphySearchItems if (gifSearchQuery.isBlank()) defaultGifItems else giphySearchItems
} else { } else {
defaultStickerItems defaultStickerItems
} }
Column( Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (emojiPickerTab == ComposerPickerTab.Gif) {
OutlinedTextField(
value = gifSearchQuery,
onValueChange = onGifSearchQueryChange,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp), singleLine = true,
) { placeholder = { Text(stringResource(id = R.string.chat_search_gifs)) },
if (emojiPickerTab == ComposerPickerTab.Gif) { )
OutlinedTextField( when {
value = gifSearchQuery, isGiphyLoading -> {
onValueChange = { gifSearchQuery = it }, Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, horizontalArrangement = Arrangement.Center,
placeholder = { Text(stringResource(id = R.string.chat_search_gifs)) }, ) {
) CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
when {
isGiphyLoading -> {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
}
}
!giphyErrorMessage.isNullOrBlank() -> {
Text(
text = giphyErrorMessage.orEmpty(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} }
} }
LazyVerticalGrid(
columns = GridCells.Fixed(3), !giphyErrorMessage.isNullOrBlank() -> {
Text(
text = giphyErrorMessage,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
LazyVerticalGrid(
columns = GridCells.Fixed(3),
modifier = Modifier
.fillMaxWidth()
.height(320.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
items(remoteItems) { remote ->
Surface(
shape = RoundedCornerShape(10.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .clip(RoundedCornerShape(10.dp))
.height(320.dp), .clickable(enabled = !isPickerSending) {
verticalArrangement = Arrangement.spacedBy(6.dp), scope.launch {
horizontalArrangement = Arrangement.spacedBy(6.dp), isPickerSending = true
) { if (emojiPickerTab == ComposerPickerTab.Gif || emojiPickerTab == ComposerPickerTab.Sticker) {
items(remoteItems) { remote -> onSendPresetMediaUrl(remote.url)
Surface( onDismiss()
shape = RoundedCornerShape(10.dp), } else {
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f), val payload = downloadRemoteMedia(remote.url, remote.fileNamePrefix)
modifier = Modifier if (payload != null) {
.clip(RoundedCornerShape(10.dp)) val stickerAdjustedName = "sticker_${payload.fileName}"
.clickable(enabled = !isPickerSending) { onSendRemoteMedia(stickerAdjustedName, payload.mimeType, payload.bytes)
scope.launch { onDismiss()
isPickerSending = true
if (emojiPickerTab == ComposerPickerTab.Gif || emojiPickerTab == ComposerPickerTab.Sticker) {
onSendPresetMediaUrl(remote.url)
showEmojiPicker = false
} else {
val payload = downloadRemoteMedia(remote.url, remote.fileNamePrefix)
if (payload != null) {
val stickerAdjustedName = "sticker_${payload.fileName}"
onSendRemoteMedia(stickerAdjustedName, payload.mimeType, payload.bytes)
showEmojiPicker = false
}
}
isPickerSending = false
} }
}, }
) { isPickerSending = false
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
AsyncImage(
model = remote.url,
contentDescription = remote.title,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f),
contentScale = ContentScale.Crop,
)
} }
} },
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
AsyncImage(
model = remote.url,
contentDescription = remote.title,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f),
contentScale = ContentScale.Crop,
)
} }
} }
} }
} }
} }
if (isPickerSending) { }
Row( }
modifier = Modifier.fillMaxWidth(), if (isPickerSending) {
horizontalArrangement = Arrangement.Center, Row(
) { modifier = Modifier.fillMaxWidth(),
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) horizontalArrangement = Arrangement.Center,
} ) {
} CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
} }
} }
} }