Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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\"")) {
Expand All @@ -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)
}
}
}
}

Expand Down Expand Up @@ -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 = "</br><p><div class=\"lti_button\" onClick=\"location.href='$immersiveUrl'\">$buttonText</div></p>"
val escapedUrl = immersiveUrl.replace("&", "&amp;")

val htmlButton = "</br><p><div class=\"lti_button\" onClick=\"location.href='$escapedUrl'\">$buttonText</div></p>"
return iframe + htmlButton
}

Expand Down Expand Up @@ -195,19 +208,90 @@ 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)
}
}
}
return newHTML
}

private fun convertStudioEmbedToImmersiveView(srcUrl: String, title: String?): String {
// Normalize HTML entities before processing
val normalizedUrl = srcUrl.replace("&amp;", "&")

// 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 &amp; entities
// The launch type parameters are URL-encoded within the nested url parameter
val normalizedText = text?.replace("&amp;", "&") ?: ""
return (normalizedText.contains("custom_arc_launch_type%3Dthumbnail_embed") ||
normalizedText.contains("custom_arc_launch_type%3Dlearn_embed"))
}
}
}

Expand Down
Loading