diff --git a/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/APIFeatureFlagState.swift b/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/APIFeatureFlagState.swift
new file mode 100644
index 0000000000..3929439eb2
--- /dev/null
+++ b/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/APIFeatureFlagState.swift
@@ -0,0 +1,66 @@
+//
+// This file is part of Canvas.
+// Copyright (C) 2025-present Instructure, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+//
+
+import Foundation
+
+public struct APIFeatureFlagState: Codable {
+
+ public enum State: String, Codable {
+ /// (valid only for account context) The feature is `off` in the account, but may be enabled in sub-accounts and courses by setting a feature flag `on` the sub-account or course.
+ case allowed
+ /// (valid only for account context) The feature is `on` in the account, but may be disabled in sub-accounts and courses by setting a feature flag `off` the sub-account or course.
+ case allowed_on
+ /// The feature is turned `on` unconditionally for the user, course, or account and sub-accounts.
+ case on
+ /// The feature is not available for the course, user, or account and sub-accounts.
+ case off
+ }
+
+ public let feature: String
+ public let state: State
+ public let locked: Bool
+
+ private let context_id: String
+ private let context_type: String
+
+ init(feature: String, state: State, locked: Bool, context_id: String, context_type: String) {
+ self.feature = feature
+ self.state = state
+ self.locked = locked
+ self.context_id = context_id
+ self.context_type = context_type
+ }
+
+ public var contextType: ContextType? {
+ return ContextType(rawValue: context_type.lowercased())
+ }
+
+ public var canvasContextID: String {
+ return "\(context_type.lowercased())_\(context_id)"
+ }
+
+ public func overriden(state: State, context: Context) -> Self {
+ APIFeatureFlagState(
+ feature: feature,
+ state: state,
+ locked: locked,
+ context_id: context.id,
+ context_type: context.contextType.rawValue
+ )
+ }
+}
diff --git a/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/FeatureFlag.swift b/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/FeatureFlag.swift
index 7329f418ce..7363054cc9 100644
--- a/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/FeatureFlag.swift
+++ b/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/FeatureFlag.swift
@@ -20,9 +20,6 @@ import Foundation
import CoreData
public struct APIFeatureFlag {
- public enum Key: String {
- case assignmentEnhancements = "assignments_2_student"
- }
public let key: String
public let isEnabled: Bool
public let canvasContextID: String
@@ -55,10 +52,24 @@ public final class FeatureFlag: NSManagedObject, WriteableModel {
flag.isEnvironmentFlag = item.isEnvironmentFlag
return flag
}
+
+ @discardableResult
+ public static func save(_ item: APIFeatureFlagState, in context: NSManagedObjectContext) -> FeatureFlag {
+ let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
+ NSPredicate(format: "%K == %@", #keyPath(FeatureFlag.canvasContextID), item.canvasContextID),
+ NSPredicate(format: "%K == %@", #keyPath(FeatureFlag.name), item.feature)
+ ])
+ let flag: FeatureFlag = context.fetch(predicate).first ?? context.insert()
+ flag.name = item.feature
+ flag.enabled = item.state == .on
+ flag.canvasContextID = item.canvasContextID
+ flag.isEnvironmentFlag = false
+ return flag
+ }
}
extension Collection where Element == FeatureFlag {
- public func isFeatureFlagEnabled(_ key: APIFeatureFlag.Key) -> Bool {
+ public func isFeatureFlagEnabled(_ key: FeatureFlagName) -> Bool {
isFeatureFlagEnabled(name: key.rawValue)
}
diff --git a/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetEnabledFeatureFlags.swift b/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetEnabledFeatureFlags.swift
index 6a39f31088..09280a943d 100644
--- a/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetEnabledFeatureFlags.swift
+++ b/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetEnabledFeatureFlags.swift
@@ -64,7 +64,7 @@ public class GetEnabledFeatureFlags: CollectionUseCase {
}
extension Store where U == GetEnabledFeatureFlags {
- public func isFeatureFlagEnabled(_ key: APIFeatureFlag.Key) -> Bool {
+ public func isFeatureFlagEnabled(_ key: FeatureFlagName) -> Bool {
all.isFeatureFlagEnabled(key)
}
}
diff --git a/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagState.swift b/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagState.swift
new file mode 100644
index 0000000000..04ea7fbe7e
--- /dev/null
+++ b/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagState.swift
@@ -0,0 +1,69 @@
+//
+// This file is part of Canvas.
+// Copyright (C) 2025-present Instructure, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+//
+
+import Foundation
+import CoreData
+
+public class GetFeatureFlagState: CollectionUseCase {
+ public typealias Model = FeatureFlag
+
+ public let featureName: FeatureFlagName
+ public let context: Context
+
+ public var scope: Scope {
+ let contextPredicate = NSPredicate(format: "%K == %@", #keyPath(FeatureFlag.canvasContextID), context.canvasContextID)
+ let namePredicate = NSPredicate(format: "%K == %@", #keyPath(FeatureFlag.name), featureName.rawValue)
+ let predicate = NSCompoundPredicate(
+ andPredicateWithSubpredicates: [
+ contextPredicate,
+ namePredicate
+ ]
+ )
+ return Scope(predicate: predicate, order: [NSSortDescriptor(key: #keyPath(FeatureFlag.name), ascending: true)])
+ }
+
+ public var cacheKey: String? {
+ return "get-\(context.canvasContextID)-\(featureName.rawValue)-feature-flag-state"
+ }
+
+ public var request: GetFeatureFlagStateRequest {
+ return GetFeatureFlagStateRequest(featureName: featureName, context: context)
+ }
+
+ public init(featureName: FeatureFlagName, context: Context) {
+ self.featureName = featureName
+ self.context = context
+ }
+
+ public func write(response: APIFeatureFlagState?, urlResponse: URLResponse?, to client: NSManagedObjectContext) {
+ guard var item = response else { return }
+
+ /// This as workaround for an API limitation, where
+ /// requesting feature state of a course context,
+ /// if not set on that course, would return the feature state of
+ /// the most higher level (which is the account)
+ if item.contextType != context.contextType && item.contextType == .account {
+ item = item.overriden(
+ state: item.state == .allowed_on ? .on : item.state,
+ context: context
+ )
+ }
+
+ FeatureFlag.save(item, in: client)
+ }
+}
diff --git a/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagStateRequest.swift b/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagStateRequest.swift
new file mode 100644
index 0000000000..7b4ad2989f
--- /dev/null
+++ b/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagStateRequest.swift
@@ -0,0 +1,43 @@
+//
+// This file is part of Canvas.
+// Copyright (C) 2025-present Instructure, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+//
+
+import Foundation
+
+// https://canvas.instructure.com/doc/api/feature_flags.html#method.feature_flags.show
+public struct GetFeatureFlagStateRequest: APIRequestable {
+ public typealias Response = APIFeatureFlagState
+
+ public let context: Context
+ public let featureName: FeatureFlagName
+
+ public var path: String {
+ return "\(context.pathComponent)/features/flags/\(featureName.rawValue)"
+ }
+
+ public init(featureName: FeatureFlagName, context: Context) {
+ self.featureName = featureName
+ self.context = context
+ }
+}
+
+// MARK: - Parameters
+
+public enum FeatureFlagName: String {
+ case assignmentEnhancements = "assignments_2_student"
+ case studioEmbedImprovements = "rce_studio_embed_improvements"
+}
diff --git a/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractor.swift b/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractor.swift
new file mode 100644
index 0000000000..7a99bc2f2b
--- /dev/null
+++ b/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractor.swift
@@ -0,0 +1,202 @@
+//
+// This file is part of Canvas.
+// Copyright (C) 2025-present Instructure, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+//
+
+import Combine
+import WebKit
+
+public class CoreWebViewStudioFeaturesInteractor {
+ private static let scanFramesScript = """
+ function scanVideoFramesForTitles() {
+ const frameElements = document.querySelectorAll('iframe[data-media-id]');
+ var result = []
+
+ frameElements.forEach(elm => {
+
+ var frameLink = elm.getAttribute("src");
+ frameLink = frameLink.replace("media_attachments_iframe", "media_attachments");
+
+ const videoTitle = elm.getAttribute("title");
+ const ariaTitle = elm.getAttribute("aria-title");
+ const title = videoTitle ?? ariaTitle;
+
+ result.push({url: frameLink, title: title});
+ });
+
+ return result;
+ }
+
+ scanVideoFramesForTitles();
+ """
+
+ var onScanFinished: (() -> Void)?
+ var onFeatureUpdate: (() -> Void)?
+
+ private(set) weak var webView: CoreWebView?
+ private var studioImprovementsFlagStore: ReactiveStore?
+ private var storeSubscription: AnyCancellable?
+
+ /// This is to persist a map of video URL vs Title for the currently loaded page
+ /// of CoreWebView. Supposed to be updated (or emptied) on each page load.
+ private(set) var videoFramesTitleMap: [String: String] = [:]
+
+ init(webView: CoreWebView) {
+ self.webView = webView
+ }
+
+ func resetFeatureFlagStore(context: Context?, env: AppEnvironment) {
+ guard let context else {
+ storeSubscription?.cancel()
+ storeSubscription = nil
+ studioImprovementsFlagStore = nil
+ return
+ }
+
+ studioImprovementsFlagStore = ReactiveStore(
+ useCase: GetFeatureFlagState(
+ featureName: .studioEmbedImprovements,
+ context: context
+ ),
+ environment: env
+ )
+
+ resetStoreSubscription()
+ }
+
+ func urlForStudioImmersiveView(of action: WKNavigationAction) -> URL? {
+ guard action.isStudioImmersiveViewLinkTap, var url = action.request.url else {
+ return nil
+ }
+
+ if url.containsQueryItem(named: "title") == false,
+ let title = videoPlayerFrameTitle(matching: url) {
+ url = url.appendingQueryItems(.init(name: "title", value: title))
+ }
+
+ if url.containsQueryItem(named: "embedded") == false {
+ url = url.appendingQueryItems(.init(name: "embedded", value: "true"))
+ }
+
+ return url
+ }
+
+ /// To be called in didFinishLoading delegate method of WKWebView, it scans through
+ /// currently loaded page HTML content looking for video studio `iframe`s. It will extract
+ /// `title` attribute value and keep a map of such values vs video src URL, to be used
+ /// later to set the title for the immersive video player. This is mainly useful when launching
+ /// the immersive player from a button that's internal to video-frame. (`Expand` button).
+ /// Only applies for video frames of Canvas uploads.
+ func scanVideoFrames() {
+ guard let webView else { return }
+
+ videoFramesTitleMap.removeAll()
+ webView.evaluateJavaScript(Self.scanFramesScript) { [weak self] result, error in
+
+ if let error {
+ RemoteLogger.shared.logError(
+ name: "Error scanning video iframes elements",
+ reason: error.localizedDescription
+ )
+ }
+
+ var mapped: [String: String] = [:]
+
+ (result as? [[String: String]] ?? [])
+ .forEach({ pair in
+ guard
+ let urlString = pair["url"],
+ let urlCleanPath = URL(string: urlString)?
+ .removingQueryAndFragment()
+ .absoluteString,
+ let title = pair["title"]
+ else { return }
+
+ mapped[urlCleanPath] = title
+ .replacingOccurrences(of: "Video player for ", with: "")
+ .replacingOccurrences(of: ".mp4", with: "")
+ })
+
+ self?.videoFramesTitleMap = mapped
+ self?.onScanFinished?()
+ }
+ }
+
+ public func refresh() {
+ resetStoreSubscription(ignoreCache: true)
+ }
+
+ // MARK: Privates
+
+ private func resetStoreSubscription(ignoreCache: Bool = false) {
+ storeSubscription?.cancel()
+ storeSubscription = studioImprovementsFlagStore?
+ .getEntities(ignoreCache: ignoreCache, keepObservingDatabaseChanges: true)
+ .replaceError(with: [])
+ .map({ $0.first?.enabled ?? false })
+ .sink(receiveValue: { [weak self] isEnabled in
+ self?.updateStudioImprovementFeature(isEnabled: isEnabled)
+ })
+ }
+
+ private func updateStudioImprovementFeature(isEnabled: Bool) {
+ guard let webView else { return }
+
+ if isEnabled {
+ webView.addFeature(.insertStudioOpenInDetailButtons)
+ } else {
+ webView.removeFeatures(ofType: InsertStudioOpenInDetailButtons.self)
+ }
+
+ onFeatureUpdate?()
+ }
+
+ private func videoPlayerFrameTitle(matching url: URL) -> String? {
+ let path = url.removingQueryAndFragment().absoluteString
+ return videoFramesTitleMap.first(where: { path.hasPrefix($0.key) })?
+ .value
+ }
+}
+
+// MARK: - WKNavigationAction Extensions
+
+extension WKNavigationAction {
+
+ fileprivate var isStudioImmersiveViewLinkTap: Bool {
+ guard let path = request.url?.path else { return false }
+
+ let isExpandLink =
+ navigationType == .other
+ && path.contains("/media_attachments/") == true
+ && path.hasSuffix("/immersive_view") == true
+ && sourceFrame.isMainFrame == false
+
+ let isCanvasUploadDetailsLink =
+ navigationType == .linkActivated
+ && path.contains("/media_attachments/") == true
+ && path.hasSuffix("/immersive_view") == true
+ && (targetFrame?.isMainFrame ?? false) == false
+
+ let query = request.url?.query()?.removingPercentEncoding ?? ""
+ let isStudioEmbedDetailsLink =
+ navigationType == .linkActivated
+ && path.hasSuffix("/external_tools/retrieve")
+ && query.contains("custom_arc_launch_type=immersive_view")
+ && (targetFrame?.isMainFrame ?? false) == false
+
+ return isExpandLink || isCanvasUploadDetailsLink || isStudioEmbedDetailsLink
+ }
+}
diff --git a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/CanvasLTIPostMessageHandler.js b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/CanvasLTIPostMessageHandler.js
new file mode 100644
index 0000000000..b38a000f86
--- /dev/null
+++ b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/CanvasLTIPostMessageHandler.js
@@ -0,0 +1,316 @@
+//
+// This file is part of Canvas.
+// Copyright (C) 2025-present Instructure, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+//
+
+/**
+ * Canvas LTI PostMessage Handler for Mobile Apps
+ *
+ * This standalone JavaScript file handles LTI postMessage events in mobile webviews
+ * where the full Canvas UI bundle is not loaded. It can be injected by mobile apps
+ * to enable LTI tool functionality.
+ *
+ * This is a simplified, dependency-free version of the handlers found in canvas codebase:
+ * - ui/shared/lti/jquery/messages.ts (main message handler)
+ * - ui/shared/lti/jquery/subjects/lti.frameResize.ts (frameResize implementation)
+ * - ui/shared/lti/jquery/subjects/lti.fetchWindowSize.ts (fetchWindowSize implementation)
+ * - ui/shared/lti/jquery/response_messages.ts (response handling)
+ *
+ * Currently supports:
+ * - lti.frameResize: Resizes iframe containing LTI tool
+ * - lti.fetchWindowSize: Returns window dimensions and scroll position
+ *
+ */
+
+(function() {
+ 'use strict';
+
+ // Prevent duplicate initialization
+ if (window.__CANVAS_MOBILE_LTI_HANDLER_INITIALIZED__) {
+ console.log('[Canvas Mobile LTI] Handler already initialized');
+ return;
+ }
+ window.__CANVAS_MOBILE_LTI_HANDLER_INITIALIZED__ = true;
+
+ console.log('[Canvas Mobile LTI] Initializing mobile LTI postMessage handler');
+
+ /**
+ * Find the iframe element that corresponds to the given window
+ *
+ * @param {Window} sourceWindow - The contentWindow of the iframe we're looking for
+ * @param {Document} [startDocument=document] - The document to search in
+ * @returns {HTMLIFrameElement|null} The iframe element or null if not found
+ */
+ function findDomForWindow(sourceWindow, startDocument) {
+ startDocument = startDocument || document;
+ var iframes = startDocument.getElementsByTagName('iframe');
+
+ for (var i = 0; i < iframes.length; i++) {
+ if (iframes[i].contentWindow === sourceWindow) {
+ return iframes[i];
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Sometimes the iframe which is the window we're looking for is nested within an RCE iframe.
+ * Those are same-origin, so we can look through those RCE iframes' documents
+ * too for the window we're looking for.
+ *
+ * @param {Window} sourceWindow - The contentWindow of the iframe we're looking for
+ * @returns {HTMLIFrameElement|null} The iframe element or null if not found
+ */
+ function findDomForWindowInRCEIframe(sourceWindow) {
+ var iframes = document.querySelectorAll('.tox-tinymce iframe');
+ for (var i = 0; i < iframes.length; i++) {
+ var doc = iframes[i].contentDocument;
+ if (doc) {
+ var domElement = findDomForWindow(sourceWindow, doc);
+ if (domElement) {
+ return domElement;
+ }
+ }
+ }
+ return null;
+ }
+
+ // ============================================================================
+ // Response Message Functions (from ui/shared/lti/jquery/response_messages.ts)
+ // ============================================================================
+
+ /**
+ * Build response message handlers
+ *
+ * @param {Object} config
+ * @param {Window} config.targetWindow - The window to send responses to
+ * @param {string} config.origin - The origin to send to
+ * @param {string} config.subject - The message subject
+ * @param {string} [config.message_id] - Optional message ID
+ * @returns {Object} Response message helpers
+ */
+ function buildResponseMessages(config) {
+ var targetWindow = config.targetWindow;
+ var origin = config.origin;
+ var subject = config.subject;
+ var message_id = config.message_id;
+
+ function sendResponse(contents) {
+ contents = contents || {};
+ var message = {
+ subject: subject + '.response'
+ };
+
+ if (message_id) {
+ message.message_id = message_id;
+ }
+
+ // Merge contents into message
+ for (var key in contents) {
+ if (contents.hasOwnProperty(key)) {
+ message[key] = contents[key];
+ }
+ }
+
+ if (targetWindow) {
+ try {
+ targetWindow.postMessage(message, origin);
+ console.log('[Canvas Mobile LTI] Sent response:', message);
+ } catch (e) {
+ console.error('[Canvas Mobile LTI] Error sending postMessage:', e);
+ }
+ } else {
+ console.error('[Canvas Mobile LTI] Cannot send response: target window does not exist');
+ }
+ }
+
+ function sendSuccess() {
+ sendResponse({});
+ }
+
+ function sendError(code, errorMessage) {
+ var error = {code: code};
+ if (errorMessage) {
+ error.message = errorMessage;
+ }
+ sendResponse({error: error});
+ }
+
+ return {
+ sendResponse: sendResponse,
+ sendSuccess: sendSuccess,
+ sendError: sendError
+ };
+ }
+
+ // =================================================================================
+ // Message Handlers (from canvas ui/shared/lti/jquery/subjects/lti.frameResize.ts)
+ // =================================================================================
+
+ /**
+ * Handle lti.frameResize messages
+ *
+ * @param {Object} message - The parsed message object
+ * @param {MessageEvent} event - The original message event
+ * @param {Object} responseMessages - Response message helpers
+ * @returns {boolean} True if response was already sent
+ */
+ function handleFrameResize(message, event, _responseMessages) {
+ console.log('[Canvas Mobile LTI] Handling lti.frameResize:', message);
+
+ var height = message.height;
+ if (Number(height) <= 0) {
+ height = 1;
+ }
+
+ // Find the iframe that sent the message (matching lti.frameResize.ts:39)
+ // Try to find in main document first, then check if nested in RCE iframe
+ var iframe = findDomForWindow(event.source) || findDomForWindowInRCEIframe(event.source);
+
+ if (!iframe) {
+ console.warn('[Canvas Mobile LTI] frameResize: could not find iframe for source window');
+ // In the desktop version, this continues without error, so we do the same
+ // (lti.frameResize.ts:46 returns false without sending error)
+ return false;
+ }
+
+ // Apply the height (matching lti.frameResize.ts:41-43)
+ var heightStr = typeof height === 'number' ? height + 'px' : height;
+ iframe.height = heightStr;
+ iframe.style.height = heightStr;
+
+ console.log('[Canvas Mobile LTI] frameResize: resized iframe to', heightStr);
+
+ return false;
+ }
+
+ /**
+ * Handle lti.fetchWindowSize messages
+ *
+ * @param {Object} message - The parsed message object
+ * @param {MessageEvent} event - The original message event
+ * @param {Object} responseMessages - Response message helpers
+ * @returns {boolean} True if response was already sent
+ */
+ function handleFetchWindowSize(_message, _event, responseMessages) {
+ console.log('[Canvas Mobile LTI] Handling lti.fetchWindowSize');
+
+ // Get the tool content wrapper element (matching lti.fetchWindowSize.ts:26)
+ var toolWrapper = document.querySelector('.tool_content_wrapper');
+ var offset = null;
+
+ // Calculate offset if wrapper exists (vanilla JS equivalent of jQuery's offset())
+ if (toolWrapper) {
+ var rect = toolWrapper.getBoundingClientRect();
+ offset = {
+ top: rect.top + window.scrollY,
+ left: rect.left + window.scrollX
+ };
+ }
+
+ // Get footer height (matching lti.fetchWindowSize.ts:27)
+ var fixedBottom = document.getElementById('fixed_bottom');
+ var footerHeight = fixedBottom ? fixedBottom.offsetHeight : 0;
+
+ // Build response (matching lti.fetchWindowSize.ts:23-29)
+ var response = {
+ height: window.innerHeight,
+ width: window.innerWidth,
+ offset: offset,
+ footer: footerHeight,
+ scrollY: window.scrollY
+ };
+
+ console.log('[Canvas Mobile LTI] fetchWindowSize response:', response);
+
+ // Send response (matching lti.fetchWindowSize.ts:30 - returns true)
+ responseMessages.sendResponse(response);
+ return true;
+ }
+
+ /**
+ * Main LTI message handler
+ *
+ * @param {MessageEvent} event - The message event
+ */
+ function ltiMessageHandler(event) {
+ if (event.data === '') {
+ return;
+ }
+
+ var message;
+ try {
+ message = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
+ } catch (err) {
+ // unparseable message may not be meant for our handlers
+ return;
+ }
+
+ if (typeof message !== 'object' || message === null) {
+ return;
+ }
+
+ // Get the subject (check messageType for backwards compatibility) (messages.ts:174)
+ var subject = message.subject || message.messageType;
+
+ if (!subject) {
+ return;
+ }
+
+ console.log('[Canvas Mobile LTI] Received message with subject:', subject);
+
+ // Build response message helpers (messages.ts:176-182)
+ var responseMessages = buildResponseMessages({
+ targetWindow: event.source,
+ origin: event.origin,
+ subject: subject,
+ message_id: message.message_id
+ });
+
+ // Handle the message based on subject
+ var hasSentResponse = false;
+
+ switch (subject) {
+ case 'lti.frameResize':
+ hasSentResponse = handleFrameResize(message, event, responseMessages);
+ break;
+
+ case 'lti.fetchWindowSize':
+ hasSentResponse = handleFetchWindowSize(message, event, responseMessages);
+ break;
+
+ default:
+ console.log('[Canvas Mobile LTI] Ignoring unsupported message subject:', subject);
+ return;
+ }
+
+ // If handler didn't send a response, send success (messages.ts:215-217)
+ if (!hasSentResponse) {
+ responseMessages.sendSuccess();
+ }
+ }
+
+ // Register the message listener
+ window.addEventListener('message', ltiMessageHandler);
+ console.log('[Canvas Mobile LTI] Mobile LTI handler initialized successfully');
+
+ // Expose handler info for debugging
+ window.__CANVAS_MOBILE_LTI_HANDLER__ = {
+ version: '1.0.0',
+ supportedSubjects: ['lti.frameResize', 'lti.fetchWindowSize']
+ };
+})();
diff --git a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/CanvasLTIPostMessageHandler.swift b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/CanvasLTIPostMessageHandler.swift
new file mode 100644
index 0000000000..f6cbfc2f45
--- /dev/null
+++ b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/CanvasLTIPostMessageHandler.swift
@@ -0,0 +1,47 @@
+//
+// This file is part of Canvas.
+// Copyright (C) 2025-present Instructure, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+//
+
+import UIKit
+
+class CanvasLTIPostMessageHandler: CoreWebViewFeature {
+
+ private let script: String? = {
+ if let url = Bundle.core.url(forResource: "CanvasLTIPostMessageHandler", withExtension: "js"),
+ let jsSource = try? String(contentsOf: url, encoding: .utf8) {
+ return jsSource
+ }
+ return nil
+ }()
+
+ public override init() { }
+
+ override func apply(on webView: CoreWebView) {
+ if let script {
+ webView.addScript(script)
+ }
+ }
+}
+
+public extension CoreWebViewFeature {
+
+ /// This is to be used with webViews that are intended to load content
+ /// as HTML string. This important to fix a resize issue with LTI iframes.
+ static var canvasLTIPostMessageHandler: CoreWebViewFeature {
+ CanvasLTIPostMessageHandler()
+ }
+}
diff --git a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/CoreWebViewFeature.swift b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/CoreWebViewFeature.swift
index caa6469ede..86f76a05bf 100644
--- a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/CoreWebViewFeature.swift
+++ b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/CoreWebViewFeature.swift
@@ -26,5 +26,6 @@ open class CoreWebViewFeature {
public init() {}
open func apply(on configuration: WKWebViewConfiguration) {}
open func apply(on webView: CoreWebView) {}
+ open func remove(from: CoreWebView) {}
open func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {}
}
diff --git a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.css b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.css
new file mode 100644
index 0000000000..5932b8f1c1
--- /dev/null
+++ b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.css
@@ -0,0 +1,31 @@
+p[ios-injected] {
+ text-align: center;
+}
+
+.open_details_button {
+ font-weight: 400;
+ font-size: 14px;
+ text-decoration: none;
+ color: #2B7ABC;
+}
+
+.open_details_button_icon {
+ display: inline-block;
+ width: 1.3em;
+ height: 100%;
+ vertical-align: middle;
+ padding-right: 0.43em;
+ padding-left: 0.43em;
+}
+
+div.open_details_button_icon svg {
+ width: 100%;
+ height: auto;
+ display: block;
+ transform: translate(0, -2px);
+}
+
+div.open_details_button_icon svg * {
+ width: 100%;
+ height: 100%;
+}
diff --git a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.js b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.js
new file mode 100644
index 0000000000..2b02e76585
--- /dev/null
+++ b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.js
@@ -0,0 +1,152 @@
+//
+// This file is part of Canvas.
+// Copyright (C) 2025-present Instructure, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+//
+
+function findCanvasUploadLink(elm, title) {
+ if (elm.hasAttribute("data-media-id") == false) { return null }
+
+ let frameSource = elm.getAttribute("src");
+ if (!frameSource) { return null }
+
+ let frameFullPath = frameSource
+ .replace("/media_attachments_iframe/", "/media_attachments/")
+
+ try {
+
+ let frameURL = new URL(frameFullPath);
+ frameURL.pathname += "/immersive_view";
+
+ if(title) {
+ title = title.replace("Video player for ", "").replace(".mp4", "");
+ frameURL.searchParams.set("title", encodeURIComponent(title));
+ }
+
+ return frameURL;
+ } catch {
+ return null;
+ }
+}
+
+function findStudioEmbedLink(elm, title) {
+ let frameSource = elm.getAttribute("src");
+ if(!frameSource) { return null }
+
+ let frameURL = new URL(frameSource);
+ let playerSource = frameURL.searchParams.get("url");
+ if(!playerSource) { return null }
+
+ try {
+
+ let playerURL = new URL(playerSource);
+
+ let mediaID = playerURL.searchParams.get("custom_arc_media_id")
+ if(!mediaID) { return null }
+
+ playerURL.searchParams.set("custom_arc_launch_type", "immersive_view");
+
+ frameURL.searchParams.set("url", playerURL.toString());
+ frameURL.searchParams.set("display", "full_width");
+
+ if(title) {
+ title = title.replace("Video player for ", "").replace(".mp4", "");
+ frameURL.searchParams.set("title", encodeURIComponent(title));
+ }
+
+ return frameURL;
+
+ } catch {
+ return null;
+ }
+}
+
+function insertDetailsLinks(elm, method) {
+ var linkSpecs = window.detailLinkSpecs;
+ linkSpecs = (linkSpecs) ? linkSpecs : { iconSVG: null, title: null };
+
+ let nextSibling = elm.nextElementSibling;
+ let nextNextSibling = (nextSibling) ? nextSibling.nextElementSibling : null;
+ let wasInjected = (nextNextSibling) ? nextNextSibling.getAttribute("ios-injected") : 0;
+
+ if(wasInjected == 1) { return }
+
+ const videoTitle = elm.getAttribute("title");
+ const ariaTitle = elm.getAttribute("aria-title");
+ const title = videoTitle ?? ariaTitle;
+
+ var buttonHref;
+ if(method == "studio") {
+ buttonHref = findStudioEmbedLink(elm, title);
+ } else {
+ buttonHref = findCanvasUploadLink(elm, title);
+ }
+
+ if(!buttonHref) { return }
+
+ const newLine = document.createElement('br');
+ const newParagraph = document.createElement('p');
+ newParagraph.setAttribute("ios-injected", 1);
+
+ const buttonContainer = document.createElement('div');
+ buttonContainer.className = "open_detail_button_container";
+
+ if(linkSpecs.iconSVG) {
+ const icon = document.createElement('div');
+ icon.className = "open_details_button_icon";
+ icon.innerHTML = linkSpecs.iconSVG;
+ buttonContainer.appendChild(icon);
+ }
+
+ if(linkSpecs.title) {
+ const detailButton = document.createElement('a');
+ detailButton.className = "open_details_button";
+ detailButton.href = buttonHref;
+ detailButton.target = "_blank";
+ detailButton.textContent = escapeHTML(linkSpecs.title);
+
+ buttonContainer.appendChild(detailButton);
+ newParagraph.appendChild(buttonContainer);
+ }
+
+ elm.insertAdjacentElement('afterend', newLine);
+ newLine.insertAdjacentElement('afterend', newParagraph);
+}
+
+function scanMediaFramesInsertingDetailsLinks() {
+ document
+ .querySelectorAll("iframe[class='lti-embed']")
+ .forEach(elm => {
+ insertDetailsLinks(elm, "studio");
+ });
+
+ document
+ .querySelectorAll("iframe[data-media-id]")
+ .forEach(elm => {
+ insertDetailsLinks(elm, "canvas");
+ });
+}
+
+function escapeHTML(text) {
+ return text
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/'/g, ''')
+ .replace(/"/g, '"')
+}
+
+scanMediaFramesInsertingDetailsLinks();
+window.addEventListener("DOMContentLoaded", scanMediaFramesInsertingDetailsLinks);
diff --git a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.swift b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.swift
new file mode 100644
index 0000000000..62328e9de9
--- /dev/null
+++ b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.swift
@@ -0,0 +1,100 @@
+//
+// This file is part of Canvas.
+// Copyright (C) 2025-present Instructure, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+//
+
+import UIKit
+
+class InsertStudioOpenInDetailButtons: CoreWebViewFeature {
+ private var isAdded: Bool = false
+
+ private let insertValues: String = {
+ let title = String(localized: "Open in Detail View", bundle: .core)
+ let iconSVG = (NSDataAsset(name: "externalLinkData", bundle: .core)
+ .flatMap({ String(data: $0.data, encoding: .utf8) ?? "" }) ?? "")
+ .components(separatedBy: .newlines).joined()
+
+ return """
+ window.detailLinkSpecs = {
+ iconSVG: \(CoreWebView.jsString(iconSVG)),
+ title: \(CoreWebView.jsString(title))
+ }
+ """
+ }()
+
+ private let insertScript: String? = {
+ if let url = Bundle.core.url(forResource: "InsertStudioOpenInDetailButtons", withExtension: "js"),
+ let jsSource = try? String(contentsOf: url, encoding: .utf8) {
+ return jsSource
+ }
+ return nil
+ }()
+
+ private let insertStyle: String? = {
+ if let url = Bundle.core.url(forResource: "InsertStudioOpenInDetailButtons", withExtension: "css"),
+ let cssSource = try? String(contentsOf: url, encoding: .utf8) {
+
+ return """
+ (() => {
+ var element = document.createElement('style');
+ element.innerHTML = \(CoreWebView.jsString(cssSource));
+ document.head.appendChild(element);
+ })()
+ """
+ }
+ return nil
+ }()
+
+ public override init() { }
+
+ override func apply(on webView: CoreWebView) {
+ guard
+ let insertScript,
+ let insertStyle
+ else {
+ RemoteLogger.shared.logError(name: "WebView: Missing immersive player links script or stylesheet")
+ return
+ }
+
+ if isAdded == false {
+ webView.addScript(insertValues)
+ webView.addScript(insertStyle)
+ webView.addScript(insertScript)
+ isAdded = true
+ }
+ }
+
+ override func remove(from webView: CoreWebView) {
+ webView.removeScript(insertValues)
+
+ if let insertStyle {
+ webView.removeScript(insertStyle)
+ }
+
+ if let insertScript {
+ webView.removeScript(insertScript)
+ }
+
+ isAdded = false
+ }
+}
+
+public extension CoreWebViewFeature {
+
+ static var insertStudioOpenInDetailButtons: CoreWebViewFeature {
+ InsertStudioOpenInDetailButtons()
+ }
+}
diff --git a/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift b/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift
index ebb6e60408..b014fd6101 100644
--- a/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift
+++ b/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift
@@ -42,6 +42,7 @@ open class CoreWebView: WKWebView {
}()
@IBInspectable public var autoresizesHeight: Bool = false
+
public weak var linkDelegate: CoreWebViewLinkDelegate?
public weak var sizeDelegate: CoreWebViewSizeDelegate?
public weak var errorDelegate: CoreWebViewErrorDelegate?
@@ -68,6 +69,7 @@ open class CoreWebView: WKWebView {
private var env: AppEnvironment = .shared
private var subscriptions = Set()
+ private(set) lazy var studioFeaturesInteractor = CoreWebViewStudioFeaturesInteractor(webView: self)
public required init?(coder: NSCoder) {
super.init(coder: coder)
@@ -97,11 +99,23 @@ open class CoreWebView: WKWebView {
setup()
}
+ /// Optional. Use this to enable insertion of `Open in Detail View` links below
+ /// each Studio video `iframe` when `rce_studio_embed_improvements` feature
+ /// flag is enabled for the passed context.
+ public func setupStudioFeatures(context: Context?, env: AppEnvironment) {
+ studioFeaturesInteractor.resetFeatureFlagStore(context: context, env: env)
+ }
+
deinit {
configuration.userContentController.removeAllScriptMessageHandlers()
configuration.userContentController.removeAllUserScripts()
}
+ open override func reload() -> WKNavigation? {
+ studioFeaturesInteractor.refresh()
+ return super.reload()
+ }
+
/**
This method is to add support for CanvasCore project. Can be removed when that project is removed
as this method isn't safe for features modifying `WKWebViewConfiguration`.
@@ -111,6 +125,19 @@ open class CoreWebView: WKWebView {
feature.apply(on: self)
}
+ @discardableResult
+ public func removeFeatures(ofType: T.Type) -> Bool {
+ let filteredList = features.enumerated().filter({ $0.element is T })
+ let indexSet = IndexSet(filteredList.map({ $0.offset }))
+ if indexSet.isNotEmpty {
+ filteredList.forEach({ $0.element.remove(from: self) })
+ features.remove(atOffsets: indexSet)
+ _ = super.reload()
+ return true
+ }
+ return false
+ }
+
public func scrollIntoView(fragment: String, then: ((Bool) -> Void)? = nil) {
guard autoresizesHeight else { return }
let script = """
@@ -463,6 +490,7 @@ extension CoreWebView: WKNavigationDelegate {
decidePolicyFor action: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
+
if action.navigationType == .linkActivated && !isLinkNavigationEnabled {
decisionHandler(.cancel)
return
@@ -492,6 +520,18 @@ extension CoreWebView: WKNavigationDelegate {
return decisionHandler(.allow) // let web view scroll to link too, if necessary
}
+ // Handle Studio Immersive Player links (media_attachments/:id/immersive_view)
+ if let immersiveURL = studioFeaturesInteractor.urlForStudioImmersiveView(of: action),
+ let controller = linkDelegate?.routeLinksFrom {
+ controller.pauseWebViewPlayback()
+ env.router.show(
+ StudioViewController(url: immersiveURL),
+ from: controller,
+ options: .modal(.overFullScreen)
+ )
+ return decisionHandler(.cancel)
+ }
+
// Handle "Launch External Tool" button OR
// LTI app buttons embedded in K5 WebViews when there's no additional JavaScript
// involved (like Zoom and Microsoft).
@@ -549,6 +589,7 @@ extension CoreWebView: WKNavigationDelegate {
}
features.forEach { $0.webView(webView, didFinish: navigation) }
+ studioFeaturesInteractor.scanVideoFrames()
}
public func webView(
diff --git a/Core/Core/Common/CommonUI/SwiftUIViews/WebView.swift b/Core/Core/Common/CommonUI/SwiftUIViews/WebView.swift
index 5b503ac82d..76266e1ede 100644
--- a/Core/Core/Common/CommonUI/SwiftUIViews/WebView.swift
+++ b/Core/Core/Common/CommonUI/SwiftUIViews/WebView.swift
@@ -31,6 +31,7 @@ public struct WebView: UIViewRepresentable {
private var configuration: WKWebViewConfiguration
private let features: [CoreWebViewFeature]
private let baseURL: URL?
+ private var featuresContext: Context?
private var isScrollEnabled: Bool = true
@Environment(\.appEnvironment) private var env
@@ -98,6 +99,12 @@ public struct WebView: UIViewRepresentable {
return modified
}
+ public func featuresContext(_ context: Context) -> Self {
+ var modified = self
+ modified.featuresContext = context
+ return modified
+ }
+
public func onProvisionalNavigationStarted(
_ handleProvisionalNavigationStarted: ((CoreWebView, WKNavigation?) -> Void)?
) -> Self {
@@ -151,6 +158,8 @@ public struct WebView: UIViewRepresentable {
guard let webView: CoreWebView = uiView.subviews.first(where: { $0 is CoreWebView }) as? CoreWebView else { return }
webView.linkDelegate = context.coordinator
webView.sizeDelegate = context.coordinator
+ webView.setupStudioFeatures(context: featuresContext, env: env)
+
// During `makeUIView` `UIView`s have no view controllers so they can't check if dark mode is enabled.
// We force an update here since a `CoreHostingController` is assiged to the view hierarchy.
webView.updateInterfaceStyle()
@@ -205,7 +214,7 @@ extension WebView {
) {
reloadObserver?.cancel()
reloadObserver = trigger?.sink {
- webView.reload()
+ _ = webView.reload()
}
}
diff --git a/Core/Core/Common/Extensions/Foundation/URLExtensions.swift b/Core/Core/Common/Extensions/Foundation/URLExtensions.swift
index d10d6a75ef..90da89a9a7 100644
--- a/Core/Core/Common/Extensions/Foundation/URLExtensions.swift
+++ b/Core/Core/Common/Extensions/Foundation/URLExtensions.swift
@@ -191,6 +191,10 @@ public extension URL {
return components.queryValue(for: key) != nil
}
+ func queryValue(for key: String) -> String? {
+ return URLComponents.parse(self).queryValue(for: key)
+ }
+
func appendingOrigin(_ origin: String) -> URL {
return appendingQueryItems(.init(name: "origin", value: origin))
}
diff --git a/Core/Core/Common/Extensions/WebKit/WKWebViewExtensions.swift b/Core/Core/Common/Extensions/WebKit/WKWebViewExtensions.swift
index b588074364..a33badb7e6 100644
--- a/Core/Core/Common/Extensions/WebKit/WKWebViewExtensions.swift
+++ b/Core/Core/Common/Extensions/WebKit/WKWebViewExtensions.swift
@@ -28,6 +28,16 @@ public extension WKWebView {
configuration.userContentController.addUserScript(script)
}
+ func removeScript(_ js: String) {
+ let controller = configuration.userContentController
+ let scriptsToKeep = controller.userScripts.filter({ $0.source != js })
+
+ controller.removeAllUserScripts()
+ scriptsToKeep.forEach { script in
+ controller.addUserScript(script)
+ }
+ }
+
func handle(_ name: String, handler: @escaping MessageHandler) {
let passer = MessagePasser(handler: handler)
configuration.userContentController.removeScriptMessageHandler(forName: name)
diff --git a/Core/Core/Features/Discussions/DiscussionDetails/DiscussionDetailsViewController.swift b/Core/Core/Features/Discussions/DiscussionDetails/DiscussionDetailsViewController.swift
index 6194521392..e73e7ff2e7 100644
--- a/Core/Core/Features/Discussions/DiscussionDetails/DiscussionDetailsViewController.swift
+++ b/Core/Core/Features/Discussions/DiscussionDetails/DiscussionDetailsViewController.swift
@@ -148,13 +148,13 @@ public class DiscussionDetailsViewController: ScreenViewTrackableViewController,
webView.accessibilityIdentifier = "DiscussionDetails.body"
webView.pinWithThemeSwitchButton(inside: webViewPlaceholder)
webView.heightAnchor.constraint(equalToConstant: 100).isActive = true
-
webView.autoresizesHeight = true // will update the height constraint
webView.scrollView.showsVerticalScrollIndicator = false
webView.scrollView.alwaysBounceVertical = false
webView.backgroundColor = .backgroundLightest
webView.linkDelegate = self
webView.errorDelegate = self
+ webView.setupStudioFeatures(context: context, env: env)
webView.addScript(DiscussionHTML.preact)
webView.addScript(DiscussionHTML.js)
webView.handle("like") { [weak self] message in self?.handleLike(message) }
@@ -613,6 +613,7 @@ extension DiscussionDetailsViewController: UIScrollViewDelegate {
}
extension DiscussionDetailsViewController: CoreWebViewLinkDelegate {
+
public func handleLink(_ url: URL) -> Bool {
guard
url.host == env.currentSession?.baseURL.host,
diff --git a/Core/Core/Features/Discussions/DiscussionReply/DiscussionReplyViewController.swift b/Core/Core/Features/Discussions/DiscussionReply/DiscussionReplyViewController.swift
index 5aba33d160..e5e788c1b4 100644
--- a/Core/Core/Features/Discussions/DiscussionReply/DiscussionReplyViewController.swift
+++ b/Core/Core/Features/Discussions/DiscussionReply/DiscussionReplyViewController.swift
@@ -167,6 +167,7 @@ public class DiscussionReplyViewController: ScreenViewTrackableViewController, E
webView.autoresizesHeight = true
webView.backgroundColor = .backgroundLightest
webView.linkDelegate = self
+ webView.setupStudioFeatures(context: context, env: env)
webView.scrollView.isScrollEnabled = false
contentHeight.priority = .defaultHigh // webViewHeight will win
contentHeight.isActive = true
diff --git a/Core/Core/Features/Files/View/FileDetails/FileDetailsViewController.swift b/Core/Core/Features/Files/View/FileDetails/FileDetailsViewController.swift
index 3114fe33d9..a170b97f08 100644
--- a/Core/Core/Features/Files/View/FileDetails/FileDetailsViewController.swift
+++ b/Core/Core/Features/Files/View/FileDetails/FileDetailsViewController.swift
@@ -312,6 +312,7 @@ public class FileDetailsViewController: ScreenViewTrackableViewController, CoreW
contentView.addSubview(webView)
webView.pinWithThemeSwitchButton(inside: contentView)
webView.linkDelegate = self
+ webView.setupStudioFeatures(context: context, env: env)
webView.accessibilityLabel = "FileDetails.webView"
progressView.progress = 0
setupLoadObservation(for: webView)
diff --git a/Core/Core/Features/LTI/LTITools.swift b/Core/Core/Features/LTI/LTITools.swift
index 00cd2f45f4..56040a3dfa 100644
--- a/Core/Core/Features/LTI/LTITools.swift
+++ b/Core/Core/Features/LTI/LTITools.swift
@@ -165,7 +165,7 @@ public class LTITools: NSObject {
}
public func presentTool(from view: UIViewController, animated: Bool = true, completionHandler: ((Bool) -> Void)? = nil) {
- getSessionlessLaunch { [weak view, originalUrl = url, env, isQuizLTI] response in
+ getSessionlessLaunch { [weak view, originalUrl = url, env, isQuizLTI, context] response in
guard let view else { return }
guard let response = response else {
@@ -193,6 +193,7 @@ public class LTITools: NSObject {
.hideReturnButtonInQuizLTI,
.disableLinksOverlayPreviews
])
+ controller.webView.setupStudioFeatures(context: context, env: env)
controller.webView.load(URLRequest(url: url))
controller.title = String(localized: "Quiz", bundle: .core)
controller.addDoneButton(side: .right)
diff --git a/Core/Core/Features/LTI/View/StudioViewController.swift b/Core/Core/Features/LTI/View/StudioViewController.swift
index fd189bb3a6..4889219a57 100644
--- a/Core/Core/Features/LTI/View/StudioViewController.swift
+++ b/Core/Core/Features/LTI/View/StudioViewController.swift
@@ -25,7 +25,8 @@ class StudioViewController: UINavigationController {
let controller = CoreWebViewController()
controller.webView.load(URLRequest(url: url))
controller.addDoneButton()
- controller.title = String(localized: "Studio", bundle: .core)
+ controller.title = url.queryValue(for: "title")?.removingPercentEncoding
+ ?? String(localized: "Studio", bundle: .core)
super.init(rootViewController: controller)
diff --git a/Core/Core/Features/Pages/PageDetails/PageDetailsViewController.swift b/Core/Core/Features/Pages/PageDetails/PageDetailsViewController.swift
index 20d39be5f1..4967f5459c 100644
--- a/Core/Core/Features/Pages/PageDetails/PageDetailsViewController.swift
+++ b/Core/Core/Features/Pages/PageDetails/PageDetailsViewController.swift
@@ -21,7 +21,7 @@ import UIKit
open class PageDetailsViewController: UIViewController, ColoredNavViewProtocol, ErrorViewController {
lazy var optionsButton = UIBarButtonItem(image: .moreLine, style: .plain, target: self, action: #selector(showOptions))
@IBOutlet weak var webViewContainer: UIView!
- public var webView = CoreWebView()
+ public var webView = CoreWebView(features: [.canvasLTIPostMessageHandler])
let refreshControl = CircleRefreshControl()
public let titleSubtitleView = TitleSubtitleView.create()
@@ -84,6 +84,7 @@ open class PageDetailsViewController: UIViewController, ColoredNavViewProtocol,
webViewContainer.addSubview(webView)
webView.pinWithThemeSwitchButton(inside: webViewContainer)
webView.linkDelegate = self
+ webView.setupStudioFeatures(context: context, env: env)
if context.contextType == .course {
webView.addScript("window.ENV={COURSE:{id:\(CoreWebView.jsString(context.id))}}")
@@ -117,6 +118,7 @@ open class PageDetailsViewController: UIViewController, ColoredNavViewProtocol,
}
@objc private func refresh() {
+ webView.studioFeaturesInteractor.refresh()
pages.refresh(force: true) { [weak self] _ in
self?.refreshControl.endRefreshing()
}
diff --git a/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizDetailsViewController.swift b/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizDetailsViewController.swift
index 00fb78db2e..54ee2ac989 100644
--- a/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizDetailsViewController.swift
+++ b/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizDetailsViewController.swift
@@ -25,7 +25,7 @@ public class StudentQuizDetailsViewController: ScreenViewTrackableViewController
@IBOutlet weak var dueLabel: UILabel!
@IBOutlet weak var instructionsHeadingLabel: UILabel!
@IBOutlet weak var instructionsContainer: UIView!
- let instructionsWebView = CoreWebView()
+ let instructionsWebView = CoreWebView(features: [.canvasLTIPostMessageHandler])
@IBOutlet weak var loadingView: CircleProgressView!
@IBOutlet weak var pointsLabel: UILabel!
@IBOutlet weak var questionsLabel: UILabel!
@@ -94,6 +94,7 @@ public class StudentQuizDetailsViewController: ScreenViewTrackableViewController
instructionsWebView.scrollView.alwaysBounceVertical = false
instructionsWebView.backgroundColor = .backgroundLightest
instructionsWebView.linkDelegate = self
+ instructionsWebView.setupStudioFeatures(context: .course(courseID), env: env)
loadingView.color = nil
refreshControl.color = nil
diff --git a/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizWebViewController.swift b/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizWebViewController.swift
index d96d5e4973..0325f1ae32 100644
--- a/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizWebViewController.swift
+++ b/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizWebViewController.swift
@@ -47,6 +47,7 @@ public class StudentQuizWebViewController: UIViewController {
webView.linkDelegate = self
webView.uiDelegate = self
+ webView.setupStudioFeatures(context: .course(courseID), env: env)
title = String(localized: "Take Quiz", bundle: .core)
navigationItem.rightBarButtonItem = UIBarButtonItem(
diff --git a/Core/Core/Features/Studio/Model/StudioIFrameReplaceInteractor.swift b/Core/Core/Features/Studio/Model/StudioIFrameReplaceInteractor.swift
index 56802dfe4c..184db7b684 100644
--- a/Core/Core/Features/Studio/Model/StudioIFrameReplaceInteractor.swift
+++ b/Core/Core/Features/Studio/Model/StudioIFrameReplaceInteractor.swift
@@ -57,15 +57,21 @@ public class StudioIFrameReplaceInteractorLive: StudioIFrameReplaceInteractor {
throw StudioIFrameReplaceError.failedToConvertDataToString
}
+ var replacedFrames = 0
for iframe in iframes {
guard let offlineVideo = offlineVideos.first(where: { $0.ltiLaunchID == iframe.mediaLTILaunchID }) else {
- throw StudioIFrameReplaceError.offlineVideoIDNotFound
+ continue
}
htmlString = replaceStudioIFrame(
html: htmlString,
iFrameHtml: iframe.sourceHtml,
studioVideo: offlineVideo
)
+ replacedFrames += 1
+ }
+
+ if replacedFrames == 0 {
+ throw StudioIFrameReplaceError.offlineVideoIDNotFound
}
guard let updatedHtmlData = htmlString.data(using: .utf8) else {
diff --git a/Core/Core/Features/Syllabus/SyllabusViewController.swift b/Core/Core/Features/Syllabus/SyllabusViewController.swift
index 4c997da6dd..d56ef9ea80 100644
--- a/Core/Core/Features/Syllabus/SyllabusViewController.swift
+++ b/Core/Core/Features/Syllabus/SyllabusViewController.swift
@@ -44,6 +44,7 @@ open class SyllabusViewController: UIViewController, CoreWebViewLinkDelegate {
webView.backgroundColor = .backgroundLightest
webView.scrollView.refreshControl = refreshControl
webView.linkDelegate = self
+ webView.setupStudioFeatures(context: .course(courseID), env: env)
view.addSubview(webView)
webView.pinWithThemeSwitchButton(inside: view)
diff --git a/Core/Core/Resources/Assets.xcassets/Images/externalLinkData.dataset/Contents.json b/Core/Core/Resources/Assets.xcassets/Images/externalLinkData.dataset/Contents.json
new file mode 100644
index 0000000000..ece364a450
--- /dev/null
+++ b/Core/Core/Resources/Assets.xcassets/Images/externalLinkData.dataset/Contents.json
@@ -0,0 +1,13 @@
+{
+ "data" : [
+ {
+ "filename" : "externalLink.svg",
+ "idiom" : "universal",
+ "universal-type-identifier" : "public.svg-image"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Core/Core/Resources/Assets.xcassets/Images/externalLinkData.dataset/externalLink.svg b/Core/Core/Resources/Assets.xcassets/Images/externalLinkData.dataset/externalLink.svg
new file mode 100644
index 0000000000..d0ba658b32
--- /dev/null
+++ b/Core/Core/Resources/Assets.xcassets/Images/externalLinkData.dataset/externalLink.svg
@@ -0,0 +1,3 @@
+
diff --git a/Core/Core/Resources/Localizable.xcstrings b/Core/Core/Resources/Localizable.xcstrings
index 3234fd8edd..9d6937e31a 100644
--- a/Core/Core/Resources/Localizable.xcstrings
+++ b/Core/Core/Resources/Localizable.xcstrings
@@ -259042,6 +259042,9 @@
}
}
},
+ "Open in Detail View" : {
+ "comment" : "Label for button that opens an embedded video in a separate, detailed view."
+ },
"Open in Safari" : {
"localizations" : {
"ar" : {
diff --git a/Core/CoreTests/Common/CommonModels/AppEnvironment/FeatureFlags/FeatureFlagTests.swift b/Core/CoreTests/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagStateRequestTests.swift
similarity index 59%
rename from Core/CoreTests/Common/CommonModels/AppEnvironment/FeatureFlags/FeatureFlagTests.swift
rename to Core/CoreTests/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagStateRequestTests.swift
index a671ad677c..0b691ffd77 100644
--- a/Core/CoreTests/Common/CommonModels/AppEnvironment/FeatureFlags/FeatureFlagTests.swift
+++ b/Core/CoreTests/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagStateRequestTests.swift
@@ -1,6 +1,6 @@
//
// This file is part of Canvas.
-// Copyright (C) 2022-present Instructure, Inc.
+// Copyright (C) 2025-present Instructure, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
@@ -16,12 +16,21 @@
// along with this program. If not, see .
//
-import Core
+import Foundation
+import TestsFoundation
+@testable import Core
import XCTest
-class FeatureFlagTests: XCTestCase {
+class GetFeatureFlagStateRequestTests: CoreTestCase {
- func testFeatureFlagKeys() {
- XCTAssertEqual(APIFeatureFlag.Key.assignmentEnhancements.rawValue, "assignments_2_student")
+ func testGetFeatureFlagStateRequest() {
+ // Given
+ let context = Context(.course, id: "22343")
+
+ // Then
+ XCTAssertEqual(
+ GetFeatureFlagStateRequest(featureName: .studioEmbedImprovements, context: context).path,
+ "courses/22343/features/flags/rce_studio_embed_improvements"
+ )
}
}
diff --git a/Core/CoreTests/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagStateTests.swift b/Core/CoreTests/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagStateTests.swift
new file mode 100644
index 0000000000..ec46d2f57e
--- /dev/null
+++ b/Core/CoreTests/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagStateTests.swift
@@ -0,0 +1,154 @@
+//
+// This file is part of Canvas.
+// Copyright (C) 2025-present Instructure, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+//
+
+import Foundation
+import TestsFoundation
+@testable import Core
+import XCTest
+
+class GetFeatureFlagStateTests: CoreTestCase {
+
+ func test_request_formation() {
+ // Given
+ let context = Context(.course, id: "22343")
+
+ // Then
+ XCTAssertEqual(
+ GetFeatureFlagState(featureName: .studioEmbedImprovements, context: context).request.path,
+ GetFeatureFlagStateRequest(featureName: .studioEmbedImprovements, context: context).path
+ )
+ }
+
+ func test_writing_state_on() throws {
+ // Given
+ let context = Context(.course, id: "123")
+ let state = APIFeatureFlagState(
+ feature: "rce_studio_embed_improvements",
+ state: .on,
+ locked: false,
+ context_id: "123",
+ context_type: "course"
+ )
+
+ // When
+ let useCase = GetFeatureFlagState(featureName: .studioEmbedImprovements, context: context)
+ useCase.write(response: state, urlResponse: nil, to: databaseClient)
+
+ // Then
+ let all: [FeatureFlag] = databaseClient.fetch(scope: useCase.scope)
+ XCTAssertEqual(all.count, 1)
+
+ let flag = try XCTUnwrap(all.first)
+ XCTAssertNotNil(flag)
+ XCTAssertEqual(flag.name, "rce_studio_embed_improvements")
+ XCTAssertEqual(flag.enabled, true)
+ XCTAssertEqual(flag.context?.canvasContextID, context.canvasContextID)
+ }
+
+ func test_writing_state_off() throws {
+ // Given
+ let context = Context(.course, id: "123")
+ let state = APIFeatureFlagState(
+ feature: "rce_studio_embed_improvements",
+ state: .off,
+ locked: false,
+ context_id: "123",
+ context_type: "course"
+ )
+
+ // When
+ let useCase = GetFeatureFlagState(featureName: .studioEmbedImprovements, context: context)
+ useCase.write(response: state, urlResponse: nil, to: databaseClient)
+
+ // Then
+ let all: [FeatureFlag] = databaseClient.fetch(scope: useCase.scope)
+ XCTAssertEqual(all.count, 1)
+
+ let flag = try XCTUnwrap(all.first)
+ XCTAssertNotNil(flag)
+ XCTAssertEqual(flag.name, "rce_studio_embed_improvements")
+ XCTAssertEqual(flag.enabled, false)
+ XCTAssertEqual(flag.context?.canvasContextID, context.canvasContextID)
+ }
+
+ func test_writing_mismatch_contexts() throws {
+ // Given
+ let context = Context(.course, id: "123")
+ let state = APIFeatureFlagState(
+ feature: "rce_studio_embed_improvements",
+ state: .allowed_on,
+ locked: false,
+ context_id: "234",
+ context_type: "account"
+ )
+
+ // When
+ let useCase = GetFeatureFlagState(featureName: .studioEmbedImprovements, context: context)
+ useCase.write(response: state, urlResponse: nil, to: databaseClient)
+
+ // Then
+ let all: [FeatureFlag] = databaseClient.fetch(scope: useCase.scope)
+ XCTAssertEqual(all.count, 1)
+
+ let flag = try XCTUnwrap(all.first)
+ XCTAssertNotNil(flag)
+ XCTAssertEqual(flag.name, "rce_studio_embed_improvements")
+ XCTAssertEqual(flag.enabled, true)
+ XCTAssertEqual(flag.context?.canvasContextID, context.canvasContextID)
+ }
+
+ func test_writing_mismatch_contexts_state_off() throws {
+ // Given
+ let context = Context(.course, id: "123")
+ let state = APIFeatureFlagState(
+ feature: "rce_studio_embed_improvements",
+ state: .off,
+ locked: false,
+ context_id: "234",
+ context_type: "account"
+ )
+
+ // When
+ let useCase = GetFeatureFlagState(featureName: .studioEmbedImprovements, context: context)
+ useCase.write(response: state, urlResponse: nil, to: databaseClient)
+
+ // Then
+ let all: [FeatureFlag] = databaseClient.fetch(scope: useCase.scope)
+ XCTAssertEqual(all.count, 1)
+
+ let flag = try XCTUnwrap(all.first)
+ XCTAssertNotNil(flag)
+ XCTAssertEqual(flag.name, "rce_studio_embed_improvements")
+ XCTAssertEqual(flag.enabled, false)
+ XCTAssertEqual(flag.context?.canvasContextID, context.canvasContextID)
+ }
+
+ func test_fetching() {
+ // Given
+ let flag: FeatureFlag = databaseClient.insert()
+ flag.name = "assignments_2_student"
+ flag.enabled = true
+ flag.context = .course("11")
+
+ // WHEN
+ let store = environment.subscribe(GetFeatureFlagState(featureName: .assignmentEnhancements, context: .course("11")))
+
+ // THEN
+ XCTAssertEqual(store.first?.enabled, true)
+ }
+}
diff --git a/Core/CoreTests/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractorTests.swift b/Core/CoreTests/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractorTests.swift
new file mode 100644
index 0000000000..bcf54a3ecc
--- /dev/null
+++ b/Core/CoreTests/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractorTests.swift
@@ -0,0 +1,226 @@
+//
+// This file is part of Canvas.
+// Copyright (C) 2025-present Instructure, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+//
+
+@testable import Core
+import WebKit
+import XCTest
+import Combine
+
+class CoreWebViewStudioFeaturesInteractorTests: CoreTestCase {
+
+ private enum TestConstants {
+ static let context = Context(.course, id: "32342")
+ static let pageHTML = """
+
+
+
+
+
+
+
+
+ """
+ }
+
+ private var webView: CoreWebView!
+
+ override func setUp() {
+ super.setUp()
+ webView = CoreWebView()
+ }
+
+ override func tearDown() {
+ webView = nil
+ super.tearDown()
+ }
+
+ func testFeatureFlagOn() {
+ // Given
+ let context = TestConstants.context
+ let interactor = webView.studioFeaturesInteractor
+ let feature = FeatureFlagName.studioEmbedImprovements.rawValue
+ let request = GetFeatureFlagStateRequest(featureName: .studioEmbedImprovements, context: context)
+
+ api.mock(
+ request,
+ value: APIFeatureFlagState(
+ feature: feature,
+ state: .on,
+ locked: false,
+ context_id: context.id,
+ context_type: context.contextType.rawValue
+ )
+ )
+
+ // when
+ let exp = expectation(description: "feature updated")
+ interactor.resetFeatureFlagStore(context: context, env: environment)
+ interactor.onFeatureUpdate = {
+ exp.fulfill()
+ }
+
+ wait(for: [exp])
+
+ // Then
+ XCTAssertTrue( webView.features.contains(where: { $0 is InsertStudioOpenInDetailButtons }) )
+ }
+
+ func testFeatureFlagOff() {
+ // Given
+ let context = TestConstants.context
+ let interactor = webView.studioFeaturesInteractor
+ let feature = FeatureFlagName.studioEmbedImprovements.rawValue
+ let request = GetFeatureFlagStateRequest(featureName: .studioEmbedImprovements, context: context)
+
+ api.mock(
+ request,
+ value: APIFeatureFlagState(
+ feature: feature,
+ state: .off,
+ locked: false,
+ context_id: context.id,
+ context_type: context.contextType.rawValue
+ )
+ )
+
+ // when
+ let exp = expectation(description: "feature updated")
+ interactor.resetFeatureFlagStore(context: context, env: environment)
+ interactor.onFeatureUpdate = {
+ exp.fulfill()
+ }
+
+ wait(for: [exp])
+
+ // Then
+ XCTAssertFalse( webView.features.contains(where: { $0 is InsertStudioOpenInDetailButtons }) )
+ }
+
+ func testFeatureFlagAllowedOn() {
+ // Given
+ let context = TestConstants.context
+ let interactor = webView.studioFeaturesInteractor
+ let feature = FeatureFlagName.studioEmbedImprovements.rawValue
+ let request = GetFeatureFlagStateRequest(featureName: .studioEmbedImprovements, context: context)
+
+ api.mock(
+ request,
+ value: APIFeatureFlagState(
+ feature: feature,
+ state: .allowed_on,
+ locked: false,
+ context_id: "1234",
+ context_type: ContextType.account.rawValue
+ )
+ )
+
+ // when
+ let exp = expectation(description: "feature updated")
+ interactor.resetFeatureFlagStore(context: context, env: environment)
+ interactor.onFeatureUpdate = {
+ exp.fulfill()
+ }
+
+ wait(for: [exp])
+
+ // Then
+ XCTAssertTrue( webView.features.contains(where: { $0 is InsertStudioOpenInDetailButtons }) )
+ }
+
+ func preloadPageContent() {
+ let mockLinkDelegate = MockCoreWebViewLinkDelegate()
+ webView.linkDelegate = mockLinkDelegate
+ webView.loadHTMLString(TestConstants.pageHTML)
+
+ wait(for: [mockLinkDelegate.navigationFinishedExpectation], timeout: 10)
+
+ let exp = expectation(description: "frame-title map updated")
+ webView.studioFeaturesInteractor.onScanFinished = {
+ exp.fulfill()
+ }
+
+ wait(for: [exp])
+ }
+
+ func testFramesScanning() {
+ // Given
+ preloadPageContent()
+ let interactor = webView.studioFeaturesInteractor
+
+ // Then
+ let titleMap = interactor.videoFramesTitleMap
+ XCTAssertEqual(titleMap["https://suhaibalabsi.instructure.com/media_attachments/613046"], "Video Title 11")
+ XCTAssertEqual(titleMap["https://suhaibalabsi.instructure.com/media_attachments/546734"], "Video Title 22")
+ }
+
+ func testImmersiveViewURL_ExpandButton() {
+ // Given
+ preloadPageContent()
+ let interactor = webView.studioFeaturesInteractor
+
+ // When
+ let actionUrl = "https://suhaibalabsi.instructure.com/media_attachments/613046/immersive_view"
+ let action = MockNavigationAction(url: actionUrl, type: .other, sourceFrame: MockFrameInfo(isMainFrame: false))
+ let immersiveUrl = interactor.urlForStudioImmersiveView(of: action)
+
+ // Then
+ XCTAssertEqual(immersiveUrl?.absoluteString, "https://suhaibalabsi.instructure.com/media_attachments/613046/immersive_view?title=Video%20Title%2011&embedded=true")
+ }
+
+ func testImmersiveViewURL_DetailButton() {
+ // Given
+ preloadPageContent()
+ let interactor = webView.studioFeaturesInteractor
+
+ // When
+ let actionUrl = "https://suhaibalabsi.instructure.com/media_attachments/546734/immersive_view?title=Hello%20World"
+ let action = MockNavigationAction(url: actionUrl, type: .linkActivated, targetFrame: MockFrameInfo(isMainFrame: false))
+ let immersiveUrl = interactor.urlForStudioImmersiveView(of: action)
+
+ // Then
+ XCTAssertEqual(immersiveUrl?.absoluteString, "https://suhaibalabsi.instructure.com/media_attachments/546734/immersive_view?title=Hello%20World&embedded=true")
+ }
+
+ func testStudioImmersiveViewURL_DetailButton() {
+ // Given
+ preloadPageContent()
+ let interactor = webView.studioFeaturesInteractor
+
+ // When
+ let actionUrl = "https://suhaibalabsi.instructure.com/courses/1234/external_tools/retrieve?"
+ + "display=full_width&url=https%3A%2F%2Fsuhaibalabsi.staging.instructuremedia.com%2Flti%2Flaunch%3Fcustom_arc_display_download%3Dtrue%26custom_arc_launch_type%3Dimmersive_view%26"
+ + "custom_arc_lock_speed%3Dtrue%26custom_arc_media_id%3D7dc9ffdc-c30a-44e6-869e-a940ea66331e-1%26custom_arc_start_at%3D0%26custom_arc_transcript_downloadable%3Dtrue%26"
+ + "com_instructure_course_canvas_resource_type%3Dwiki_page.body%26com_instructure_course_canvas_resource_id%3D6408%26custom_arc_source_view_type%3Dcollaboration_embed%26"
+ + "platform_redirect_url%3Dhttps%253A%252F%252Fsuhaibalabsi.instructure.com%252Fcourses%252F991%252Fpages%252Ftest-both-embed-types%26full_win_launch_requested%3D1"
+ let action = MockNavigationAction(url: actionUrl, type: .linkActivated, targetFrame: MockFrameInfo(isMainFrame: false))
+ let immersiveUrl = interactor.urlForStudioImmersiveView(of: action)
+
+ // Then
+ XCTAssertNotNil(immersiveUrl)
+ }
+}
diff --git a/Core/CoreTests/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtonsTests.swift b/Core/CoreTests/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtonsTests.swift
new file mode 100644
index 0000000000..ec5b07bbbc
--- /dev/null
+++ b/Core/CoreTests/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtonsTests.swift
@@ -0,0 +1,93 @@
+//
+// This file is part of Canvas.
+// Copyright (C) 2025-present Instructure, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+//
+
+import Core
+import XCTest
+
+class InsertStudioOpenInDetailButtonsTests: XCTestCase {
+
+ func testInsertion() {
+ let mockLinkDelegate = MockCoreWebViewLinkDelegate()
+ let webView = CoreWebView(features: [
+ .insertStudioOpenInDetailButtons
+ ])
+
+ webView.linkDelegate = mockLinkDelegate
+ webView.loadHTMLString("""
+