diff --git a/apps/student/src/main/java/com/instructure/student/router/RouteMatcher.kt b/apps/student/src/main/java/com/instructure/student/router/RouteMatcher.kt index ab904846d4..585160c6e7 100644 --- a/apps/student/src/main/java/com/instructure/student/router/RouteMatcher.kt +++ b/apps/student/src/main/java/com/instructure/student/router/RouteMatcher.kt @@ -551,6 +551,36 @@ object RouteMatcher : BaseRouteMatcher() { activity.toast(R.string.route_not_available) return } + + // Check for Studio embed immersive view BEFORE other routing logic + // This prevents it from being caught by the LTI route matcher + Logger.e("RouteMatcher - Checking route: ${route?.uri?.toString()}") + if (route?.uri?.toString()?.contains("external_tools/retrieve") == true && + route.uri?.toString()?.contains("custom_arc_launch_type") == true && + route.uri?.toString()?.contains("immersive_view") == true) { + Logger.e("RouteMatcher - Detected Studio embed immersive view URL in route()") + // Handle Studio embed immersive view - pass the full URL and title to InternalWebviewFragment + val uri = route.uri!! + val urlString = uri.toString() + + route.primaryClass = InternalWebviewFragment::class.java + route.routeContext = RouteContext.INTERNAL + route.arguments.putString(Const.INTERNAL_URL, urlString) + + // Extract title from URL query parameter if present, otherwise use fallback + val title = uri.getQueryParameter("title") ?: activity.getString(R.string.immersiveView) + route.arguments.putString(Const.ACTION_BAR_TITLE, title) + + Logger.e("RouteMatcher - Routing to InternalWebviewFragment with URL: $urlString") + + if (activity.resources.getBoolean(R.bool.isDeviceTablet)) { + handleTabletRoute(activity, route) + } else { + handleFullscreenRoute(activity, route) + } + return + } + if (route == null || route.routeContext == RouteContext.DO_NOT_ROUTE) { if (route?.uri != null) { // No route, no problem diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/HtmlContentFormatter.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/HtmlContentFormatter.kt index 3fff9bd4b9..91586634f8 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/HtmlContentFormatter.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/HtmlContentFormatter.kt @@ -55,8 +55,33 @@ class HtmlContentFormatter( // Snag that src val srcUrl = matcher.group(1) - if (hasExternalTools(srcUrl)) { - // Handle the LTI case + // Check Studio embed URLs first (before generic external_tools check) + // because Studio embeds also contain "external_tools" in their URL + if (hasStudioEmbedUrl(srcUrl)) { + val studioEmbedImprovementsEnabled = courseId?.let { + featureFlagProvider.checkStudioEmbedImprovementsFlag(it) + } ?: false + + if (studioEmbedImprovementsEnabled) { + val videoTitle = extractVideoTitle(iframe) + val immersiveUrl = convertStudioEmbedToImmersiveView(srcUrl, videoTitle) + val newIframe = iframeWithStudioButton(immersiveUrl, iframe, context) + newHTML = newHTML.replace(iframe, newIframe) + } + } else if (hasStudioMediaUrl(srcUrl)) { + // Only check feature flag if we actually have a Studio URL + val studioEmbedImprovementsEnabled = courseId?.let { + featureFlagProvider.checkStudioEmbedImprovementsFlag(it) + } ?: false + + if (studioEmbedImprovementsEnabled) { + val videoTitle = extractVideoTitle(iframe) + val immersiveUrl = convertToImmersiveViewUrl(srcUrl, videoTitle) + val newIframe = iframeWithStudioButton(immersiveUrl, iframe, context) + newHTML = newHTML.replace(iframe, newIframe) + } + } else if (hasExternalTools(srcUrl)) { + // Handle the generic LTI case (after checking for specific Studio types) val newIframe = externalToolIframe(srcUrl, iframe, context) newHTML = newHTML.replace(iframe, newIframe) } else if (iframe.contains("id=\"cnvs_content\"")) { @@ -76,20 +101,6 @@ class HtmlContentFormatter( val newIframe = iframeWithGoogleDocsButton(srcUrl, iframe, context.getString(R.string.openLtiInExternalApp)) newHTML = newHTML.replace(iframe, newIframe) } - - if (hasStudioMediaUrl(srcUrl)) { - // Only check feature flag if we actually have a Studio URL - val studioEmbedImprovementsEnabled = courseId?.let { - featureFlagProvider.checkStudioEmbedImprovementsFlag(it) - } ?: false - - if (studioEmbedImprovementsEnabled) { - val videoTitle = extractVideoTitle(iframe) - val immersiveUrl = convertToImmersiveViewUrl(srcUrl, videoTitle) - val newIframe = iframeWithStudioButton(immersiveUrl, iframe, context) - newHTML = newHTML.replace(iframe, newIframe) - } - } } } @@ -162,7 +173,9 @@ class HtmlContentFormatter( private fun iframeWithStudioButton(immersiveUrl: String, iframe: String, context: Context): String { val buttonText = context.getString(R.string.openInDetailView) - val htmlButton = "
" + val escapedUrl = immersiveUrl.replace("&", "&") + + val htmlButton = "" return iframe + htmlButton } @@ -195,7 +208,7 @@ class HtmlContentFormatter( // We only want to change the urls that are part of an external tool, not everything (like avatars) for (index in 0..matcher.groupCount()) { val newUrl = matcher.group(index) - if (newUrl.contains("external_tools")) { + if (newUrl?.contains("external_tools") == true) { newHTML = html.replace(newUrl, authenticatedSessionUrl) } } @@ -203,11 +216,82 @@ class HtmlContentFormatter( return newHTML } + private fun convertStudioEmbedToImmersiveView(srcUrl: String, title: String?): String { + // Normalize HTML entities before processing + val normalizedUrl = srcUrl.replace("&", "&") + + // Extract the base URL and the encoded url parameter + val urlPattern = Pattern.compile("url=([^&]+)") + val urlMatcher = urlPattern.matcher(normalizedUrl) + + if (urlMatcher.find()) { + val encodedLtiUrl = urlMatcher.group(1) ?: return srcUrl + + // Decode the LTI URL to modify it + var decodedLtiUrl = java.net.URLDecoder.decode(encodedLtiUrl, "UTF-8") + + // Replace launch type with immersive_view + decodedLtiUrl = decodedLtiUrl + .replace("custom_arc_launch_type=thumbnail_embed", "custom_arc_launch_type=immersive_view") + .replace("custom_arc_launch_type=learn_embed", "custom_arc_launch_type=immersive_view") + + // Add source view type based on original embed type + val sourceViewType = when { + encodedLtiUrl.contains("thumbnail_embed") -> "thumbnail_embed" + encodedLtiUrl.contains("learn_embed") -> "learn_embed" + else -> "collaboration_embed" + } + + if (!decodedLtiUrl.contains("custom_arc_source_view_type")) { + decodedLtiUrl += "&custom_arc_source_view_type=$sourceViewType" + } + + // Add platform redirect URL if not present + if (!decodedLtiUrl.contains("platform_redirect_url")) { + val baseCanvasUrl = srcUrl.substringBefore("/external_tools") + val encodedRedirectUrl = URLEncoder.encode(baseCanvasUrl, "UTF-8") + decodedLtiUrl += "&platform_redirect_url=$encodedRedirectUrl" + } + + // Add full_win_launch_requested parameter + if (!decodedLtiUrl.contains("full_win_launch_requested")) { + decodedLtiUrl += "&full_win_launch_requested=1" + } + + // Re-encode the modified LTI URL + val newEncodedLtiUrl = URLEncoder.encode(decodedLtiUrl, "UTF-8") + + // Build the final immersive view URL + val baseUrl = srcUrl.substringBefore("?") + var immersiveUrl = "$baseUrl?display=full_width&url=$newEncodedLtiUrl&placement=course_navigation&embedded=true" + + // Add title if present + if (title != null) { + val encodedTitle = URLEncoder.encode(title, "UTF-8") + immersiveUrl += "&title=$encodedTitle" + } + + return immersiveUrl + } + + return srcUrl + } + companion object { fun hasGoogleDocsUrl(text: String?) = text?.contains("docs.google.com").orDefault() fun hasKalturaUrl(text: String?) = text?.contains("kaltura.com").orDefault() fun hasExternalTools(text: String?) = text?.contains("external_tools").orDefault() fun hasStudioMediaUrl(text: String?) = text?.contains("media_attachments_iframe").orDefault() + fun hasStudioEmbedUrl(text: String?): Boolean { + if (text?.contains("external_tools/retrieve").orDefault().not()) return false + + // Check for thumbnail_embed or learn_embed (excluding collaboration_embed) + // URLs extracted from HTML may still contain & entities + // The launch type parameters are URL-encoded within the nested url parameter + val normalizedText = text?.replace("&", "&") ?: "" + return (normalizedText.contains("custom_arc_launch_type%3Dthumbnail_embed") || + normalizedText.contains("custom_arc_launch_type%3Dlearn_embed")) + } } }