diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/model/VideoDownloadState.kt b/WordPress/src/main/java/org/wordpress/android/support/he/model/VideoDownloadState.kt new file mode 100644 index 000000000000..5705494a753c --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/support/he/model/VideoDownloadState.kt @@ -0,0 +1,10 @@ +package org.wordpress.android.support.he.model + +import java.io.File + +sealed class VideoDownloadState { + object Idle : VideoDownloadState() + object Downloading : VideoDownloadState() + object Error : VideoDownloadState() + data class Success(val file: File) : VideoDownloadState() +} diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/AttachmentFullscreenImagePreview.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/AttachmentFullscreenImagePreview.kt index 66aa7f362d8a..87df10671c6b 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/AttachmentFullscreenImagePreview.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/AttachmentFullscreenImagePreview.kt @@ -40,18 +40,23 @@ import androidx.compose.ui.window.DialogProperties import coil.compose.SubcomposeAsyncImage import coil.request.ImageRequest import org.wordpress.android.R +import org.wordpress.android.support.he.ui.HESupportActivity.Companion.AUTHORIZATION_TAG import org.wordpress.android.ui.compose.theme.AppThemeM3 @Composable fun AttachmentFullscreenImagePreview( imageUrl: String, + onGetAuthorizationHeaderArgument: () -> String, onDismiss: () -> Unit, - onDownload: () -> Unit = {} + onDownload: () -> Unit = {}, ) { var scale by remember { mutableFloatStateOf(1f) } var offsetX by remember { mutableFloatStateOf(0f) } var offsetY by remember { mutableFloatStateOf(0f) } + // Cache authorization header to avoid repeated function calls during composition + val authorizationHeader = remember { onGetAuthorizationHeaderArgument() } + // Load semantics val loadingImageDescription = stringResource(R.string.he_support_loading_image) val attachmentImageDescription = stringResource(R.string.he_support_attachment_image) @@ -90,6 +95,9 @@ fun AttachmentFullscreenImagePreview( model = ImageRequest.Builder(LocalContext.current) .data(imageUrl) .crossfade(true) + .apply { + addHeader(AUTHORIZATION_TAG, authorizationHeader) + } .build(), contentDescription = attachmentImageDescription, modifier = Modifier @@ -181,6 +189,7 @@ private fun AttachmentFullscreenImagePreviewPreview() { AppThemeM3(isDarkTheme = false) { AttachmentFullscreenImagePreview( imageUrl = "https://via.placeholder.com/800x600", + onGetAuthorizationHeaderArgument = { "" }, onDismiss = { }, onDownload = { } ) @@ -193,6 +202,7 @@ private fun AttachmentFullscreenImagePreviewPreviewDark() { AppThemeM3(isDarkTheme = true) { AttachmentFullscreenImagePreview( imageUrl = "https://via.placeholder.com/800x600", + onGetAuthorizationHeaderArgument = { "" }, onDismiss = { }, onDownload = { } ) 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 78d247990bb8..1718741eeeca 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,7 +1,6 @@ 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 @@ -21,7 +20,7 @@ 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.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -37,49 +36,54 @@ 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 androidx.core.net.toUri 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 +import org.wordpress.android.support.he.model.VideoDownloadState +import org.wordpress.android.util.AppLog +import java.io.File @Composable fun AttachmentFullscreenVideoPlayer( videoUrl: String, onDismiss: () -> Unit, onDownload: () -> Unit = {}, - videoUrlResolver: VideoUrlResolver? = null + downloadState: VideoDownloadState, + onStartVideoDownload: (String) -> Unit, + onResetVideoDownloadState: () -> Unit = {}, ) { val context = LocalContext.current - var hasError by remember { mutableStateOf(false) } - var resolvedUrl by remember { mutableStateOf(null) } - var isResolving by remember { mutableStateOf(true) } + var localVideoFile by remember { mutableStateOf(null) } + + // Start download when composable is first launched + LaunchedEffect(videoUrl) { + onStartVideoDownload(videoUrl) + } - // Resolve URL redirects before playing - androidx.compose.runtime.LaunchedEffect(videoUrl) { - if (videoUrlResolver != null) { - resolvedUrl = videoUrlResolver.resolveUrl(videoUrl) - } else { - resolvedUrl = videoUrl + // Update local file when download succeeds + LaunchedEffect(downloadState) { + if (downloadState is VideoDownloadState.Success) { + localVideoFile = downloadState.file } - isResolving = false } - val exoPlayer = remember(resolvedUrl) { - // Don't create player until URL is resolved - val url = resolvedUrl ?: return@remember null + val exoPlayer = remember(localVideoFile) { + // Don't create player until video is downloaded + val file = localVideoFile ?: return@remember null SimpleExoPlayer.Builder(context).build().apply { - // Add error listener + // Add error listener for logging addListener(object : Player.EventListener { override fun onPlayerError(error: com.google.android.exoplayer2.ExoPlaybackException) { - hasError = true + AppLog.e(AppLog.T.SUPPORT, "Video playback error", error) } }) - // Simple configuration using MediaItem - val mediaItem = MediaItem.fromUri(url.toUri()) + // Play from local file + val mediaItem = MediaItem.fromUri(file.toUri()) setMediaItem(mediaItem) prepare() playWhenReady = true @@ -87,17 +91,9 @@ fun AttachmentFullscreenVideoPlayer( } } - DisposableEffect(Unit) { - onDispose { - exoPlayer?.stop() - exoPlayer?.release() - } - } - Dialog( onDismissRequest = { - exoPlayer?.stop() - onDismiss() + closeFullScreen(exoPlayer, onDismiss, onResetVideoDownloadState) }, properties = DialogProperties( usePlatformDefaultWidth = false, @@ -110,15 +106,16 @@ fun AttachmentFullscreenVideoPlayer( .fillMaxSize() .background(Color.Black) ) { - when { - isResolving -> { - // Show loading indicator while resolving URL + when (downloadState) { + is VideoDownloadState.Idle, + is VideoDownloadState.Downloading -> { + // Show loading indicator while downloading video CircularProgressIndicator( modifier = Modifier.align(Alignment.Center), color = Color.White ) } - hasError -> { + is VideoDownloadState.Error -> { // Show error message when video fails to load Column( modifier = Modifier @@ -146,17 +143,16 @@ fun AttachmentFullscreenVideoPlayer( ) Button( onClick = { - exoPlayer?.stop() onDownload() - onDismiss() + closeFullScreen(exoPlayer, onDismiss, onResetVideoDownloadState) } ) { Text(stringResource(R.string.he_support_download_video_button)) } } } - else -> { - // Show video player when URL is resolved and no error + is VideoDownloadState.Success -> { + // Show video player when video is downloaded successfully exoPlayer?.let { player -> AndroidView( factory = { ctx -> @@ -190,9 +186,8 @@ fun AttachmentFullscreenVideoPlayer( // Download button IconButton( onClick = { - exoPlayer?.stop() - onDownload.invoke() - onDismiss.invoke() + onDownload() + closeFullScreen(exoPlayer, onDismiss, onResetVideoDownloadState) } ) { Icon( @@ -206,8 +201,7 @@ fun AttachmentFullscreenVideoPlayer( // Close button IconButton( onClick = { - exoPlayer?.stop() - onDismiss() + closeFullScreen(exoPlayer, onDismiss, onResetVideoDownloadState) } ) { Icon( @@ -221,3 +215,14 @@ fun AttachmentFullscreenVideoPlayer( } } } + +fun closeFullScreen( + exoPlayer: SimpleExoPlayer?, + onDismiss: () -> Unit, + onResetVideoDownloadState: () -> Unit, +) { + exoPlayer?.stop() + exoPlayer?.release() + onDismiss() + onResetVideoDownloadState() +} 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 bf3a8dd4e731..3e0a551df042 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 @@ -67,6 +67,8 @@ 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 +import org.wordpress.android.support.he.model.VideoDownloadState +import org.wordpress.android.support.he.ui.HESupportActivity.Companion.AUTHORIZATION_TAG import org.wordpress.android.support.he.util.AttachmentActionsListener import org.wordpress.android.support.he.util.generateSampleHESupportConversations import org.wordpress.android.ui.compose.components.MainTopAppBar @@ -87,7 +89,10 @@ fun HEConversationDetailScreen( attachmentState: AttachmentState = AttachmentState(), attachmentActionsListener: AttachmentActionsListener, onDownloadAttachment: (SupportAttachment) -> Unit = {}, - videoUrlResolver: org.wordpress.android.support.he.util.VideoUrlResolver? = null + onGetAuthorizationHeaderArgument: () -> String, + videoDownloadState: VideoDownloadState, + onStartVideoDownload: (String) -> Unit, + onResetVideoDownloadState: () -> Unit = {}, ) { val listState = rememberLazyListState() val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -165,7 +170,8 @@ fun HEConversationDetailScreen( message = message, timestamp = formatRelativeTime(message.createdAt, resources), onPreviewAttachment = { attachment -> previewAttachment = attachment }, - onDownloadAttachment = onDownloadAttachment + onDownloadAttachment = onDownloadAttachment, + onGetAuthorizationHeaderArgument = onGetAuthorizationHeaderArgument ) } @@ -237,6 +243,7 @@ fun HEConversationDetailScreen( AttachmentType.Image -> { AttachmentFullscreenImagePreview( imageUrl = attachment.url, + onGetAuthorizationHeaderArgument = onGetAuthorizationHeaderArgument, onDismiss = { previewAttachment = null }, onDownload = { onDownloadAttachment(attachment) @@ -246,11 +253,15 @@ fun HEConversationDetailScreen( AttachmentType.Video -> { AttachmentFullscreenVideoPlayer( videoUrl = attachment.url, - onDismiss = { previewAttachment = null }, + downloadState = videoDownloadState, + onStartVideoDownload = onStartVideoDownload, + onResetVideoDownloadState = onResetVideoDownloadState, + onDismiss = { + previewAttachment = null + }, onDownload = { onDownloadAttachment(attachment) }, - videoUrlResolver = videoUrlResolver ) } else -> { @@ -357,7 +368,8 @@ private fun MessageItem( message: SupportMessage, timestamp: String, onPreviewAttachment: (SupportAttachment) -> Unit, - onDownloadAttachment: (SupportAttachment) -> Unit + onDownloadAttachment: (SupportAttachment) -> Unit, + onGetAuthorizationHeaderArgument: () -> String, ) { val messageDescription = "${message.authorName}, $timestamp. ${message.formattedText}" @@ -418,7 +430,8 @@ private fun MessageItem( AttachmentsList( attachments = message.attachments, onPreviewAttachment = onPreviewAttachment, - onDownloadAttachment = onDownloadAttachment + onDownloadAttachment = onDownloadAttachment, + onGetAuthorizationHeaderArgument = onGetAuthorizationHeaderArgument ) } } @@ -429,7 +442,8 @@ private fun MessageItem( private fun AttachmentsList( attachments: List, onPreviewAttachment: (SupportAttachment) -> Unit, - onDownloadAttachment: (SupportAttachment) -> Unit + onDownloadAttachment: (SupportAttachment) -> Unit, + onGetAuthorizationHeaderArgument: () -> String, ) { FlowRow( horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -443,7 +457,8 @@ private fun AttachmentsList( AttachmentType.Image, AttachmentType.Video -> onPreviewAttachment(attachment) else -> onDownloadAttachment(attachment) } - } + }, + onGetAuthorizationHeaderArgument = onGetAuthorizationHeaderArgument ) } } @@ -452,8 +467,12 @@ private fun AttachmentsList( @Composable private fun AttachmentItem( attachment: SupportAttachment, - onClick: () -> Unit + onClick: () -> Unit, + onGetAuthorizationHeaderArgument: () -> String, ) { + // Cache authorization header to avoid repeated function calls during composition + val authorizationHeader = remember { onGetAuthorizationHeaderArgument() } + val iconRes = when (attachment.type) { AttachmentType.Image -> R.drawable.ic_image_white_24dp AttachmentType.Video -> R.drawable.ic_video_camera_white_24dp @@ -483,6 +502,9 @@ private fun AttachmentItem( videoFrameMillis(0) // Get first frame } } + .apply { + addHeader(AUTHORIZATION_TAG, authorizationHeader) + } .build(), contentDescription = attachment.filename, modifier = Modifier.fillMaxSize(), @@ -586,7 +608,10 @@ private fun HEConversationDetailScreenPreview() { override fun onRemoveImage(uri: Uri) { // stub } - } + }, + onGetAuthorizationHeaderArgument = { "" }, + videoDownloadState = VideoDownloadState.Idle, + onStartVideoDownload = { _ -> }, ) } } @@ -610,7 +635,10 @@ private fun HEConversationDetailScreenPreviewDark() { override fun onRemoveImage(uri: Uri) { // stub } - } + }, + onGetAuthorizationHeaderArgument = { "" }, + videoDownloadState = VideoDownloadState.Idle, + onStartVideoDownload = { _ -> }, ) } } @@ -636,7 +664,10 @@ private fun HEConversationDetailScreenWordPressPreview() { override fun onRemoveImage(uri: Uri) { // stub } - } + }, + onGetAuthorizationHeaderArgument = { "" }, + videoDownloadState = VideoDownloadState.Idle, + onStartVideoDownload = { _ -> }, ) } } @@ -661,7 +692,10 @@ private fun HEConversationDetailScreenPreviewWordPressDark() { override fun onRemoveImage(uri: Uri) { // stub } - } + }, + onGetAuthorizationHeaderArgument = { "" }, + videoDownloadState = VideoDownloadState.Idle, + onStartVideoDownload = { _ -> }, ) } } 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 9cf20c3691e0..8fc43a3c0a60 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 @@ -28,10 +28,10 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import org.wordpress.android.ui.compose.theme.AppThemeM3 import org.wordpress.android.R +import org.wordpress.android.fluxc.store.AccountStore 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 @@ -43,7 +43,7 @@ import javax.inject.Inject class HESupportActivity : AppCompatActivity() { @Inject lateinit var fileDownloadManager: ReaderFileDownloadManager @Inject lateinit var appLogWrapper: AppLogWrapper - @Inject lateinit var videoUrlResolver: VideoUrlResolver + @Inject lateinit var accountStore: AccountStore private val viewModel by viewModels() private lateinit var composeView: ComposeView @@ -86,6 +86,12 @@ class HESupportActivity : AppCompatActivity() { viewModel.init() } + override fun onDestroy() { + super.onDestroy() + // Cleanup cached video files + viewModel.cleanupVideoCache() + } + private fun observeNavigationEvents() { lifecycleScope.launch { @@ -174,6 +180,7 @@ class HESupportActivity : AppCompatActivity() { val isSendingMessage by viewModel.isSendingMessage.collectAsState() val messageSendResult by viewModel.messageSendResult.collectAsState() val attachmentState by viewModel.attachmentState.collectAsState() + val videoDownloadState by viewModel.videoDownloadState.collectAsState() selectedConversation?.let { conversation -> HEConversationDetailScreen( @@ -182,7 +189,6 @@ class HESupportActivity : AppCompatActivity() { isLoading = isLoadingConversation, isSendingMessage = isSendingMessage, messageSendResult = messageSendResult, - videoUrlResolver = videoUrlResolver, onBackClick = { viewModel.onBackClick() }, onSendMessage = { message, includeAppLogs -> viewModel.onAddMessageToConversation( @@ -205,7 +211,13 @@ class HESupportActivity : AppCompatActivity() { } // Start download with proper filename fileDownloadManager.downloadFile(attachment.url, attachment.filename) - } + }, + onGetAuthorizationHeaderArgument = { viewModel.getAuthorizationHeader() }, + videoDownloadState = videoDownloadState, + onStartVideoDownload = { url -> + viewModel.downloadVideoToTempFile(url) + }, + onResetVideoDownloadState = { viewModel.resetVideoDownloadState() } ) } } @@ -276,6 +288,8 @@ class HESupportActivity : AppCompatActivity() { companion object { + const val AUTHORIZATION_TAG = "Authorization" + @JvmStatic fun createIntent(context: Context): Intent = Intent(context, HESupportActivity::class.java) } 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 7f684fa45a8e..a44f610d7a04 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 @@ -17,11 +17,13 @@ import org.wordpress.android.support.common.ui.ConversationsSupportViewModel import org.wordpress.android.support.he.model.AttachmentState import org.wordpress.android.support.he.model.MessageSendResult import org.wordpress.android.support.he.model.SupportConversation +import org.wordpress.android.support.he.model.VideoDownloadState import org.wordpress.android.support.he.repository.CreateConversationResult import org.wordpress.android.support.he.repository.HESupportRepository import org.wordpress.android.support.he.util.TempAttachmentsUtil import org.wordpress.android.util.AppLog import org.wordpress.android.util.NetworkUtilsWrapper +import java.io.File import javax.inject.Inject import javax.inject.Named @@ -37,6 +39,7 @@ class HESupportViewModel @Inject constructor( ) : ConversationsSupportViewModel(accountStore, appLogWrapper, networkUtilsWrapper) { companion object { const val MAX_TOTAL_SIZE_BYTES = 20L * 1024 * 1024 // 20MB total + private const val BEARER_TAG = "Bearer" } private val _isSendingMessage = MutableStateFlow(false) val isSendingMessage: StateFlow = _isSendingMessage.asStateFlow() @@ -48,6 +51,14 @@ class HESupportViewModel @Inject constructor( private val _attachmentState = MutableStateFlow(AttachmentState()) val attachmentState: StateFlow = _attachmentState.asStateFlow() + // Cache for downloaded video file paths (videoUrl -> file path) + // Stores paths instead of File objects to minimize memory footprint + private val videoCache = mutableMapOf() + + // Video download state + private val _videoDownloadState = MutableStateFlow(VideoDownloadState.Idle) + val videoDownloadState: StateFlow = _videoDownloadState.asStateFlow() + override fun initRepository(accessToken: String) { heSupportRepository.init(accessToken) } @@ -275,4 +286,76 @@ class HESupportViewModel @Inject constructor( fun notifyGeneralError() { _errorMessage.value = ErrorType.GENERAL } + + /** + * Downloads a video to a temporary file with caching and state management. + * Updates videoDownloadState as it progresses. + */ + @Suppress("TooGenericExceptionCaught") + fun downloadVideoToTempFile(videoUrl: String) { + viewModelScope.launch(ioDispatcher) { + try { + // Check cache first (before setting state to avoid unnecessary state changes) + videoCache[videoUrl]?.let { cachedFilePath -> + val cachedFile = File(cachedFilePath) + if (cachedFile.exists()) { + AppLog.d(AppLog.T.SUPPORT, "Using cached video file for: $videoUrl") + _videoDownloadState.value = VideoDownloadState.Success(cachedFile) + return@launch + } else { + // File was deleted, remove from cache + videoCache.remove(videoUrl) + } + } + + // Start downloading + _videoDownloadState.value = VideoDownloadState.Downloading + AppLog.d(AppLog.T.SUPPORT, "Downloading video to temp file: $videoUrl") + val tempFile = tempAttachmentsUtil.createVideoTempFile(videoUrl) + if (tempFile == null) { + _videoDownloadState.value = VideoDownloadState.Error + } else { + // Cache the downloaded file path + videoCache[videoUrl] = tempFile.absolutePath + _videoDownloadState.value = VideoDownloadState.Success(tempFile) + } + } catch (e: Exception) { + AppLog.e(AppLog.T.SUPPORT, "Error downloading video", e) + _videoDownloadState.value = VideoDownloadState.Error + } + } + } + + /** + * Resets the video download state to Idle. Call this when closing the video player. + */ + fun resetVideoDownloadState() { + _videoDownloadState.value = VideoDownloadState.Idle + } + + /** + * Cleans up all cached video files. Call this when the activity is destroyed. + */ + fun cleanupVideoCache() { + AppLog.d(AppLog.T.SUPPORT, "Cleaning up ${videoCache.size} cached video files") + videoCache.values.forEach { filePath -> + val file = File(filePath) + if (file.exists()) { + AppLog.d(AppLog.T.SUPPORT, "Deleting temp video file: $filePath") + file.delete() + } + } + videoCache.clear() + } + + fun getAuthorizationHeader():String = "$BEARER_TAG ${accountStore.accessToken}" + + /** + * Called when the ViewModel is destroyed. Ensures video cache cleanup even if Activity + * onDestroy() is not called (e.g., process death). + */ + override fun onCleared() { + super.onCleared() + cleanupVideoCache() + } } diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/util/TempAttachmentsUtil.kt b/WordPress/src/main/java/org/wordpress/android/support/he/util/TempAttachmentsUtil.kt index 9278d1d89412..454fdf66513f 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/util/TempAttachmentsUtil.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/util/TempAttachmentsUtil.kt @@ -4,10 +4,13 @@ import android.app.Application import android.net.Uri import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext +import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.modules.IO_THREAD import org.wordpress.android.util.AppLog import java.io.File +import java.net.HttpURLConnection +import java.net.URL import javax.inject.Inject import javax.inject.Named import kotlin.collections.forEach @@ -16,7 +19,13 @@ class TempAttachmentsUtil @Inject constructor( @Named(IO_THREAD) private val ioDispatcher: CoroutineDispatcher, private val appLogWrapper: AppLogWrapper, private val application: Application, + private val accountStore: AccountStore ) { + companion object { + private const val CONNECTION_TIMEOUT_MS = 30_000 // 30 seconds + private const val READ_TIMEOUT_MS = 60_000 // 60 seconds + } + @Suppress("TooGenericExceptionCaught") suspend fun createTempFilesFrom(uris: List): List = withContext(ioDispatcher) { uris.map{ it.toTempFile() } @@ -84,4 +93,48 @@ class TempAttachmentsUtil @Inject constructor( // Default to jpg if we can't determine the extension return "jpg" } + + @Suppress("TooGenericExceptionCaught") + suspend fun createVideoTempFile(videoUrl: String): File? = withContext(ioDispatcher) { + var tempFile: File? = null + var connection: HttpURLConnection? = null + + try { + tempFile = File.createTempFile("video_", ".mp4", application.cacheDir) + connection = (URL(videoUrl).openConnection() as HttpURLConnection).apply { + requestMethod = "GET" + setRequestProperty("Authorization", "Bearer ${accountStore.accessToken}") + instanceFollowRedirects = true + connectTimeout = CONNECTION_TIMEOUT_MS + readTimeout = READ_TIMEOUT_MS + } + + connection.connect() + + val responseCode = connection.responseCode + AppLog.d(AppLog.T.SUPPORT, "Download response code: $responseCode") + + if (responseCode == HttpURLConnection.HTTP_OK) { + connection.inputStream.use { input -> + tempFile.outputStream().use { output -> + input.copyTo(output) + } + } + + AppLog.d(AppLog.T.SUPPORT, "Video downloaded: ${tempFile.absolutePath}") + tempFile + } else { + val deleted = tempFile?.delete() + AppLog.e(AppLog.T.SUPPORT, "Failed to download video. Deleted: ${deleted} - " + + "Response code: $responseCode") + null + } + } catch (e: Exception) { + val deleted = tempFile?.delete() + AppLog.e(AppLog.T.SUPPORT, "Error downloading video: ${e.message} Deleted: ${deleted}") + null + } finally { + connection?.disconnect() + } + } } 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 deleted file mode 100644 index 4db12bd8c248..000000000000 --- a/WordPress/src/main/java/org/wordpress/android/support/he/util/VideoUrlResolver.kt +++ /dev/null @@ -1,68 +0,0 @@ -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/test/java/org/wordpress/android/support/he/ui/HESupportViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/support/he/ui/HESupportViewModelTest.kt index 4e5b3a743f69..b076c085b95f 100644 --- a/WordPress/src/test/java/org/wordpress/android/support/he/ui/HESupportViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/support/he/ui/HESupportViewModelTest.kt @@ -958,6 +958,290 @@ class HESupportViewModelTest : BaseUnitTest() { // endregion + // region Video download tests + + @Test + fun `downloadVideoToTempFile sets state to Downloading when starting download`() = test { + val videoUrl = "https://example.com/video.mp4" + val tempFile = java.io.File.createTempFile("test_video", ".mp4") + + whenever(tempAttachmentsUtil.createVideoTempFile(videoUrl)) + .thenReturn(tempFile) + + viewModel.downloadVideoToTempFile(videoUrl) + advanceUntilIdle() + + // State should transition through Idle -> Downloading -> Success + assertThat(viewModel.videoDownloadState.value) + .isInstanceOf(org.wordpress.android.support.he.model.VideoDownloadState.Success::class.java) + + tempFile.delete() + } + + @Test + fun `downloadVideoToTempFile sets state to Success with file when download succeeds`() = test { + val videoUrl = "https://example.com/video.mp4" + val tempFile = java.io.File.createTempFile("test_video", ".mp4") + + whenever(tempAttachmentsUtil.createVideoTempFile(videoUrl)) + .thenReturn(tempFile) + + viewModel.downloadVideoToTempFile(videoUrl) + advanceUntilIdle() + + val state = viewModel.videoDownloadState.value + assertThat(state).isInstanceOf(org.wordpress.android.support.he.model.VideoDownloadState.Success::class.java) + assertThat((state as org.wordpress.android.support.he.model.VideoDownloadState.Success).file) + .isEqualTo(tempFile) + + tempFile.delete() + } + + @Test + fun `downloadVideoToTempFile sets state to Error when download fails`() = test { + val videoUrl = "https://example.com/video.mp4" + + whenever(tempAttachmentsUtil.createVideoTempFile(videoUrl)) + .thenReturn(null) + + viewModel.downloadVideoToTempFile(videoUrl) + advanceUntilIdle() + + assertThat(viewModel.videoDownloadState.value) + .isInstanceOf(org.wordpress.android.support.he.model.VideoDownloadState.Error::class.java) + } + + @Test + fun `downloadVideoToTempFile sets state to Error when exception occurs`() = test { + val videoUrl = "https://example.com/video.mp4" + + whenever(tempAttachmentsUtil.createVideoTempFile(videoUrl)) + .thenThrow(RuntimeException("Network error")) + + viewModel.downloadVideoToTempFile(videoUrl) + advanceUntilIdle() + + assertThat(viewModel.videoDownloadState.value) + .isInstanceOf(org.wordpress.android.support.he.model.VideoDownloadState.Error::class.java) + } + + @Test + fun `downloadVideoToTempFile returns cached file when available`() = test { + val videoUrl = "https://example.com/video.mp4" + val tempFile = java.io.File.createTempFile("test_video", ".mp4") + + whenever(tempAttachmentsUtil.createVideoTempFile(videoUrl)) + .thenReturn(tempFile) + + // First download + viewModel.downloadVideoToTempFile(videoUrl) + advanceUntilIdle() + + // Second download should use cache + viewModel.downloadVideoToTempFile(videoUrl) + advanceUntilIdle() + + // Should only call createVideoTempFile once + verify(tempAttachmentsUtil, org.mockito.kotlin.times(1)) + .createVideoTempFile(videoUrl) + + val state = viewModel.videoDownloadState.value + assertThat(state).isInstanceOf(org.wordpress.android.support.he.model.VideoDownloadState.Success::class.java) + assertThat((state as org.wordpress.android.support.he.model.VideoDownloadState.Success).file) + .isEqualTo(tempFile) + + tempFile.delete() + } + + @Test + fun `downloadVideoToTempFile removes deleted cached file and re-downloads`() = test { + val videoUrl = "https://example.com/video.mp4" + val tempFile1 = java.io.File.createTempFile("test_video1", ".mp4") + val tempFile2 = java.io.File.createTempFile("test_video2", ".mp4") + + whenever(tempAttachmentsUtil.createVideoTempFile(videoUrl)) + .thenReturn(tempFile1) + .thenReturn(tempFile2) + + // First download + viewModel.downloadVideoToTempFile(videoUrl) + advanceUntilIdle() + + // Delete the cached file + tempFile1.delete() + + // Second download should detect deleted file and re-download + viewModel.downloadVideoToTempFile(videoUrl) + advanceUntilIdle() + + // Should call createVideoTempFile twice + verify(tempAttachmentsUtil, org.mockito.kotlin.times(2)) + .createVideoTempFile(videoUrl) + + val state = viewModel.videoDownloadState.value + assertThat(state).isInstanceOf(org.wordpress.android.support.he.model.VideoDownloadState.Success::class.java) + assertThat((state as org.wordpress.android.support.he.model.VideoDownloadState.Success).file) + .isEqualTo(tempFile2) + + tempFile2.delete() + } + + @Test + fun `downloadVideoToTempFile caches multiple different videos`() = test { + val videoUrl1 = "https://example.com/video1.mp4" + val videoUrl2 = "https://example.com/video2.mp4" + val tempFile1 = java.io.File.createTempFile("test_video1", ".mp4") + val tempFile2 = java.io.File.createTempFile("test_video2", ".mp4") + + whenever(tempAttachmentsUtil.createVideoTempFile(videoUrl1)) + .thenReturn(tempFile1) + whenever(tempAttachmentsUtil.createVideoTempFile(videoUrl2)) + .thenReturn(tempFile2) + + // Download first video + viewModel.downloadVideoToTempFile(videoUrl1) + advanceUntilIdle() + + // Download second video + viewModel.downloadVideoToTempFile(videoUrl2) + advanceUntilIdle() + + // Download first video again - should use cache + viewModel.downloadVideoToTempFile(videoUrl1) + advanceUntilIdle() + + // Should only call createVideoTempFile once per unique URL + verify(tempAttachmentsUtil, org.mockito.kotlin.times(1)) + .createVideoTempFile(videoUrl1) + verify(tempAttachmentsUtil, org.mockito.kotlin.times(1)) + .createVideoTempFile(videoUrl2) + + tempFile1.delete() + tempFile2.delete() + } + + @Test + fun `resetVideoDownloadState sets state to Idle`() = test { + val videoUrl = "https://example.com/video.mp4" + val tempFile = java.io.File.createTempFile("test_video", ".mp4") + + whenever(tempAttachmentsUtil.createVideoTempFile(videoUrl)) + .thenReturn(tempFile) + + // Download video to set state to Success + viewModel.downloadVideoToTempFile(videoUrl) + advanceUntilIdle() + + assertThat(viewModel.videoDownloadState.value) + .isInstanceOf(org.wordpress.android.support.he.model.VideoDownloadState.Success::class.java) + + // Reset state + viewModel.resetVideoDownloadState() + + assertThat(viewModel.videoDownloadState.value) + .isInstanceOf(org.wordpress.android.support.he.model.VideoDownloadState.Idle::class.java) + + tempFile.delete() + } + + @Test + fun `cleanupVideoCache deletes all cached video files`() = test { + val videoUrl1 = "https://example.com/video1.mp4" + val videoUrl2 = "https://example.com/video2.mp4" + val tempFile1 = java.io.File.createTempFile("test_video1", ".mp4") + val tempFile2 = java.io.File.createTempFile("test_video2", ".mp4") + + // Create actual temp files that exist + tempFile1.writeText("test content 1") + tempFile2.writeText("test content 2") + + whenever(tempAttachmentsUtil.createVideoTempFile(videoUrl1)) + .thenReturn(tempFile1) + whenever(tempAttachmentsUtil.createVideoTempFile(videoUrl2)) + .thenReturn(tempFile2) + + // Download both videos to cache them + viewModel.downloadVideoToTempFile(videoUrl1) + advanceUntilIdle() + viewModel.downloadVideoToTempFile(videoUrl2) + advanceUntilIdle() + + assertThat(tempFile1.exists()).isTrue + assertThat(tempFile2.exists()).isTrue + + // Cleanup cache + viewModel.cleanupVideoCache() + + assertThat(tempFile1.exists()).isFalse + assertThat(tempFile2.exists()).isFalse + } + + @Test + fun `cleanupVideoCache clears cache map`() = test { + val videoUrl = "https://example.com/video.mp4" + val tempFile = java.io.File.createTempFile("test_video", ".mp4") + + whenever(tempAttachmentsUtil.createVideoTempFile(videoUrl)) + .thenReturn(tempFile) + + // Download video to cache it + viewModel.downloadVideoToTempFile(videoUrl) + advanceUntilIdle() + + // Cleanup cache + viewModel.cleanupVideoCache() + + // Try to download again - should call createVideoTempFile again (not use cache) + val tempFile2 = java.io.File.createTempFile("test_video2", ".mp4") + whenever(tempAttachmentsUtil.createVideoTempFile(videoUrl)) + .thenReturn(tempFile2) + + viewModel.downloadVideoToTempFile(videoUrl) + advanceUntilIdle() + + verify(tempAttachmentsUtil, org.mockito.kotlin.times(2)) + .createVideoTempFile(videoUrl) + + tempFile2.delete() + } + + @Test + fun `cleanupVideoCache handles non-existent files gracefully`() = test { + val videoUrl = "https://example.com/video.mp4" + val tempFile = java.io.File.createTempFile("test_video", ".mp4") + + whenever(tempAttachmentsUtil.createVideoTempFile(videoUrl)) + .thenReturn(tempFile) + + // Download video to cache it + viewModel.downloadVideoToTempFile(videoUrl) + advanceUntilIdle() + + // Manually delete the file before cleanup + tempFile.delete() + assertThat(tempFile.exists()).isFalse + + // Cleanup should not throw exception + viewModel.cleanupVideoCache() + } + + @Test + fun `getAuthorizationHeader returns Bearer token format`() { + val expectedHeader = "Bearer $testAccessToken" + + val actualHeader = viewModel.getAuthorizationHeader() + + assertThat(actualHeader).isEqualTo(expectedHeader) + } + + @Test + fun `videoDownloadState is Idle initially`() { + assertThat(viewModel.videoDownloadState.value) + .isInstanceOf(org.wordpress.android.support.he.model.VideoDownloadState.Idle::class.java) + } + + // endregion + // Helper functions private fun createTestConversation( id: Long,