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(""" +
+

+ +

+

+ +

+
+ """) + + wait(for: [mockLinkDelegate.navigationFinishedExpectation], timeout: 10) + + let checkInsertionsScript = """ + (function() { + const elements = document.querySelectorAll('.open_details_button'); + var result = []; + elements.forEach(elm => { + result.push(elm.getAttribute("href")); + }); + return result; + })() + """ + + let exp = expectation(description: "js evaluated") + webView.evaluateJavaScript(checkInsertionsScript) { result, _ in + defer { exp.fulfill() } + + let list = result as? [String] + let urls = list?.compactMap({ URL(string: $0) }) ?? [] + + guard urls.count == 2 else { + XCTFail("Expecting 2 URLs to be evaluated") + return + } + + XCTAssertEqual( + urls[0].removingQueryAndFragment().absoluteString, + "https://suhaibalabsi.instructure.com/media_attachments/613046/immersive_view" + ) + + XCTAssertEqual( + urls[1].removingQueryAndFragment().absoluteString, + "https://suhaibalabsi.instructure.com/media_attachments/546734/immersive_view" + ) + + XCTAssertEqual(urls[0].queryValue(for: "title"), "Example%20Video%20Title") + XCTAssertEqual(urls[1].queryValue(for: "title"), "Some_File_Name") + } + + wait(for: [exp]) + } + + func testFontSizeCSSProperty() { + let mockLinkDelegate = MockCoreWebViewLinkDelegate() + let webView = CoreWebView(features: [.insertStudioOpenInDetailButtons]) + webView.linkDelegate = mockLinkDelegate + webView.loadHTMLString("
Test
") + wait(for: [mockLinkDelegate.navigationFinishedExpectation], timeout: 10) + + let expectedFontSize = UIFont.scaledNamedFont(.regular14).pointSize + + let jsEvaluated = expectation(description: "JS evaluated") + + let extractCSSScript = """ + (function() { + const styles = document.head.querySelectorAll('style'); + for (let style of styles) { + const css = style.innerHTML; + if (css.includes('open_details_button')) { + const match = css.match(/\\.open_details_button\\s*\\{[^}]*font-size:\\s*([\\d.]+)px/); + return match ? match[1] : null; + } + } + return null; + })() + """ + + webView.evaluateJavaScript(extractCSSScript) { result, error in + defer { jsEvaluated.fulfill() } + + XCTAssertNil(error) + guard let fontSizeString = result as? String, + let actualFontSize = Double(fontSizeString) else { + XCTFail("Could not extract font-size value from CSS") + return + } + + XCTAssertEqual(actualFontSize, expectedFontSize, accuracy: 0.01, "Font size in CSS should match UIFont.scaledNamedFont(.regular14).pointSize") + } + + wait(for: [jsEvaluated], timeout: 10) + } +} diff --git a/Core/CoreTests/Common/CommonUI/CoreWebView/View/CoreWebViewTests.swift b/Core/CoreTests/Common/CommonUI/CoreWebView/View/CoreWebViewTests.swift index 9e12ac78d5..073cb62d66 100644 --- a/Core/CoreTests/Common/CommonUI/CoreWebView/View/CoreWebViewTests.swift +++ b/Core/CoreTests/Common/CommonUI/CoreWebView/View/CoreWebViewTests.swift @@ -123,36 +123,6 @@ class CoreWebViewTests: CoreTestCase { XCTAssertNotEqual(scrollView.contentOffset.y, 0) } - class MockNavigationAction: WKNavigationAction { - let mockRequest: URLRequest - override var request: URLRequest { - return mockRequest - } - - let mockType: WKNavigationType - override var navigationType: WKNavigationType { - return mockType - } - - let mockSourceFrame: WKFrameInfo - override var sourceFrame: WKFrameInfo { - mockSourceFrame - } - - let mockTargetFrame: WKFrameInfo? - override var targetFrame: WKFrameInfo? { - mockTargetFrame - } - - init(url: String, type: WKNavigationType) { - mockRequest = URLRequest(url: URL(string: url)!) - mockType = type - mockSourceFrame = WKFrameInfo() - mockTargetFrame = mockSourceFrame - super.init() - } - } - class MockNavigationResponse: WKNavigationResponse { let mockResponse: URLResponse override var response: URLResponse { mockResponse } @@ -421,3 +391,50 @@ private class MockA11yHelper: CoreWebViewAccessibilityHelper { receivedViewController = viewController } } + +class MockNavigationAction: WKNavigationAction { + let mockRequest: URLRequest + override var request: URLRequest { + return mockRequest + } + + let mockType: WKNavigationType + override var navigationType: WKNavigationType { + return mockType + } + + let mockSourceFrame: MockFrameInfo + let mockTargetFrame: MockFrameInfo? + + init( + url: String, + type: WKNavigationType, + sourceFrame: MockFrameInfo = MockFrameInfo(isMainFrame: true), + targetFrame: MockFrameInfo? = nil + ) { + mockRequest = URLRequest(url: URL(string: url)!) + mockType = type + mockSourceFrame = sourceFrame + mockTargetFrame = targetFrame + super.init() + } + + override var sourceFrame: WKFrameInfo { + mockSourceFrame + } + + override var targetFrame: WKFrameInfo? { + mockTargetFrame ?? sourceFrame + } +} + +class MockFrameInfo: WKFrameInfo { + + let mockIsMainFrame: Bool + init(isMainFrame: Bool) { + mockIsMainFrame = isMainFrame + super.init() + } + + override var isMainFrame: Bool { mockIsMainFrame } +} diff --git a/Parent/Parent/Courses/CourseDetailsViewController.swift b/Parent/Parent/Courses/CourseDetailsViewController.swift index e28ea06a75..708dd8e76b 100644 --- a/Parent/Parent/Courses/CourseDetailsViewController.swift +++ b/Parent/Parent/Courses/CourseDetailsViewController.swift @@ -135,6 +135,7 @@ class CourseDetailsViewController: HorizontalMenuViewController { func configureFrontPage() { let vc = CoreWebViewController() vc.webView.resetEnvironment(env) + vc.webView.setupStudioFeatures(context: .course(courseID), env: env) vc.webView.loadHTMLString(frontPages.first?.body ?? "", baseURL: frontPages.first?.htmlURL) viewControllers.append(vc) } diff --git a/Student/Student/Assignments/AssignmentDetails/StudentAssignmentDetailsViewController.swift b/Student/Student/Assignments/AssignmentDetails/StudentAssignmentDetailsViewController.swift index 3a9f94190d..45aacf6db4 100644 --- a/Student/Student/Assignments/AssignmentDetails/StudentAssignmentDetailsViewController.swift +++ b/Student/Student/Assignments/AssignmentDetails/StudentAssignmentDetailsViewController.swift @@ -238,6 +238,7 @@ class StudentAssignmentDetailsViewController: ScreenViewTrackableViewController, lockedIconImageView.image = UIImage(named: Panda.Locked.name, in: .core, compatibleWith: nil) // Routing from description + webView.setupStudioFeatures(context: .course(courseID), env: env) webView.linkDelegate = self webView.autoresizesHeight = true webView.heightAnchor.constraint(equalToConstant: 0).isActive = true diff --git a/Student/Student/Submissions/SubmissionDetails/SubmissionDetailsPresenter.swift b/Student/Student/Submissions/SubmissionDetails/SubmissionDetailsPresenter.swift index daae3d5232..0057256299 100644 --- a/Student/Student/Submissions/SubmissionDetails/SubmissionDetailsPresenter.swift +++ b/Student/Student/Submissions/SubmissionDetails/SubmissionDetailsPresenter.swift @@ -210,12 +210,14 @@ class SubmissionDetailsPresenter { if let quizID = assignment.quizID, let url = URL(string: "/courses/\(assignment.courseID)/quizzes/\(quizID)/history?version=\(selectedAttempt ?? 1)&headless=1", relativeTo: env.api.baseURL) { let controller = CoreWebViewController(features: [.invertColorsInDarkMode]) + controller.webView.setupStudioFeatures(context: .course(assignment.courseID), env: env) controller.webView.accessibilityIdentifier = "SubmissionDetails.onlineQuizWebView" controller.webView.load(URLRequest(url: url)) return controller } case .some(.online_text_entry): let controller = CoreWebViewController() + controller.webView.setupStudioFeatures(context: .course(assignment.courseID), env: env) controller.webView.accessibilityIdentifier = "SubmissionDetails.onlineTextEntryWebView" controller.webView.loadHTMLString(submission.body ?? "") return controller @@ -245,6 +247,7 @@ class SubmissionDetailsPresenter { ) } let controller = CoreWebViewController(features: [.invertColorsInDarkMode]) + controller.webView.setupStudioFeatures(context: .course(assignment.courseID), env: env) controller.webView.accessibilityIdentifier = "SubmissionDetails.webView" controller.webView.load(URLRequest(url: url)) return controller @@ -253,6 +256,7 @@ class SubmissionDetailsPresenter { guard let previewUrl = submission.previewUrl else { break } let controller = CoreWebViewController(features: [.invertColorsInDarkMode]) + controller.webView.setupStudioFeatures(context: .course(assignment.courseID), env: env) controller.webView.accessibilityIdentifier = "SubmissionDetails.discussionWebView" controller.webView.load(URLRequest(url: previewUrl)) return controller diff --git a/Student/StudentUnitTests/Assignments/AssignmentDetails/StudentAssignmentDetailsPresenterTests.swift b/Student/StudentUnitTests/Assignments/AssignmentDetails/StudentAssignmentDetailsPresenterTests.swift index 9b9a8e51cc..1a7e82408f 100644 --- a/Student/StudentUnitTests/Assignments/AssignmentDetails/StudentAssignmentDetailsPresenterTests.swift +++ b/Student/StudentUnitTests/Assignments/AssignmentDetails/StudentAssignmentDetailsPresenterTests.swift @@ -567,7 +567,7 @@ class StudentAssignmentDetailsPresenterTests: StudentTestCase { func testAttemptPickerActiveOnMultipleSubmissionsWhenFlagIsActive() { Submission.make(from: .make(attempt: 1, id: "1")) Submission.make(from: .make(attempt: 2, id: "2")) - FeatureFlag.make(name: APIFeatureFlag.Key.assignmentEnhancements.rawValue, enabled: true) + FeatureFlag.make(name: FeatureFlagName.assignmentEnhancements.rawValue, enabled: true) waitUntil(shouldFail: true) { resultingAttemptPickerActiveState == true @@ -577,14 +577,14 @@ class StudentAssignmentDetailsPresenterTests: StudentTestCase { func testAttemptPickerDisabledOnMultipleSubmissionsWhenFlagIsInactive() { Submission.make(from: .make(attempt: 1, id: "1")) Submission.make(from: .make(attempt: 2, id: "2")) - FeatureFlag.make(name: APIFeatureFlag.Key.assignmentEnhancements.rawValue, enabled: false) + FeatureFlag.make(name: FeatureFlagName.assignmentEnhancements.rawValue, enabled: false) XCTAssertEqual(resultingAttemptPickerActiveState, false) } func testAttemptPickerDisabledOnSingleSubmissionWhenFlagIsActive() { Submission.make(from: .make(attempt: 1, id: "1")) - FeatureFlag.make(name: APIFeatureFlag.Key.assignmentEnhancements.rawValue, enabled: true) + FeatureFlag.make(name: FeatureFlagName.assignmentEnhancements.rawValue, enabled: true) XCTAssertEqual(resultingAttemptPickerActiveState, false) } @@ -593,7 +593,7 @@ class StudentAssignmentDetailsPresenterTests: StudentTestCase { Assignment.make() let submission1 = Submission.make(from: .make(attempt: 1, id: "1", score: 1)) let submission2 = Submission.make(from: .make(attempt: 2, id: "2", score: 2)) - FeatureFlag.make(name: APIFeatureFlag.Key.assignmentEnhancements.rawValue, enabled: true) + FeatureFlag.make(name: FeatureFlagName.assignmentEnhancements.rawValue, enabled: true) waitUntil(shouldFail: true) { resultingAttemptPickerItems?.count == 2 @@ -615,7 +615,7 @@ class StudentAssignmentDetailsPresenterTests: StudentTestCase { Assignment.make() Submission.make(from: .make(attempt: 1, id: "1", score: 1)) Submission.make(from: .make(attempt: 2, id: "2", score: 2)) - FeatureFlag.make(name: APIFeatureFlag.Key.assignmentEnhancements.rawValue, enabled: true) + FeatureFlag.make(name: FeatureFlagName.assignmentEnhancements.rawValue, enabled: true) waitUntil(shouldFail: true) { resultingAttemptPickerItems?.count == 2