From 4bd6e6eaa4e950b1ac0cde3c87ce1cb5f5235a52 Mon Sep 17 00:00:00 2001 From: adalpari Date: Wed, 5 Nov 2025 13:08:49 +0100 Subject: [PATCH 1/7] Showing a video thumbnail --- .../he/ui/HEConversationDetailScreen.kt | 43 ++++++++++++++----- 1 file changed, 33 insertions(+), 10 deletions(-) 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..1b26cd72e4c2 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,7 @@ fun HEConversationDetailScreen( onClearMessageSendResult: () -> Unit = {}, attachments: List = emptyList(), attachmentActionsListener: AttachmentActionsListener, - onDownloadAttachment: (org.wordpress.android.support.he.model.SupportAttachment) -> Unit = {} + onDownloadAttachment: (SupportAttachment) -> Unit = {} ) { val listState = rememberLazyListState() val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -372,7 +376,7 @@ private fun AttachmentsList( AttachmentItem( attachment = attachment, onClick = { - if (attachment.type == org.wordpress.android.support.he.model.AttachmentType.Image) { + if (attachment.type == AttachmentType.Image) { onPreviewImage(attachment) } else { onDownloadAttachment(attachment) @@ -385,13 +389,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 +408,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 +437,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 +446,20 @@ private fun AttachmentItem( ) } ) + + // Add play icon overlay for videos + if (attachment.type == AttachmentType.Video) { + Icon( + imageVector = Icons.Default.PlayCircle, + contentDescription = null, + 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, From 63fcfbb384736425de668886018dbc8f8a7a0bee Mon Sep 17 00:00:00 2001 From: adalpari Date: Wed, 5 Nov 2025 14:20:50 +0100 Subject: [PATCH 2/7] Playing videos --- .../he/ui/AttachmentFullscreenVideoPlayer.kt | 236 ++++++++++++++++++ .../he/ui/HEConversationDetailScreen.kt | 29 ++- .../support/he/ui/HESupportActivity.kt | 1 + .../support/he/ui/HESupportViewModel.kt | 1 + .../support/he/util/VideoUrlResolver.kt | 75 ++++++ 5 files changed, 337 insertions(+), 5 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/support/he/ui/AttachmentFullscreenVideoPlayer.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/support/he/util/VideoUrlResolver.kt 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..c940b161c3d7 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/AttachmentFullscreenVideoPlayer.kt @@ -0,0 +1,236 @@ +@file:Suppress("DEPRECATION") + +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.Player +import com.google.android.exoplayer2.SimpleExoPlayer +import com.google.android.exoplayer2.source.ProgressiveMediaSource +import com.google.android.exoplayer2.ui.PlayerView +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory +import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory +import com.google.android.exoplayer2.util.Util +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 + if (resolvedUrl == null) 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 - URL is already resolved by VideoUrlResolver + val userAgent = Util.getUserAgent(context, context.packageName) + @Suppress("DEPRECATION") + val httpDataSourceFactory = DefaultHttpDataSourceFactory(userAgent) + val dataSourceFactory = DefaultDataSourceFactory(context, httpDataSourceFactory) + + @Suppress("DEPRECATION") + val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory) + .createMediaSource(resolvedUrl!!.toUri()) + + setMediaSource(mediaSource) + 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 = "Unable to play video", + color = Color.White, + style = androidx.compose.material3.MaterialTheme.typography.titleLarge + ) + Text( + text = "This video cannot be played inline. Please download it to view.", + color = Color.White.copy(alpha = 0.7f), + style = androidx.compose.material3.MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center + ) + Button( + onClick = { + exoPlayer?.stop() + onDownload() + onDismiss() + } + ) { + Text("Download Video") + } + } + } + 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 1b26cd72e4c2..823dc0efd514 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 @@ -83,7 +83,8 @@ fun HEConversationDetailScreen( onClearMessageSendResult: () -> Unit = {}, attachments: List = emptyList(), attachmentActionsListener: AttachmentActionsListener, - onDownloadAttachment: (SupportAttachment) -> Unit = {} + onDownloadAttachment: (SupportAttachment) -> Unit = {}, + videoUrlResolver: org.wordpress.android.support.he.util.VideoUrlResolver? = null ) { val listState = rememberLazyListState() val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -97,6 +98,8 @@ fun HEConversationDetailScreen( // State for fullscreen image preview var previewImageUrl by remember { mutableStateOf(null) } + // State for fullscreen video preview + var previewVideoAttachment by remember { mutableStateOf(null) } // Scroll to bottom when conversation changes or new messages arrive LaunchedEffect(conversation.messages.size) { @@ -155,6 +158,7 @@ fun HEConversationDetailScreen( message = message, timestamp = formatRelativeTime(message.createdAt, resources), onPreviewImage = { attachment -> previewImageUrl = attachment.url }, + onPreviewVideo = { attachment -> previewVideoAttachment = attachment }, onDownloadAttachment = onDownloadAttachment ) } @@ -218,6 +222,18 @@ fun HEConversationDetailScreen( } ) } + + // Show fullscreen video player when a video attachment is tapped + previewVideoAttachment?.let { videoAttachment -> + AttachmentFullscreenVideoPlayer( + videoUrl = videoAttachment.url, + onDismiss = { previewVideoAttachment = null }, + onDownload = { + onDownloadAttachment(videoAttachment) + }, + videoUrlResolver = videoUrlResolver + ) + } } @Composable @@ -294,6 +310,7 @@ private fun MessageItem( message: SupportMessage, timestamp: String, onPreviewImage: (SupportAttachment) -> Unit, + onPreviewVideo: (SupportAttachment) -> Unit, onDownloadAttachment: (SupportAttachment) -> Unit ) { val messageDescription = "${message.authorName}, $timestamp. ${message.formattedText}" @@ -355,6 +372,7 @@ private fun MessageItem( AttachmentsList( attachments = message.attachments, onPreviewImage = onPreviewImage, + onPreviewVideo = onPreviewVideo, onDownloadAttachment = onDownloadAttachment ) } @@ -366,6 +384,7 @@ private fun MessageItem( private fun AttachmentsList( attachments: List, onPreviewImage: (SupportAttachment) -> Unit, + onPreviewVideo: (SupportAttachment) -> Unit, onDownloadAttachment: (SupportAttachment) -> Unit ) { FlowRow( @@ -376,10 +395,10 @@ private fun AttachmentsList( AttachmentItem( attachment = attachment, onClick = { - if (attachment.type == AttachmentType.Image) { - onPreviewImage(attachment) - } else { - onDownloadAttachment(attachment) + when (attachment.type) { + AttachmentType.Image -> onPreviewImage(attachment) + AttachmentType.Video -> onPreviewVideo(attachment) + else -> onDownloadAttachment(attachment) } } ) 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..8d7eb215bbf1 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 @@ -179,6 +179,7 @@ class HESupportActivity : AppCompatActivity() { isLoading = isLoadingConversation, isSendingMessage = isSendingMessage, messageSendResult = messageSendResult, + videoUrlResolver = viewModel.videoUrlResolver, onBackClick = { viewModel.onBackClick() }, onSendMessage = { message, includeAppLogs -> viewModel.onAddMessageToConversation( diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportViewModel.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportViewModel.kt index c7a906fc1c72..841e974b5ae6 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportViewModel.kt @@ -26,6 +26,7 @@ class HESupportViewModel @Inject constructor( private val heSupportRepository: HESupportRepository, @Named(IO_THREAD) private val ioDispatcher: CoroutineDispatcher, private val tempAttachmentsUtil: TempAttachmentsUtil, + val videoUrlResolver: org.wordpress.android.support.he.util.VideoUrlResolver, accountStore: AccountStore, appLogWrapper: AppLogWrapper, networkUtilsWrapper: NetworkUtilsWrapper, 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..db3607a9b7ad --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/support/he/util/VideoUrlResolver.kt @@ -0,0 +1,75 @@ +package org.wordpress.android.support.he.util + +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import javax.inject.Inject + +/** + * 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() { + companion object { + private const val TAG = "VideoUrlResolver" + } + + /** + * 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 + */ + suspend fun resolveUrl(url: String): String = withContext(Dispatchers.IO) { + try { + var redirectCount = 0 + val client = OkHttpClient.Builder() + .followRedirects(true) + .followSslRedirects(true) + .addNetworkInterceptor { chain -> + val request = chain.request() + val response = chain.proceed(request) + if (response.isRedirect) { + redirectCount++ + Log.d(TAG, "Redirect #$redirectCount: ${request.url} -> ${response.header("Location")}") + } + response + } + .build() + + val request = Request.Builder() + .url(url) + .get() + .header("Range", "bytes=0-0") // Request only first byte to minimize data transfer + .build() + + Log.d(TAG, "Resolving URL: $url") + + client.newCall(request).execute().use { response -> + val finalUrl = response.request.url.toString() + + when { + response.isSuccessful || response.code == 206 -> { + Log.d(TAG, "Successfully resolved URL after $redirectCount redirects: $finalUrl") + finalUrl + } + finalUrl != url -> { + // Even if response isn't successful, use the final URL if it's different + Log.w(TAG, "Using final URL despite response code ${response.code}: $finalUrl") + finalUrl + } + else -> { + Log.e(TAG, "Failed to resolve URL. Response code: ${response.code}") + url + } + } + } + } catch (e: Exception) { + Log.e(TAG, "Error resolving URL: $url", e) + url + } + } +} From 514d9bee226b68f929787b68e0c9c73946ed5fbb Mon Sep 17 00:00:00 2001 From: adalpari Date: Wed, 5 Nov 2025 14:41:08 +0100 Subject: [PATCH 3/7] Some refactor --- .../he/ui/HEConversationDetailScreen.kt | 72 +++++++++---------- 1 file changed, 33 insertions(+), 39 deletions(-) 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 823dc0efd514..b96db01e7dcc 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 @@ -96,10 +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 video preview - var previewVideoAttachment 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) { @@ -157,8 +155,7 @@ fun HEConversationDetailScreen( MessageItem( message = message, timestamp = formatRelativeTime(message.createdAt, resources), - onPreviewImage = { attachment -> previewImageUrl = attachment.url }, - onPreviewVideo = { attachment -> previewVideoAttachment = attachment }, + onPreviewAttachment = { attachment -> previewAttachment = attachment }, onDownloadAttachment = onDownloadAttachment ) } @@ -207,32 +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) + } + ) } - ) - } - - // Show fullscreen video player when a video attachment is tapped - previewVideoAttachment?.let { videoAttachment -> - AttachmentFullscreenVideoPlayer( - videoUrl = videoAttachment.url, - onDismiss = { previewVideoAttachment = null }, - onDownload = { - onDownloadAttachment(videoAttachment) - }, - videoUrlResolver = videoUrlResolver - ) + 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 + } + } } } @@ -309,8 +307,7 @@ private fun ConversationTitleCard(title: String) { private fun MessageItem( message: SupportMessage, timestamp: String, - onPreviewImage: (SupportAttachment) -> Unit, - onPreviewVideo: (SupportAttachment) -> Unit, + onPreviewAttachment: (SupportAttachment) -> Unit, onDownloadAttachment: (SupportAttachment) -> Unit ) { val messageDescription = "${message.authorName}, $timestamp. ${message.formattedText}" @@ -371,8 +368,7 @@ private fun MessageItem( Spacer(modifier = Modifier.height(12.dp)) AttachmentsList( attachments = message.attachments, - onPreviewImage = onPreviewImage, - onPreviewVideo = onPreviewVideo, + onPreviewAttachment = onPreviewAttachment, onDownloadAttachment = onDownloadAttachment ) } @@ -383,8 +379,7 @@ private fun MessageItem( @Composable private fun AttachmentsList( attachments: List, - onPreviewImage: (SupportAttachment) -> Unit, - onPreviewVideo: (SupportAttachment) -> Unit, + onPreviewAttachment: (SupportAttachment) -> Unit, onDownloadAttachment: (SupportAttachment) -> Unit ) { FlowRow( @@ -396,8 +391,7 @@ private fun AttachmentsList( attachment = attachment, onClick = { when (attachment.type) { - AttachmentType.Image -> onPreviewImage(attachment) - AttachmentType.Video -> onPreviewVideo(attachment) + AttachmentType.Image, AttachmentType.Video -> onPreviewAttachment(attachment) else -> onDownloadAttachment(attachment) } } From f2f8f1d1707762713d6b40dcdbb5451f756488f9 Mon Sep 17 00:00:00 2001 From: adalpari Date: Wed, 5 Nov 2025 16:09:43 +0100 Subject: [PATCH 4/7] Removing deprecated code --- .../he/ui/AttachmentFullscreenVideoPlayer.kt | 21 ++++--------------- 1 file changed, 4 insertions(+), 17 deletions(-) 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 index c940b161c3d7..8608d1fc355a 100644 --- 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 @@ -1,5 +1,3 @@ -@file:Suppress("DEPRECATION") - package org.wordpress.android.support.he.ui import android.view.ViewGroup @@ -39,13 +37,10 @@ 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.source.ProgressiveMediaSource import com.google.android.exoplayer2.ui.PlayerView -import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory -import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory -import com.google.android.exoplayer2.util.Util import org.wordpress.android.R import org.wordpress.android.support.he.util.VideoUrlResolver @@ -83,17 +78,9 @@ fun AttachmentFullscreenVideoPlayer( } }) - // Simple configuration - URL is already resolved by VideoUrlResolver - val userAgent = Util.getUserAgent(context, context.packageName) - @Suppress("DEPRECATION") - val httpDataSourceFactory = DefaultHttpDataSourceFactory(userAgent) - val dataSourceFactory = DefaultDataSourceFactory(context, httpDataSourceFactory) - - @Suppress("DEPRECATION") - val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory) - .createMediaSource(resolvedUrl!!.toUri()) - - setMediaSource(mediaSource) + // Simple configuration using MediaItem + val mediaItem = MediaItem.fromUri(resolvedUrl!!.toUri()) + setMediaItem(mediaItem) prepare() playWhenReady = true repeatMode = Player.REPEAT_MODE_OFF From 525fa989bff45e456b461d0c5fd09ffe96c8bfdd Mon Sep 17 00:00:00 2001 From: adalpari Date: Wed, 5 Nov 2025 16:34:19 +0100 Subject: [PATCH 5/7] detekt --- .../support/he/util/VideoUrlResolver.kt | 28 +++++++------------ 1 file changed, 10 insertions(+), 18 deletions(-) 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 index db3607a9b7ad..137ffad54d99 100644 --- 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 @@ -1,41 +1,38 @@ package org.wordpress.android.support.he.util -import android.util.Log 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 javax.inject.Inject +private const val SUCESSFULLY_RESOLVED_CODE = 206 + /** * 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() { - companion object { - private const val TAG = "VideoUrlResolver" - } - +class VideoUrlResolver @Inject constructor( + private val appLogWrapper: AppLogWrapper +) { /** * 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 { - var redirectCount = 0 val client = OkHttpClient.Builder() .followRedirects(true) .followSslRedirects(true) .addNetworkInterceptor { chain -> val request = chain.request() val response = chain.proceed(request) - if (response.isRedirect) { - redirectCount++ - Log.d(TAG, "Redirect #$redirectCount: ${request.url} -> ${response.header("Location")}") - } response } .build() @@ -46,29 +43,24 @@ class VideoUrlResolver @Inject constructor() { .header("Range", "bytes=0-0") // Request only first byte to minimize data transfer .build() - Log.d(TAG, "Resolving URL: $url") - client.newCall(request).execute().use { response -> val finalUrl = response.request.url.toString() when { - response.isSuccessful || response.code == 206 -> { - Log.d(TAG, "Successfully resolved URL after $redirectCount redirects: $finalUrl") + response.isSuccessful || response.code == SUCESSFULLY_RESOLVED_CODE -> { finalUrl } finalUrl != url -> { // Even if response isn't successful, use the final URL if it's different - Log.w(TAG, "Using final URL despite response code ${response.code}: $finalUrl") finalUrl } else -> { - Log.e(TAG, "Failed to resolve URL. Response code: ${response.code}") url } } } } catch (e: Exception) { - Log.e(TAG, "Error resolving URL: $url", e) + appLogWrapper.e(AppLog.T.UTILS, "Error resolving support url: ${e.stackTraceToString()}",) url } } From 82c93589a4afcbbff8416ad32fdcdd6132ff8f24 Mon Sep 17 00:00:00 2001 From: adalpari Date: Wed, 5 Nov 2025 17:00:23 +0100 Subject: [PATCH 6/7] Moving the video-resolver out of the VM --- .../org/wordpress/android/support/he/ui/HESupportActivity.kt | 4 +++- .../org/wordpress/android/support/he/ui/HESupportViewModel.kt | 1 - 2 files changed, 3 insertions(+), 2 deletions(-) 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 8d7eb215bbf1..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,7 +181,7 @@ class HESupportActivity : AppCompatActivity() { isLoading = isLoadingConversation, isSendingMessage = isSendingMessage, messageSendResult = messageSendResult, - videoUrlResolver = viewModel.videoUrlResolver, + videoUrlResolver = videoUrlResolver, onBackClick = { viewModel.onBackClick() }, onSendMessage = { message, includeAppLogs -> viewModel.onAddMessageToConversation( diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportViewModel.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportViewModel.kt index 841e974b5ae6..c7a906fc1c72 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HESupportViewModel.kt @@ -26,7 +26,6 @@ class HESupportViewModel @Inject constructor( private val heSupportRepository: HESupportRepository, @Named(IO_THREAD) private val ioDispatcher: CoroutineDispatcher, private val tempAttachmentsUtil: TempAttachmentsUtil, - val videoUrlResolver: org.wordpress.android.support.he.util.VideoUrlResolver, accountStore: AccountStore, appLogWrapper: AppLogWrapper, networkUtilsWrapper: NetworkUtilsWrapper, From 2be2aa783409eb2bb52ead1cf6481744b4910545 Mon Sep 17 00:00:00 2001 From: adalpari Date: Wed, 5 Nov 2025 17:27:09 +0100 Subject: [PATCH 7/7] PR suggestions --- .../he/ui/AttachmentFullscreenVideoPlayer.kt | 10 +++---- .../he/ui/HEConversationDetailScreen.kt | 2 +- .../support/he/util/VideoUrlResolver.kt | 27 ++++++++++--------- WordPress/src/main/res/values/strings.xml | 3 +++ 4 files changed, 23 insertions(+), 19 deletions(-) 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 index 8608d1fc355a..78d247990bb8 100644 --- 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 @@ -68,7 +68,7 @@ fun AttachmentFullscreenVideoPlayer( val exoPlayer = remember(resolvedUrl) { // Don't create player until URL is resolved - if (resolvedUrl == null) return@remember null + val url = resolvedUrl ?: return@remember null SimpleExoPlayer.Builder(context).build().apply { // Add error listener @@ -79,7 +79,7 @@ fun AttachmentFullscreenVideoPlayer( }) // Simple configuration using MediaItem - val mediaItem = MediaItem.fromUri(resolvedUrl!!.toUri()) + val mediaItem = MediaItem.fromUri(url.toUri()) setMediaItem(mediaItem) prepare() playWhenReady = true @@ -134,12 +134,12 @@ fun AttachmentFullscreenVideoPlayer( modifier = Modifier.size(64.dp) ) Text( - text = "Unable to play video", + text = stringResource(R.string.he_support_video_playback_error_title), color = Color.White, style = androidx.compose.material3.MaterialTheme.typography.titleLarge ) Text( - text = "This video cannot be played inline. Please download it to view.", + 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 @@ -151,7 +151,7 @@ fun AttachmentFullscreenVideoPlayer( onDismiss() } ) { - Text("Download Video") + Text(stringResource(R.string.he_support_download_video_button)) } } } 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 b96db01e7dcc..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 @@ -464,7 +464,7 @@ private fun AttachmentItem( if (attachment.type == AttachmentType.Video) { Icon( imageVector = Icons.Default.PlayCircle, - contentDescription = null, + contentDescription = stringResource(R.string.photo_picker_thumbnail_desc), modifier = Modifier .align(Alignment.Center) .size(48.dp), 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 index 137ffad54d99..4db12bd8c248 100644 --- 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 @@ -6,9 +6,11 @@ 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 SUCESSFULLY_RESOLVED_CODE = 206 +private const val SUCCESSFULLY_RESOLVED_CODE = 206 +private const val TIMEOUT_SECONDS = 30L /** * Helper class to resolve video URLs that may have redirect chains. @@ -18,6 +20,15 @@ private const val SUCESSFULLY_RESOLVED_CODE = 206 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. * @@ -27,16 +38,6 @@ class VideoUrlResolver @Inject constructor( @Suppress("TooGenericExceptionCaught") suspend fun resolveUrl(url: String): String = withContext(Dispatchers.IO) { try { - val client = OkHttpClient.Builder() - .followRedirects(true) - .followSslRedirects(true) - .addNetworkInterceptor { chain -> - val request = chain.request() - val response = chain.proceed(request) - response - } - .build() - val request = Request.Builder() .url(url) .get() @@ -47,7 +48,7 @@ class VideoUrlResolver @Inject constructor( val finalUrl = response.request.url.toString() when { - response.isSuccessful || response.code == SUCESSFULLY_RESOLVED_CODE -> { + response.isSuccessful || response.code == SUCCESSFULLY_RESOLVED_CODE -> { finalUrl } finalUrl != url -> { @@ -60,7 +61,7 @@ class VideoUrlResolver @Inject constructor( } } } catch (e: Exception) { - appLogWrapper.e(AppLog.T.UTILS, "Error resolving support url: ${e.stackTraceToString()}",) + 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