From 9ed20d56e20575192ef62de10185aff0d50bf184 Mon Sep 17 00:00:00 2001
From: Akos Hermann
Date: Thu, 20 Nov 2025 14:48:23 +0100
Subject: [PATCH] [MBL-19552] Add Studio embed immersive view support
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Implements support for Studio embed videos to open in immersive view with
"Open in Detail View" buttons. This provides better video viewing experience
for Studio content embedded in pages and assignments.
Changes:
- Detect Studio embed URLs (thumbnail_embed and learn_embed types)
- Detect Studio media URLs (media_attachments_iframe)
- Transform embed URLs to immersive view format with required parameters
- Add "Open in Detail View" buttons below Studio iframes
- Route immersive view URLs to InternalWebviewFragment without LTI auth
- Extract and pass video titles to immersive view
Known limitation:
- Buttons inside iframes cannot be intercepted due to WebView cross-origin
isolation. Users must use the "Open in Detail View" button below the iframe.
- Working with web team on postMessage solution for better UX.
Test plan:
- Verify Studio embed iframes show "Open in Detail View" button
- Verify button navigates to immersive view in InternalWebviewFragment
- Verify video title displays correctly in immersive view
- Verify standard media uploads continue to work with Expand button
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
---
.../student/router/RouteMatcher.kt | 30 +++++
.../pandautils/utils/HtmlContentFormatter.kt | 120 +++++++++++++++---
2 files changed, 132 insertions(+), 18 deletions(-)
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 = "$buttonText
"
+ val escapedUrl = immersiveUrl.replace("&", "&")
+
+ val htmlButton = "$buttonText
"
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"))
+ }
}
}