diff --git a/.github/workflows/pr-pipeline.yml b/.github/workflows/pr-pipeline.yml index 15e1b9aa5c..fccf6cda79 100644 --- a/.github/workflows/pr-pipeline.yml +++ b/.github/workflows/pr-pipeline.yml @@ -4,7 +4,9 @@ on: pull_request: types: [opened, synchronize, labeled] branches-ignore: - - 'release/**' + - 'release/student' + - 'release/teacher' + - 'release/parent' concurrency: group: ${{ github.head_ref || github.run_id }} diff --git a/apps/student/build.gradle b/apps/student/build.gradle index 84db855b0c..6c1bc2deca 100644 --- a/apps/student/build.gradle +++ b/apps/student/build.gradle @@ -38,8 +38,8 @@ android { applicationId "com.instructure.candroid" minSdkVersion Versions.MIN_SDK targetSdkVersion Versions.TARGET_SDK - versionCode = 283 - versionName = '8.4.0' + versionCode = 284 + versionName = '8.4.1' vectorDrawables.useSupportLibrary = true testInstrumentationRunner 'com.instructure.student.espresso.StudentHiltTestRunner' diff --git a/apps/student/src/main/java/com/instructure/student/activity/BaseRouterActivity.kt b/apps/student/src/main/java/com/instructure/student/activity/BaseRouterActivity.kt index 54d92ece0f..141e0026ed 100644 --- a/apps/student/src/main/java/com/instructure/student/activity/BaseRouterActivity.kt +++ b/apps/student/src/main/java/com/instructure/student/activity/BaseRouterActivity.kt @@ -39,7 +39,6 @@ import com.instructure.pandautils.models.PushNotification import com.instructure.pandautils.receivers.PushExternalReceiver import com.instructure.pandautils.utils.Const import com.instructure.pandautils.utils.LoaderUtils -import com.instructure.pandautils.utils.RouteUtils import com.instructure.pandautils.utils.toast import com.instructure.student.R import com.instructure.student.fragment.InternalWebviewFragment @@ -317,8 +316,7 @@ abstract class BaseRouterActivity : CallbackActivity(), FullScreenInteractions { } private suspend fun shouldOpenInternally(url: String): Boolean { - val mediaUrl = RouteUtils.getMediaUri(Uri.parse(url)).toString() - return (mediaUrl.endsWith(".mpd") || mediaUrl.endsWith(".m3u8") || mediaUrl.endsWith(".mp4")) + return (url.endsWith(".mpd") || url.endsWith(".m3u8") || url.endsWith(".mp4")) } override fun onDestroy() { diff --git a/apps/student/src/main/java/com/instructure/student/activity/VideoViewActivity.kt b/apps/student/src/main/java/com/instructure/student/activity/VideoViewActivity.kt index bf58280176..8780cfab70 100644 --- a/apps/student/src/main/java/com/instructure/student/activity/VideoViewActivity.kt +++ b/apps/student/src/main/java/com/instructure/student/activity/VideoViewActivity.kt @@ -22,7 +22,6 @@ import android.net.Uri import android.os.Bundle import android.os.Handler import androidx.annotation.OptIn -import androidx.lifecycle.lifecycleScope import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.util.UnstableApi @@ -49,12 +48,10 @@ import com.instructure.pandautils.analytics.SCREEN_VIEW_VIDEO_VIEW import com.instructure.pandautils.analytics.ScreenView import com.instructure.pandautils.base.BaseCanvasActivity import com.instructure.pandautils.binding.viewBinding -import com.instructure.pandautils.utils.RouteUtils import com.instructure.pandautils.utils.ThemePrefs import com.instructure.pandautils.utils.ViewStyler import com.instructure.student.databinding.ActivityVideoViewBinding import com.instructure.student.util.Const -import kotlinx.coroutines.launch @OptIn(UnstableApi::class) @ScreenView(SCREEN_VIEW_VIDEO_VIEW) @@ -85,17 +82,14 @@ class VideoViewActivity : BaseCanvasActivity() { } private fun fetchMediaUri(uri: Uri) { - lifecycleScope.launch { - val mediaUri = RouteUtils.getMediaUri(uri) - player = ExoPlayer.Builder(this@VideoViewActivity) - .setTrackSelector(trackSelector) - .setLoadControl(DefaultLoadControl()) - .build() - binding.playerView.player = player - player?.playWhenReady = true - player?.setMediaSource(buildMediaSource(mediaUri)) - player?.prepare() - } + player = ExoPlayer.Builder(this@VideoViewActivity) + .setTrackSelector(trackSelector) + .setLoadControl(DefaultLoadControl()) + .build() + binding.playerView.player = player + player?.playWhenReady = true + player?.setMediaSource(buildMediaSource(uri)) + player?.prepare() } private fun buildMediaSource(uri: Uri): MediaSource { diff --git a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/content/MediaSubmissionViewFragment.kt b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/content/MediaSubmissionViewFragment.kt index 81201f736a..b8506fb4c7 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/content/MediaSubmissionViewFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/content/MediaSubmissionViewFragment.kt @@ -22,7 +22,6 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.annotation.OptIn -import androidx.lifecycle.lifecycleScope import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.HttpDataSource import androidx.media3.exoplayer.source.UnrecognizedInputFormatException @@ -38,7 +37,6 @@ import com.instructure.pandautils.utils.ExoAgentState import com.instructure.pandautils.utils.ExoInfoListener import com.instructure.pandautils.utils.NullableStringArg import com.instructure.pandautils.utils.ParcelableArg -import com.instructure.pandautils.utils.RouteUtils import com.instructure.pandautils.utils.StringArg import com.instructure.pandautils.utils.ViewStyler import com.instructure.pandautils.utils.onClick @@ -49,7 +47,6 @@ import com.instructure.student.R import com.instructure.student.databinding.FragmentMediaSubmissionViewBinding import com.instructure.student.mobius.assignmentDetails.submissionDetails.SubmissionDetailsContentType import com.instructure.student.router.RouteMatcher -import kotlinx.coroutines.launch @OptIn(UnstableApi::class) class MediaSubmissionViewFragment : BaseCanvasFragment() { @@ -115,11 +112,9 @@ class MediaSubmissionViewFragment : BaseCanvasFragment() { } private fun fetchMediaUri() { - lifecycleScope.launch { - mediaUri = RouteUtils.getMediaUri(uri) - if (isResumed) { - attachMediaPlayer() - } + mediaUri = uri + if (isResumed) { + attachMediaPlayer() } } diff --git a/apps/teacher/src/main/java/com/instructure/teacher/fragments/ViewMediaFragment.kt b/apps/teacher/src/main/java/com/instructure/teacher/fragments/ViewMediaFragment.kt index caa5545e69..547ed991c2 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/fragments/ViewMediaFragment.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/fragments/ViewMediaFragment.kt @@ -24,7 +24,6 @@ import android.view.ViewGroup import android.widget.ImageButton import androidx.annotation.OptIn import androidx.appcompat.widget.Toolbar -import androidx.lifecycle.lifecycleScope import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.HttpDataSource import androidx.media3.exoplayer.source.UnrecognizedInputFormatException @@ -51,7 +50,6 @@ import com.instructure.pandautils.utils.IntArg import com.instructure.pandautils.utils.NullableParcelableArg import com.instructure.pandautils.utils.NullableStringArg import com.instructure.pandautils.utils.ParcelableArg -import com.instructure.pandautils.utils.RouteUtils import com.instructure.pandautils.utils.StringArg import com.instructure.pandautils.utils.Utils import com.instructure.pandautils.utils.ViewStyler @@ -65,7 +63,6 @@ import com.instructure.teacher.router.RouteMatcher import com.instructure.teacher.utils.setupBackButtonWithExpandCollapseAndBack import com.instructure.teacher.utils.setupMenu import com.instructure.teacher.utils.updateToolbarExpandCollapseIcon -import kotlinx.coroutines.launch import org.greenrobot.eventbus.EventBus @ScreenView(SCREEN_VIEW_VIEW_MEDIA) @@ -143,11 +140,9 @@ class ViewMediaFragment : BaseCanvasFragment(), ShareableFile { } private fun fetchMediaUri() { - lifecycleScope.launch { - mediaUri = RouteUtils.getMediaUri(uri) - if (isResumed) { - attachMediaPlayer() - } + mediaUri = uri + if (isResumed) { + attachMediaPlayer() } } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/activities/BaseViewMediaActivity.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/activities/BaseViewMediaActivity.kt index 07732b7aaf..7ef9ed7db0 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/activities/BaseViewMediaActivity.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/activities/BaseViewMediaActivity.kt @@ -29,7 +29,6 @@ import android.widget.LinearLayout import androidx.annotation.OptIn import androidx.appcompat.widget.Toolbar import androidx.core.content.ContextCompat -import androidx.lifecycle.lifecycleScope import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.HttpDataSource import androidx.media3.exoplayer.source.UnrecognizedInputFormatException @@ -46,7 +45,6 @@ import com.instructure.pandautils.utils.ExoAgentState import com.instructure.pandautils.utils.ExoInfoListener import com.instructure.pandautils.utils.FileFolderDeletedEvent import com.instructure.pandautils.utils.FileFolderUpdatedEvent -import com.instructure.pandautils.utils.RouteUtils import com.instructure.pandautils.utils.Utils import com.instructure.pandautils.utils.ViewStyler import com.instructure.pandautils.utils.onClick @@ -55,7 +53,6 @@ import com.instructure.pandautils.utils.setMenu import com.instructure.pandautils.utils.setVisible import com.instructure.pandautils.utils.setupAsCloseButton import com.instructure.pandautils.utils.viewExternally -import kotlinx.coroutines.launch import org.greenrobot.eventbus.EventBus import java.io.File @@ -92,10 +89,8 @@ abstract class BaseViewMediaActivity : BaseCanvasActivity() { mediaProgressBar.announceForAccessibility(getString(R.string.loading)) mediaProgressBar.setVisible() } - lifecycleScope.launch { - mediaUri = RouteUtils.getMediaUri(mUri) - attachMediaPlayer() - } + mediaUri = mUri + attachMediaPlayer() } private fun attachMediaPlayer() = with(binding) { diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/loaders/OpenMediaAsyncTaskLoader.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/loaders/OpenMediaAsyncTaskLoader.kt index 36db97aece..e80a2f341e 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/loaders/OpenMediaAsyncTaskLoader.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/loaders/OpenMediaAsyncTaskLoader.kt @@ -30,7 +30,6 @@ import com.instructure.canvasapi2.CanvasRestAdapter.Companion.okHttpClient import com.instructure.canvasapi2.builders.RestParams import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.utils.ApiPrefs -import com.instructure.canvasapi2.utils.HttpHelper.redirectURL import com.instructure.canvasapi2.utils.isValid import com.instructure.pandautils.R import com.instructure.pandautils.loaders.OpenMediaAsyncTaskLoader.LoadedMedia @@ -44,9 +43,7 @@ import okio.buffer import okio.sink import java.io.File import java.io.IOException -import java.net.HttpURLConnection import java.net.MalformedURLException -import java.net.URL import java.net.URLConnection import java.util.regex.Pattern @@ -82,6 +79,12 @@ class OpenMediaAsyncTaskLoader(context: Context, args: Bundle?) : AsyncTaskLoade } + data class DownloadResult( + val file: File, + val mimeType: String?, + val filename: String? + ) + private var mimeType: String? = null var url: String = "" var path: String = "" @@ -150,20 +153,21 @@ class OpenMediaAsyncTaskLoader(context: Context, args: Bundle?) : AsyncTaskLoade loadedMedia.setHtmlBundle(bundle) } else { loadedMedia.isHtmlFile = isHtmlFile - val uri = if (url.isNotBlank()) { - attemptConnection(url) + if (url.isNotBlank()) { + // For remote URLs, we'll get the URI and metadata during or after download + val uri = Uri.parse(url) + intent.setDataAndType(uri, mimeType) + loadedMedia.intent = intent + if (extras != null) loadedMedia.bundle = extras + attemptDownloadFile(context, intent, loadedMedia, url, filename) } else { val file = File(path) - FileProvider.getUriForFile(context, context.applicationContext.packageName + Const.FILE_PROVIDER_AUTHORITY, file) - } - if (uri != null) { + val uri = FileProvider.getUriForFile(context, context.applicationContext.packageName + Const.FILE_PROVIDER_AUTHORITY, file) intent.setDataAndType(uri, mimeType) loadedMedia.intent = intent if (extras != null) loadedMedia.bundle = extras Log.d(Const.OPEN_MEDIA_ASYNC_TASK_LOADER_LOG, "Intent can be handled: " + isIntentHandledByActivity(intent)) attemptDownloadFile(context, intent, loadedMedia, url, filename) - } else { - loadedMedia.errorMessage = R.string.noDataConnection } } } catch (e: MalformedURLException) { @@ -188,67 +192,51 @@ class OpenMediaAsyncTaskLoader(context: Context, args: Bundle?) : AsyncTaskLoade private val isHtmlFile: Boolean get() = filename?.endsWith(".htm", true) == true || filename?.endsWith(".html", true) == true - /** - * @return Uri if there's a connection, returns null otherwise - */ - @Throws(IOException::class) - private fun attemptConnection(url: String): Uri? { - var uri: Uri? = null - val hc = URL(url).openConnection() as HttpURLConnection - val connection = redirectURL(hc) - val connectedUrl = connection.url.toString() - // When only the url is specified in the bundle arguments, mimeType and filename are null or empty. - if (!mimeType.isValid()) { - mimeType = connection.contentType // Gets content type from headers - if (mimeType == null) { - // Gets content type from url query param - mimeType = Uri.parse(url).getQueryParameter("content_type") - } - if (mimeType == null) throw IOException() - } - Log.d(Const.OPEN_MEDIA_ASYNC_TASK_LOADER_LOG, "mimeType: $mimeType") - if (!filename.isValid()) { - // parse filename from Content-Disposition header which is a response field that is normally used to set the file name - val headerField = connection.getHeaderField("Content-Disposition") - if (headerField != null) { - filename = parseFilename(headerField) - filename = makeFilenameUnique(filename, url) - } else { - filename = "" + url.hashCode() - } - } - if (connectedUrl.isValid()) { - uri = Uri.parse(connectedUrl) - if ("binary/octet-stream".equals(mimeType, true) || "*/*".equals(mimeType, true)) { - val guessedMimeType = URLConnection.guessContentTypeFromName(uri.path) - Log.d(Const.OPEN_MEDIA_ASYNC_TASK_LOADER_LOG, "guess mimeType: $guessedMimeType") - if (guessedMimeType.isValid()) { - mimeType = guessedMimeType - } - } - } - connection.disconnect() - return uri - } - - private fun downloadFile(context: Context, url: String, filename: String?): File { + private fun downloadFile(context: Context, url: String, filenameParam: String?): File { // They have to download the content first... gross. // Download it if the file doesn't exist in the external cache Log.d(Const.OPEN_MEDIA_ASYNC_TASK_LOADER_LOG, "downloadFile URL: $url") - val attachmentFile = if (filename?.endsWith(".pdf").orDefault()) { - File(File(context.filesDir, "pdfs-${ApiPrefs.user?.id}"), filename) + + // If we don't have a filename yet, we need to download to get it from headers + // We'll use a temporary approach first + val needsFilenameFromHeaders = !filenameParam.isValid() + + if (needsFilenameFromHeaders) { + // Download and get filename from headers + val result = downloadWithHeaders(context, url, null) + // Update instance variables with values from headers + if (!mimeType.isValid() && result.mimeType != null) { + mimeType = result.mimeType + } + if (!filename.isValid() && result.filename != null) { + filename = result.filename + } + return result.file } else { - File(getAttachmentsDirectory(context), filename) - } - Log.d(Const.OPEN_MEDIA_ASYNC_TASK_LOADER_LOG, "File: $attachmentFile") - if (!attachmentFile.exists()) { - // Download the content from the url - if (writeAttachmentsDirectoryFromURL(url, attachmentFile)) { - Log.d(Const.OPEN_MEDIA_ASYNC_TASK_LOADER_LOG, "file not cached") + // We have a filename, check if file exists + val attachmentFile = if (filenameParam?.endsWith(".pdf").orDefault()) { + File(File(context.filesDir, "pdfs-${ApiPrefs.user?.id}"), filenameParam) + } else { + File(getAttachmentsDirectory(context), filenameParam) + } + Log.d(Const.OPEN_MEDIA_ASYNC_TASK_LOADER_LOG, "File: $attachmentFile") + + if (!attachmentFile.exists()) { + // Download the file and get headers in the same request + val result = downloadWithHeaders(context, url, attachmentFile) + // Update mimeType if we didn't have it + if (!mimeType.isValid() && result.mimeType != null) { + mimeType = result.mimeType + } + return result.file + } else { + // File exists, ensure we have mimeType + if (!mimeType.isValid()) { + mimeType = guessMimeTypeFromFilename(attachmentFile.name) + } return attachmentFile } } - return attachmentFile } private fun isIntentHandledByActivity(intent: Intent): Boolean { @@ -286,7 +274,7 @@ class OpenMediaAsyncTaskLoader(context: Context, args: Bundle?) : AsyncTaskLoade @Throws(IOException::class) @Suppress("BooleanLiteralArgument") - private fun writeAttachmentsDirectoryFromURL(url: String, toWriteTo: File): Boolean { + private fun downloadWithHeaders(context: Context, url: String, targetFile: File?): DownloadResult { val client = okHttpClient.newBuilder().cache(null).build() val params = RestParams(null, null, "/api/v1/", false, true, false, false, null) val requestBuilder = Request.Builder().url(url).tag(params) @@ -294,24 +282,101 @@ class OpenMediaAsyncTaskLoader(context: Context, args: Bundle?) : AsyncTaskLoade if (cookie.isValid()) requestBuilder.addHeader("Cookie", cookie) val request = requestBuilder.build() val response = client.newCall(request).execute() - if (!response.isSuccessful) { - response.body?.close() - throw IOException("Unable to download. Error code ${response.code}") + + return response.use { resp -> + if (!resp.isSuccessful) { + throw IOException("Unable to download. Error code ${resp.code}") + } + + // Extract headers from response (headers are available before reading the body) + var responseMimeType = resp.header("Content-Type") + var responseFilename: String? = null + + // Parse filename from Content-Disposition header + val contentDisposition = resp.header("Content-Disposition") + if (contentDisposition != null) { + responseFilename = parseFilename(contentDisposition) + } + + // If we still don't have a filename, generate one from URL + if (responseFilename == null) { + responseFilename = url.hashCode().toString() + } + + // Make filename unique if we have the necessary info + responseFilename = makeFilenameUnique(responseFilename, url, fileId) + + // Determine the actual file to write to + val toWriteTo = targetFile ?: run { + if (responseFilename.endsWith(".pdf", ignoreCase = true)) { + File(File(context.filesDir, "pdfs-${ApiPrefs.user?.id}"), responseFilename) + } else { + File(getAttachmentsDirectory(context), responseFilename) + } + } + + // Check if file already exists in cache + if (toWriteTo.exists() && toWriteTo.length() > 0) { + Log.d(Const.OPEN_MEDIA_ASYNC_TASK_LOADER_LOG, "File found in cache, skipping download: $toWriteTo") + + // If mimeType is generic, try to guess from filename + if (responseMimeType == "binary/octet-stream" || responseMimeType == "*/*") { + val guessedMimeType = guessMimeTypeFromFilename(responseFilename) + if (guessedMimeType != null) { + responseMimeType = guessedMimeType + } + } + + // Response will be closed automatically by use() when we return + return@use DownloadResult( + file = toWriteTo, + mimeType = responseMimeType, + filename = responseFilename + ) + } + + // File not in cache, proceed with download + Log.d(Const.OPEN_MEDIA_ASYNC_TASK_LOADER_LOG, "Downloading file with mimeType: $responseMimeType, filename: $responseFilename") + + toWriteTo.parentFile?.mkdirs() + + resp.body!!.use { body -> + val sink = toWriteTo.sink().buffer() + sink.use { s -> + body.source().use { source -> + s.writeAll(source) + } + } + } + + Log.d(Const.OPEN_MEDIA_ASYNC_TASK_LOADER_LOG, "Download completed: $toWriteTo") + + // If mimeType is generic, try to guess from filename + if (responseMimeType == "binary/octet-stream" || responseMimeType == "*/*") { + val guessedMimeType = guessMimeTypeFromFilename(responseFilename) + if (guessedMimeType != null) { + responseMimeType = guessedMimeType + } + } + + DownloadResult( + file = toWriteTo, + mimeType = responseMimeType, + filename = responseFilename + ) } - toWriteTo.parentFile.mkdirs() - val sink = toWriteTo.sink().buffer() - val source: Source = response.body!!.source() - sink.writeAll(source) - sink.flush() - sink.close() - source.close() - return true + } + + private fun guessMimeTypeFromFilename(filename: String): String? { + val guessedMimeType = URLConnection.guessContentTypeFromName(filename) + Log.d(Const.OPEN_MEDIA_ASYNC_TASK_LOADER_LOG, "Guessed mimeType: $guessedMimeType for filename: $filename") + return if (guessedMimeType.isValid()) guessedMimeType else null } companion object { fun parseFilename(headerField: String?): String? { var filename = headerField - val matcher = Pattern.compile("filename=\"(.*)\"").matcher(headerField) + val matcher = Pattern.compile("filename=\"(.*)\"").matcher(headerField ?: "") if (matcher.find()) { filename = matcher.group(1) } @@ -319,7 +384,7 @@ class OpenMediaAsyncTaskLoader(context: Context, args: Bundle?) : AsyncTaskLoade } fun makeFilenameUnique(filename: String?, url: String, fileId: String? = null): String { - val matcher = Pattern.compile("(.*)\\.(.*)").matcher(filename) + val matcher = Pattern.compile("(.*)\\.(.*)").matcher(filename ?: "") return if (matcher.find()) { val actualFilename = matcher.group(1) val fileType = matcher.group(2) @@ -401,4 +466,4 @@ class OpenMediaAsyncTaskLoader(context: Context, args: Bundle?) : AsyncTaskLoade return openMediaBundle } } -} +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/ExoPlayerHelper.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/ExoPlayerHelper.kt index 7182c16604..2fc7aa874b 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/ExoPlayerHelper.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/ExoPlayerHelper.kt @@ -19,6 +19,7 @@ package com.instructure.pandautils.utils import android.net.Uri import android.view.SurfaceView import androidx.annotation.OptIn +import androidx.core.net.toUri import androidx.media3.common.C import androidx.media3.common.C.TRACK_TYPE_VIDEO import androidx.media3.common.MediaItem @@ -38,6 +39,7 @@ import androidx.media3.exoplayer.hls.HlsMediaSource import androidx.media3.exoplayer.smoothstreaming.DefaultSsChunkSource import androidx.media3.exoplayer.smoothstreaming.SsMediaSource import androidx.media3.exoplayer.source.ProgressiveMediaSource +import androidx.media3.exoplayer.source.UnrecognizedInputFormatException import androidx.media3.exoplayer.trackselection.AdaptiveTrackSelection import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.trackselection.TrackSelector @@ -82,16 +84,22 @@ class ExoAgent private constructor(val uri: Uri) { /** Whether the media track is audio only, or false if unknown */ private var mIsAudioOnly = false + /** Whether we've already tried retrying with .mpd extension */ + private var triedMpdRetry = false + + /** The current URI being used (may be modified for DASH retry) */ + private var currentUri: Uri = uri + /** The current state of this agent */ private var currentState = ExoAgentState.IDLE set(value) { mInfoListener?.onStateChanged(value) } - /** The media source that will feed data from the [uri] */ - private val mMediaSource by lazy { - val mediaItem = MediaItem.fromUri(uri) - when (Util.inferContentType(uri)) { + /** Creates a media source for the given URI */ + private fun createMediaSource(sourceUri: Uri) = run { + val mediaItem = MediaItem.fromUri(sourceUri) + when (Util.inferContentType(sourceUri)) { C.CONTENT_TYPE_SS -> SsMediaSource.Factory(DefaultSsChunkSource.Factory(DATA_SOURCE_FACTORY), DATA_SOURCE_FACTORY).createMediaSource(mediaItem) C.CONTENT_TYPE_DASH -> DashMediaSource.Factory(DefaultDashChunkSource.Factory(DATA_SOURCE_FACTORY), DATA_SOURCE_FACTORY).createMediaSource(mediaItem) C.CONTENT_TYPE_HLS -> HlsMediaSource.Factory(DefaultHlsDataSourceFactory(DATA_SOURCE_FACTORY)).createMediaSource(mediaItem) @@ -163,8 +171,26 @@ class ExoAgent private constructor(val uri: Uri) { } override fun onPlayerError(exception: PlaybackException) { - reset() - mInfoListener?.onError(exception.cause) + val cause = exception.cause + + // Check if this might be a DASH video without .mpd extension + if (cause is UnrecognizedInputFormatException && + !triedMpdRetry && + !currentUri.toString().endsWith(".mpd")) { + + // Retry with .mpd appended + triedMpdRetry = true + currentUri = "${currentUri}.mpd".toUri() + + // Reset and retry with the new URI + mPlayer?.release() + mPlayer = null + preparePlayer() + } else { + // Not a retryable error, or already tried retry + reset() + mInfoListener?.onError(cause) + } } override fun onPlaybackStateChanged(playbackState: Int) { @@ -184,7 +210,7 @@ class ExoAgent private constructor(val uri: Uri) { }) mPlayer?.playWhenReady = true - mPlayer?.setMediaSource(mMediaSource) + mPlayer?.setMediaSource(createMediaSource(currentUri)) mPlayer?.prepare() } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/RouteUtils.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/RouteUtils.kt index b5267f1eeb..2b5f757ec5 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/RouteUtils.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/RouteUtils.kt @@ -16,18 +16,10 @@ package com.instructure.pandautils.utils -import android.net.Uri -import com.instructure.canvasapi2.CanvasRestAdapter import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.interactions.router.Route import com.instructure.interactions.router.RouterParams -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import okhttp3.Request -import androidx.core.net.toUri -import com.instructure.canvasapi2.builders.RestParams -import okhttp3.Response object RouteUtils { fun retrieveFileUrl( @@ -50,48 +42,4 @@ object RouteUtils { block.invoke(fileUrl, context, needsAuth) } - - suspend fun getMediaUri(uri: Uri): Uri { - var response: Response? = null - val responseUri = withContext(Dispatchers.IO) { - try { - val client = CanvasRestAdapter.okHttpClient - .newBuilder() - .followRedirects(true) - .cache(null) - .build() - - val request = Request.Builder() - .head() - .url(uri.toString()) - .tag(RestParams(disableFileVerifiers = false)) - .build() - - response = client.newCall(request).execute() - response.use { - var responseUrl = response.request.url.toString().toUri() - if (responseUrl.toString().isEmpty()) { - responseUrl = uri - } - val contentTypeHeader = response.header("content-type") - if (contentTypeHeader != null) { - if (contentTypeHeader.contains("dash") && !responseUrl.toString() - .endsWith(".mpd") - ) { - ("$responseUrl.mpd").toUri() - } else { - responseUrl - } - } else { - responseUrl - } - } - } catch (e: Exception) { - response?.close() - return@withContext uri - } - } - response?.close() - return responseUri - } } diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/unit/RouteUtilsTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/unit/RouteUtilsTest.kt index 37dd4d2582..e18b87cc61 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/unit/RouteUtilsTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/unit/RouteUtilsTest.kt @@ -24,8 +24,13 @@ import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.interactions.router.Route import com.instructure.interactions.router.RouterParams import com.instructure.pandautils.utils.RouteUtils -import io.mockk.* -import kotlinx.coroutines.runBlocking +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic import okhttp3.HttpUrl import okhttp3.OkHttpClient import okhttp3.Request @@ -162,34 +167,4 @@ class RouteUtilsTest : Assert() { assertEquals(CanvasContext.currentUserContext(user), context) } } - - @Test - fun `getMediaUri returns proper dash url if content-type is dash`() = runBlocking { - val result = RouteUtils.getMediaUri(requestUri) - assertEquals(dashUri, result) - } - - @Test - fun `getMediaUri returns responseUri if if content-type is not dash`() = runBlocking { - every { response.header("content-type") } returns "application/mp4" - - val result = RouteUtils.getMediaUri(responseUri) - assertEquals(responseUri, result) - } - - @Test - fun `getMediaUri returns responseUri if if content-type is null`() = runBlocking { - every { response.header("content-type") } returns null - - val result = RouteUtils.getMediaUri(responseUri) - assertEquals(responseUri, result) - } - - @Test - fun `getMediaUri returns original uri on exception`() = runBlocking { - coEvery { call.execute() } throws Exception("Network error") - - val result = RouteUtils.getMediaUri(requestUri) - assertEquals(requestUri, result) - } }