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

+ +

+

+ +

+
+ """ + } + + private var webView: CoreWebView! + + override func setUp() { + super.setUp() + webView = CoreWebView() + } + + override func tearDown() { + webView = nil + super.tearDown() + } + + func testFeatureFlagOn() { + // Given + let context = TestConstants.context + let interactor = webView.studioFeaturesInteractor + let feature = FeatureFlagName.studioEmbedImprovements.rawValue + let request = GetFeatureFlagStateRequest(featureName: .studioEmbedImprovements, context: context) + + api.mock( + request, + value: APIFeatureFlagState( + feature: feature, + state: .on, + locked: false, + context_id: context.id, + context_type: context.contextType.rawValue + ) + ) + + // when + let exp = expectation(description: "feature updated") + interactor.resetFeatureFlagStore(context: context, env: environment) + interactor.onFeatureUpdate = { + exp.fulfill() + } + + wait(for: [exp]) + + // Then + XCTAssertTrue( webView.features.contains(where: { $0 is InsertStudioOpenInDetailButtons }) ) + } + + func testFeatureFlagOff() { + // Given + let context = TestConstants.context + let interactor = webView.studioFeaturesInteractor + let feature = FeatureFlagName.studioEmbedImprovements.rawValue + let request = GetFeatureFlagStateRequest(featureName: .studioEmbedImprovements, context: context) + + api.mock( + request, + value: APIFeatureFlagState( + feature: feature, + state: .off, + locked: false, + context_id: context.id, + context_type: context.contextType.rawValue + ) + ) + + // when + let exp = expectation(description: "feature updated") + interactor.resetFeatureFlagStore(context: context, env: environment) + interactor.onFeatureUpdate = { + exp.fulfill() + } + + wait(for: [exp]) + + // Then + XCTAssertFalse( webView.features.contains(where: { $0 is InsertStudioOpenInDetailButtons }) ) + } + + func testFeatureFlagAllowedOn() { + // Given + let context = TestConstants.context + let interactor = webView.studioFeaturesInteractor + let feature = FeatureFlagName.studioEmbedImprovements.rawValue + let request = GetFeatureFlagStateRequest(featureName: .studioEmbedImprovements, context: context) + + api.mock( + request, + value: APIFeatureFlagState( + feature: feature, + state: .allowed_on, + locked: false, + context_id: "1234", + context_type: ContextType.account.rawValue + ) + ) + + // when + let exp = expectation(description: "feature updated") + interactor.resetFeatureFlagStore(context: context, env: environment) + interactor.onFeatureUpdate = { + exp.fulfill() + } + + wait(for: [exp]) + + // Then + XCTAssertTrue( webView.features.contains(where: { $0 is InsertStudioOpenInDetailButtons }) ) + } + + func preloadPageContent() { + let mockLinkDelegate = MockCoreWebViewLinkDelegate() + webView.linkDelegate = mockLinkDelegate + webView.loadHTMLString(TestConstants.pageHTML) + + wait(for: [mockLinkDelegate.navigationFinishedExpectation], timeout: 10) + + let exp = expectation(description: "frame-title map updated") + webView.studioFeaturesInteractor.onScanFinished = { + exp.fulfill() + } + + wait(for: [exp]) + } + + func testFramesScanning() { + // Given + preloadPageContent() + let interactor = webView.studioFeaturesInteractor + + // Then + let titleMap = interactor.videoFramesTitleMap + XCTAssertEqual(titleMap["https://suhaibalabsi.instructure.com/media_attachments/613046"], "Video Title 11") + XCTAssertEqual(titleMap["https://suhaibalabsi.instructure.com/media_attachments/546734"], "Video Title 22") + } + + func testImmersiveViewURL_ExpandButton() { + // Given + preloadPageContent() + let interactor = webView.studioFeaturesInteractor + + // When + let actionUrl = "https://suhaibalabsi.instructure.com/media_attachments/613046/immersive_view" + let action = MockNavigationAction(url: actionUrl, type: .other, sourceFrame: MockFrameInfo(isMainFrame: false)) + let immersiveUrl = interactor.urlForStudioImmersiveView(of: action) + + // Then + XCTAssertEqual(immersiveUrl?.absoluteString, "https://suhaibalabsi.instructure.com/media_attachments/613046/immersive_view?title=Video%20Title%2011&embedded=true") + } + + func testImmersiveViewURL_DetailButton() { + // Given + preloadPageContent() + let interactor = webView.studioFeaturesInteractor + + // When + let actionUrl = "https://suhaibalabsi.instructure.com/media_attachments/546734/immersive_view?title=Hello%20World" + let action = MockNavigationAction(url: actionUrl, type: .linkActivated, targetFrame: MockFrameInfo(isMainFrame: false)) + let immersiveUrl = interactor.urlForStudioImmersiveView(of: action) + + // Then + XCTAssertEqual(immersiveUrl?.absoluteString, "https://suhaibalabsi.instructure.com/media_attachments/546734/immersive_view?title=Hello%20World&embedded=true") + } + + func testStudioImmersiveViewURL_DetailButton() { + // Given + preloadPageContent() + let interactor = webView.studioFeaturesInteractor + + // When + let actionUrl = "https://suhaibalabsi.instructure.com/courses/1234/external_tools/retrieve?" + + "display=full_width&url=https%3A%2F%2Fsuhaibalabsi.staging.instructuremedia.com%2Flti%2Flaunch%3Fcustom_arc_display_download%3Dtrue%26custom_arc_launch_type%3Dimmersive_view%26" + + "custom_arc_lock_speed%3Dtrue%26custom_arc_media_id%3D7dc9ffdc-c30a-44e6-869e-a940ea66331e-1%26custom_arc_start_at%3D0%26custom_arc_transcript_downloadable%3Dtrue%26" + + "com_instructure_course_canvas_resource_type%3Dwiki_page.body%26com_instructure_course_canvas_resource_id%3D6408%26custom_arc_source_view_type%3Dcollaboration_embed%26" + + "platform_redirect_url%3Dhttps%253A%252F%252Fsuhaibalabsi.instructure.com%252Fcourses%252F991%252Fpages%252Ftest-both-embed-types%26full_win_launch_requested%3D1" + let action = MockNavigationAction(url: actionUrl, type: .linkActivated, targetFrame: MockFrameInfo(isMainFrame: false)) + let immersiveUrl = interactor.urlForStudioImmersiveView(of: action) + + // Then + XCTAssertNotNil(immersiveUrl) + } +} diff --git a/Core/CoreTests/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtonsTests.swift b/Core/CoreTests/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtonsTests.swift new file mode 100644 index 0000000000..ec5b07bbbc --- /dev/null +++ b/Core/CoreTests/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtonsTests.swift @@ -0,0 +1,93 @@ +// +// This file is part of Canvas. +// Copyright (C) 2025-present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import Core +import XCTest + +class InsertStudioOpenInDetailButtonsTests: XCTestCase { + + func testInsertion() { + let mockLinkDelegate = MockCoreWebViewLinkDelegate() + let webView = CoreWebView(features: [ + .insertStudioOpenInDetailButtons + ]) + + webView.linkDelegate = mockLinkDelegate + webView.loadHTMLString(""" +
+

+ +

+

+ +

+
+ """) + + 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]) + } +} 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..b9700fd85a 100644 --- a/Student/Student/Assignments/AssignmentDetails/StudentAssignmentDetailsViewController.swift +++ b/Student/Student/Assignments/AssignmentDetails/StudentAssignmentDetailsViewController.swift @@ -153,7 +153,7 @@ class StudentAssignmentDetailsViewController: ScreenViewTrackableViewController, } private var env: AppEnvironment = .defaultValue - private let webView = CoreWebView() + private let webView = CoreWebView(features: [.canvasLTIPostMessageHandler]) private let isLeftToRightLayout: Bool = UIApplication.shared.userInterfaceLayoutDirection == .leftToRight private weak var gradeBorderLayer: CAShapeLayer? private var offlineModeInteractor: OfflineModeInteractor? @@ -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