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..da72d65145
--- /dev/null
+++ b/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractor.swift
@@ -0,0 +1,195 @@
+//
+// 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 immersive video player title. This mainly useful when triggering the player
+ /// from a button that's internal to video-frame. (`Expand` button)
+ 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 isDetailsLink =
+ navigationType == .linkActivated
+ && path.contains("/media_attachments/") == true
+ && path.hasSuffix("/immersive_view") == true
+ && (targetFrame?.isMainFrame ?? false) == false
+
+ return isExpandLink || isDetailsLink
+ }
+}
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/InsertStudioOpenDetailViewButton.swift b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift
new file mode 100644
index 0000000000..6fd07db80b
--- /dev/null
+++ b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift
@@ -0,0 +1,175 @@
+//
+// 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 let insertStyle: String = {
+ let fontSize = UIFont.scaledNamedFont(.regular14).pointSize
+ let css = """
+ p[ios-injected] {
+ text-align: center;
+ }
+
+ .open_details_button {
+ font-weight: 400;
+ font-size: \(fontSize)px;
+ 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%;
+ }
+ """
+
+ let cssString = css.components(separatedBy: .newlines).joined()
+ return """
+ (() => {
+ var element = document.createElement('style');
+ element.innerHTML = '\(cssString)';
+ document.head.appendChild(element);
+ })()
+ """
+ }()
+
+ private let insertScript: 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 """
+ function escapeHTML(text) {
+ return text
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/'/g, ''')
+ .replace(/"/g, '"')
+ }
+
+ 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 insertStudioDetailsLinks() {
+ const frameElements = document.querySelectorAll('iframe[data-media-id]');
+
+ frameElements.forEach(elm => {
+ 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");
+
+ let title = videoTitle ?? ariaTitle;
+ let frameLink = findCanvasUploadLink(elm, title);
+
+ 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";
+
+ const icon = document.createElement('div');
+ icon.className = "open_details_button_icon";
+ icon.innerHTML = \(CoreWebView.jsString(iconSVG));
+
+ const detailButton = document.createElement('a');
+ detailButton.className = "open_details_button";
+ detailButton.href = frameLink;
+ detailButton.target = "_blank";
+ detailButton.textContent = escapeHTML(\(CoreWebView.jsString(title)));
+
+ buttonContainer.appendChild(icon);
+ buttonContainer.appendChild(detailButton);
+ newParagraph.appendChild(buttonContainer);
+
+ elm.insertAdjacentElement('afterend', newLine);
+ newLine.insertAdjacentElement('afterend', newParagraph);
+ });
+ }
+
+ insertStudioDetailsLinks();
+ window.addEventListener("DOMContentLoaded", insertStudioDetailsLinks);
+ """
+ }()
+
+ public override init() {}
+
+ override func apply(on webView: CoreWebView) {
+ webView.addScript(insertStyle)
+ webView.addScript(insertScript)
+ }
+
+ override func remove(from webView: CoreWebView) {
+ webView.removeScript(insertStyle)
+ webView.removeScript(insertScript)
+ }
+}
+
+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..d3c23b20fa 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
@@ -528,6 +556,18 @@ extension CoreWebView: WKNavigationDelegate {
return decisionHandler(.cancel)
}
+ // 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)
+ }
+
// Forward decision to delegate
if action.navigationType == .linkActivated, let url = action.request.url,
linkDelegate?.handleLink(url) == true {
@@ -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..938813c5ec 100644
--- a/Core/Core/Features/LTI/View/StudioViewController.swift
+++ b/Core/Core/Features/LTI/View/StudioViewController.swift
@@ -25,7 +25,7 @@ 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") ?? 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..f2e6c24f56 100644
--- a/Core/Core/Features/Pages/PageDetails/PageDetailsViewController.swift
+++ b/Core/Core/Features/Pages/PageDetails/PageDetailsViewController.swift
@@ -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..b3b3114349 100644
--- a/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizDetailsViewController.swift
+++ b/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizDetailsViewController.swift
@@ -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/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..6bbbf6fad4
--- /dev/null
+++ b/Core/CoreTests/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractorTests.swift
@@ -0,0 +1,208 @@
+//
+// 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")
+ }
+}
diff --git a/Core/CoreTests/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButtonTests.swift b/Core/CoreTests/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButtonTests.swift
new file mode 100644
index 0000000000..d69fe1d152
--- /dev/null
+++ b/Core/CoreTests/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButtonTests.swift
@@ -0,0 +1,134 @@
+//
+// 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 InsertStudioOpenDetailViewButtonTests: XCTestCase {
+
+ func testInsertion() {
+ let mockLinkDelegate = MockCoreWebViewLinkDelegate()
+ let webView = CoreWebView(features: [
+ .insertStudioOpenInDetailButtons
+ ])
+
+ webView.linkDelegate = mockLinkDelegate
+ webView.loadHTMLString("""
+