diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/AttachmentFullscreenVideoPlayer.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/AttachmentFullscreenVideoPlayer.kt new file mode 100644 index 000000000000..78d247990bb8 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/AttachmentFullscreenVideoPlayer.kt @@ -0,0 +1,223 @@ +package org.wordpress.android.support.he.ui + +import android.view.ViewGroup +import androidx.core.net.toUri +import android.widget.FrameLayout +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.Player +import com.google.android.exoplayer2.SimpleExoPlayer +import com.google.android.exoplayer2.ui.PlayerView +import org.wordpress.android.R +import org.wordpress.android.support.he.util.VideoUrlResolver + +@Composable +fun AttachmentFullscreenVideoPlayer( + videoUrl: String, + onDismiss: () -> Unit, + onDownload: () -> Unit = {}, + videoUrlResolver: VideoUrlResolver? = null +) { + val context = LocalContext.current + var hasError by remember { mutableStateOf(false) } + var resolvedUrl by remember { mutableStateOf(null) } + var isResolving by remember { mutableStateOf(true) } + + // Resolve URL redirects before playing + androidx.compose.runtime.LaunchedEffect(videoUrl) { + if (videoUrlResolver != null) { + resolvedUrl = videoUrlResolver.resolveUrl(videoUrl) + } else { + resolvedUrl = videoUrl + } + isResolving = false + } + + val exoPlayer = remember(resolvedUrl) { + // Don't create player until URL is resolved + val url = resolvedUrl ?: return@remember null + + SimpleExoPlayer.Builder(context).build().apply { + // Add error listener + addListener(object : Player.EventListener { + override fun onPlayerError(error: com.google.android.exoplayer2.ExoPlaybackException) { + hasError = true + } + }) + + // Simple configuration using MediaItem + val mediaItem = MediaItem.fromUri(url.toUri()) + setMediaItem(mediaItem) + prepare() + playWhenReady = true + repeatMode = Player.REPEAT_MODE_OFF + } + } + + DisposableEffect(Unit) { + onDispose { + exoPlayer?.stop() + exoPlayer?.release() + } + } + + Dialog( + onDismissRequest = { + exoPlayer?.stop() + onDismiss() + }, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnBackPress = true, + dismissOnClickOutside = false + ) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + ) { + when { + isResolving -> { + // Show loading indicator while resolving URL + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + color = Color.White + ) + } + hasError -> { + // Show error message when video fails to load + Column( + modifier = Modifier + .align(Alignment.Center) + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(64.dp) + ) + Text( + text = stringResource(R.string.he_support_video_playback_error_title), + color = Color.White, + style = androidx.compose.material3.MaterialTheme.typography.titleLarge + ) + Text( + text = stringResource(R.string.he_support_video_playback_error_message), + color = Color.White.copy(alpha = 0.7f), + style = androidx.compose.material3.MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center + ) + Button( + onClick = { + exoPlayer?.stop() + onDownload() + onDismiss() + } + ) { + Text(stringResource(R.string.he_support_download_video_button)) + } + } + } + else -> { + // Show video player when URL is resolved and no error + exoPlayer?.let { player -> + AndroidView( + factory = { ctx -> + PlayerView(ctx).apply { + this.player = player + useController = true + layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + } + }, + modifier = Modifier.fillMaxSize() + ) + } + } + } + + // Top bar with close and download buttons + Row( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(16.dp) + .background( + color = Color.Black.copy(alpha = 0.5f), + shape = RoundedCornerShape(24.dp) + ) + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + // Download button + IconButton( + onClick = { + exoPlayer?.stop() + onDownload.invoke() + onDismiss.invoke() + } + ) { + Icon( + painter = painterResource(R.drawable.ic_get_app_white_24dp), + contentDescription = stringResource(R.string.he_support_download_attachment), + tint = Color.White, + modifier = Modifier.size(24.dp) + ) + } + + // Close button + IconButton( + onClick = { + exoPlayer?.stop() + onDismiss() + } + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(R.string.close), + tint = Color.White, + modifier = Modifier.size(24.dp) + ) + } + } + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationDetailScreen.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationDetailScreen.kt index 57c241f8c6e2..e71555dbe259 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationDetailScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationDetailScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Reply +import androidx.compose.material.icons.filled.PlayCircle import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api @@ -54,9 +55,12 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import coil.decode.VideoFrameDecoder import coil.request.ImageRequest +import coil.request.videoFrameMillis import org.wordpress.android.R import org.wordpress.android.support.aibot.util.formatRelativeTime +import org.wordpress.android.support.he.model.AttachmentType import org.wordpress.android.support.he.model.SupportAttachment import org.wordpress.android.support.he.model.SupportConversation import org.wordpress.android.support.he.model.SupportMessage @@ -79,7 +83,8 @@ fun HEConversationDetailScreen( onClearMessageSendResult: () -> Unit = {}, attachments: List = emptyList(), attachmentActionsListener: AttachmentActionsListener, - onDownloadAttachment: (org.wordpress.android.support.he.model.SupportAttachment) -> Unit = {} + onDownloadAttachment: (SupportAttachment) -> Unit = {}, + videoUrlResolver: org.wordpress.android.support.he.util.VideoUrlResolver? = null ) { val listState = rememberLazyListState() val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -91,8 +96,8 @@ fun HEConversationDetailScreen( var draftMessageText by remember { mutableStateOf("") } var draftIncludeAppLogs by remember { mutableStateOf(false) } - // State for fullscreen image preview - var previewImageUrl by remember { mutableStateOf(null) } + // State for fullscreen attachment preview (image or video) + var previewAttachment by remember { mutableStateOf(null) } // Scroll to bottom when conversation changes or new messages arrive LaunchedEffect(conversation.messages.size) { @@ -150,7 +155,7 @@ fun HEConversationDetailScreen( MessageItem( message = message, timestamp = formatRelativeTime(message.createdAt, resources), - onPreviewImage = { attachment -> previewImageUrl = attachment.url }, + onPreviewAttachment = { attachment -> previewAttachment = attachment }, onDownloadAttachment = onDownloadAttachment ) } @@ -199,20 +204,33 @@ fun HEConversationDetailScreen( ) } - // Show fullscreen image preview when an image attachment is tapped - previewImageUrl?.let { imageUrl -> - // Find the attachment with this URL to get the filename for download - val attachment = conversation.messages - .flatMap { it.attachments } - .firstOrNull { it.url == imageUrl } - - AttachmentFullscreenImagePreview( - imageUrl = imageUrl, - onDismiss = { previewImageUrl = null }, - onDownload = { - attachment?.let { onDownloadAttachment(it) } + // Show fullscreen attachment preview based on type + previewAttachment?.let { attachment -> + when (attachment.type) { + AttachmentType.Image -> { + AttachmentFullscreenImagePreview( + imageUrl = attachment.url, + onDismiss = { previewAttachment = null }, + onDownload = { + onDownloadAttachment(attachment) + } + ) } - ) + AttachmentType.Video -> { + AttachmentFullscreenVideoPlayer( + videoUrl = attachment.url, + onDismiss = { previewAttachment = null }, + onDownload = { + onDownloadAttachment(attachment) + }, + videoUrlResolver = videoUrlResolver + ) + } + else -> { + // For other types (documents, etc.), do nothing + // They should only be downloadable, not previewable + } + } } } @@ -289,7 +307,7 @@ private fun ConversationTitleCard(title: String) { private fun MessageItem( message: SupportMessage, timestamp: String, - onPreviewImage: (SupportAttachment) -> Unit, + onPreviewAttachment: (SupportAttachment) -> Unit, onDownloadAttachment: (SupportAttachment) -> Unit ) { val messageDescription = "${message.authorName}, $timestamp. ${message.formattedText}" @@ -350,7 +368,7 @@ private fun MessageItem( Spacer(modifier = Modifier.height(12.dp)) AttachmentsList( attachments = message.attachments, - onPreviewImage = onPreviewImage, + onPreviewAttachment = onPreviewAttachment, onDownloadAttachment = onDownloadAttachment ) } @@ -361,7 +379,7 @@ private fun MessageItem( @Composable private fun AttachmentsList( attachments: List, - onPreviewImage: (SupportAttachment) -> Unit, + onPreviewAttachment: (SupportAttachment) -> Unit, onDownloadAttachment: (SupportAttachment) -> Unit ) { FlowRow( @@ -372,10 +390,9 @@ private fun AttachmentsList( AttachmentItem( attachment = attachment, onClick = { - if (attachment.type == org.wordpress.android.support.he.model.AttachmentType.Image) { - onPreviewImage(attachment) - } else { - onDownloadAttachment(attachment) + when (attachment.type) { + AttachmentType.Image, AttachmentType.Video -> onPreviewAttachment(attachment) + else -> onDownloadAttachment(attachment) } } ) @@ -385,13 +402,13 @@ private fun AttachmentsList( @Composable private fun AttachmentItem( - attachment: org.wordpress.android.support.he.model.SupportAttachment, + attachment: SupportAttachment, onClick: () -> Unit ) { val iconRes = when (attachment.type) { - org.wordpress.android.support.he.model.AttachmentType.Image -> R.drawable.ic_image_white_24dp - org.wordpress.android.support.he.model.AttachmentType.Video -> R.drawable.ic_video_camera_white_24dp - org.wordpress.android.support.he.model.AttachmentType.Other -> R.drawable.ic_pages_white_24dp + AttachmentType.Image -> R.drawable.ic_image_white_24dp + AttachmentType.Video -> R.drawable.ic_video_camera_white_24dp + AttachmentType.Other -> R.drawable.ic_pages_white_24dp } Box( @@ -404,12 +421,19 @@ private fun AttachmentItem( ), contentAlignment = Alignment.Center ) { - if (attachment.type == org.wordpress.android.support.he.model.AttachmentType.Image) { - // Show image preview for image attachments + if (attachment.type == AttachmentType.Image || + attachment.type == AttachmentType.Video) { + // Show image/video preview for image and video attachments SubcomposeAsyncImage( model = ImageRequest.Builder(LocalContext.current) .data(attachment.url) .crossfade(true) + .apply { + if (attachment.type == AttachmentType.Video) { + decoderFactory(VideoFrameDecoder.Factory()) + videoFrameMillis(0) // Get first frame + } + } .build(), contentDescription = attachment.filename, modifier = Modifier.fillMaxSize(), @@ -426,7 +450,7 @@ private fun AttachmentItem( } }, error = { - // Show icon if image fails to load + // Show icon if image/video fails to load Icon( painter = painterResource(iconRes), contentDescription = null, @@ -435,8 +459,20 @@ private fun AttachmentItem( ) } ) + + // Add play icon overlay for videos + if (attachment.type == AttachmentType.Video) { + Icon( + imageVector = Icons.Default.PlayCircle, + contentDescription = stringResource(R.string.photo_picker_thumbnail_desc), + modifier = Modifier + .align(Alignment.Center) + .size(48.dp), + tint = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f) + ) + } } else { - // Show icon for non-image attachments + // Show icon for non-image/video attachments Icon( painter = painterResource(iconRes), contentDescription = null, diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportActivity.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportActivity.kt index e6c2f9861f68..6f042a5f02cc 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportActivity.kt @@ -31,6 +31,7 @@ import org.wordpress.android.R import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.support.common.ui.ConversationsSupportViewModel import org.wordpress.android.support.he.util.AttachmentActionsListener +import org.wordpress.android.support.he.util.VideoUrlResolver import org.wordpress.android.ui.photopicker.MediaPickerConstants import org.wordpress.android.ui.reader.ReaderFileDownloadManager import org.wordpress.android.ui.mediapicker.MediaPickerSetup @@ -42,6 +43,7 @@ import javax.inject.Inject class HESupportActivity : AppCompatActivity() { @Inject lateinit var fileDownloadManager: ReaderFileDownloadManager @Inject lateinit var appLogWrapper: AppLogWrapper + @Inject lateinit var videoUrlResolver: VideoUrlResolver private val viewModel by viewModels() private lateinit var composeView: ComposeView @@ -179,6 +181,7 @@ class HESupportActivity : AppCompatActivity() { isLoading = isLoadingConversation, isSendingMessage = isSendingMessage, messageSendResult = messageSendResult, + videoUrlResolver = videoUrlResolver, onBackClick = { viewModel.onBackClick() }, onSendMessage = { message, includeAppLogs -> viewModel.onAddMessageToConversation( diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/util/VideoUrlResolver.kt b/WordPress/src/main/java/org/wordpress/android/support/he/util/VideoUrlResolver.kt new file mode 100644 index 000000000000..4db12bd8c248 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/support/he/util/VideoUrlResolver.kt @@ -0,0 +1,68 @@ +package org.wordpress.android.support.he.util + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import org.wordpress.android.fluxc.utils.AppLogWrapper +import org.wordpress.android.util.AppLog +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +private const val SUCCESSFULLY_RESOLVED_CODE = 206 +private const val TIMEOUT_SECONDS = 30L + +/** + * Helper class to resolve video URLs that may have redirect chains. + * This is particularly useful for Zendesk attachment URLs which use multiple redirects + * with authentication tokens. + */ +class VideoUrlResolver @Inject constructor( + private val appLogWrapper: AppLogWrapper +) { + private val client by lazy { + OkHttpClient.Builder() + .followRedirects(true) + .followSslRedirects(true) + .connectTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .writeTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .build() + } + /** + * Resolves a video URL by following all redirects and returning the final URL. + * + * @param url The original video URL + * @return The final URL after following all redirects, or the original URL if resolution fails + */ + @Suppress("TooGenericExceptionCaught") + suspend fun resolveUrl(url: String): String = withContext(Dispatchers.IO) { + try { + val request = Request.Builder() + .url(url) + .get() + .header("Range", "bytes=0-0") // Request only first byte to minimize data transfer + .build() + + client.newCall(request).execute().use { response -> + val finalUrl = response.request.url.toString() + + when { + response.isSuccessful || response.code == SUCCESSFULLY_RESOLVED_CODE -> { + finalUrl + } + finalUrl != url -> { + // Even if response isn't successful, use the final URL if it's different + finalUrl + } + else -> { + url + } + } + } + } catch (e: Exception) { + appLogWrapper.e(AppLog.T.UTILS, "Error resolving support url: ${e.stackTraceToString()}") + url + } + } +} diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 957a50397d79..eea48b5b0145 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -5180,6 +5180,9 @@ translators: %s: Select control option value e.g: "Auto, 25%". --> Including logs can help our team investigate issues. Logs may contain recent app activity. Download attachment Select attachments + Unable to play video + This video cannot be played inline. Please download it to view. + Download Video Contact Support