From 6d96ddfb5e5f9a0fab7ea720be7cd453cb95ec98 Mon Sep 17 00:00:00 2001 From: Suhaib Al-Absi Date: Mon, 10 Nov 2025 02:35:01 +0300 Subject: [PATCH 01/45] Fetching feature flag --- .../FeatureFlags/FeatureFlag.swift | 19 ++++- .../FeatureFlags/GetEnabledFeatureFlags.swift | 2 +- .../FeatureFlags/GetFeatureFlagState.swift | 57 ++++++++++++++ .../GetFeatureFlagStateRequest.swift | 65 ++++++++++++++++ .../Delegates/CoreWebViewLinkDelegate.swift | 1 + .../Features/InsertStudioDetailView.swift | 77 +++++++++++++++++++ .../CoreWebView/View/CoreWebView.swift | 47 ++++++++++- .../View/CoreWebViewController.swift | 6 ++ .../CommonUI/SwiftUIViews/WebView.swift | 9 +++ .../Extensions/Foundation/URLExtensions.swift | 4 + .../DiscussionDetailsViewController.swift | 5 ++ .../DiscussionReplyViewController.swift | 4 + .../FileDetailsViewController.swift | 4 + Core/Core/Features/LTI/LTITools.swift | 3 +- .../LTI/View/StudioViewController.swift | 5 +- .../PageDetailsViewController.swift | 2 + .../StudentQuizDetailsViewController.swift | 4 + .../StudentQuizWebViewController.swift | 4 + .../Syllabus/SyllabusTabViewController.swift | 4 + .../Syllabus/SyllabusViewController.swift | 4 + Core/Core/Resources/Localizable.xcstrings | 4 + .../FeatureFlags/FeatureFlagTests.swift | 2 +- .../View/ParentSubmissionViewController.swift | 2 + .../Courses/CourseDetailsViewController.swift | 1 + ...udentAssignmentDetailsViewController.swift | 2 + .../SubmissionDetailsPresenter.swift | 4 + .../RubricViewController.swift | 2 + ...udentAssignmentDetailsPresenterTests.swift | 10 +-- 28 files changed, 339 insertions(+), 14 deletions(-) create mode 100644 Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagState.swift create mode 100644 Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagStateRequest.swift create mode 100644 Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioDetailView.swift 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..422ee63a4f --- /dev/null +++ b/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagState.swift @@ -0,0 +1,57 @@ +// +// 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: APIUseCase { + 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 let response = response else { return } + FeatureFlag.save(response, 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..4ca3ee1102 --- /dev/null +++ b/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagStateRequest.swift @@ -0,0 +1,65 @@ +// +// 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 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" +} + +// MARK: - Response + +public struct APIFeatureFlagState: Codable { + + public enum State: String, Codable { + case allowed + case allowed_on + case on + case off + } + + public let feature: String + public let state: State + public let locked: Bool + + private let context_id: String + private let context_type: String + + public var canvasContextID: String { + return "\(context_type)_\(context_id)" + } +} diff --git a/Core/Core/Common/CommonUI/CoreWebView/Model/Delegates/CoreWebViewLinkDelegate.swift b/Core/Core/Common/CommonUI/CoreWebView/Model/Delegates/CoreWebViewLinkDelegate.swift index bdd7e6331d..251d06fe8e 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/Model/Delegates/CoreWebViewLinkDelegate.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/Model/Delegates/CoreWebViewLinkDelegate.swift @@ -20,6 +20,7 @@ import WebKit public protocol CoreWebViewLinkDelegate: AnyObject { var routeLinksFrom: UIViewController { get } + var coreWebViewFeaturesContext: Context? { get } func handleLink(_ url: URL) -> Bool func finishedNavigation() diff --git a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioDetailView.swift b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioDetailView.swift new file mode 100644 index 0000000000..8083cec64a --- /dev/null +++ b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioDetailView.swift @@ -0,0 +1,77 @@ +// +// 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 . +// + +class InsertStudioDetailView: CoreWebViewFeature { + + private let insertScript: String = { + let title = String(localized: "Open in Detail View", bundle: .core) + + return """ + function insertStudioDetailsLinks() { + const frameElements = document.querySelectorAll('iframe[data-media-id]'); + + frameElements.forEach(elm => { + let next = elm.nextElementSibling.nextElementSibling; + let wasInjected = next.getAttribute("web-injected"); + + if(wasInjected == 1) { return } + + const videoTitle = elm.getAttribute("title"); + var frameLink = elm.getAttribute("src"); + frameLink = frameLink.replace("media_attachments_iframe", "media_attachments"); + + var linkSuffix = "/immersive_view"; + if(videoTitle){ + linkSuffix = "/immersive_view?title=" + encodeURIComponent(videoTitle); + } + + const newLine = document.createElement('br'); + const newParagraph = document.createElement('p'); + newParagraph.setAttribute("web-injected", 1); + + const detailButton = document.createElement('a'); + detailButton.className = "details_view_link"; + detailButton.href = frameLink + linkSuffix; + detailButton.target = "_detail_view"; + detailButton.textContent = '\(title)'; + + newParagraph.appendChild(detailButton); + + elm.insertAdjacentElement('afterend', newLine); + newLine.insertAdjacentElement('afterend', newParagraph); + }); + } + + insertStudioDetailsLinks(); + window.addEventListener("DOMContentLoaded", insertStudioDetailsLinks); + """ + }() + + public override init() {} + + override func apply(on webView: CoreWebView) { + webView.addScript(insertScript) + } +} + +public extension CoreWebViewFeature { + + static var insertStudioDetailView: CoreWebViewFeature { + InsertStudioDetailView() + } +} diff --git a/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift b/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift index 62c2c0827f..2c1dba81b4 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift @@ -44,7 +44,26 @@ open class CoreWebView: WKWebView { public static let processPool = WKProcessPool() @IBInspectable public var autoresizesHeight: Bool = false - public weak var linkDelegate: CoreWebViewLinkDelegate? + + public weak var linkDelegate: CoreWebViewLinkDelegate? { + didSet { + + guard let context = self.linkDelegate?.coreWebViewFeaturesContext else { + studioImprovementsFlag = nil + updateStudioFeatures() + return + } + + studioImprovementsFlag = env.subscribe( + GetFeatureFlagState(featureName: .studioEmbedImprovements, context: context) + ) { [weak self] in + self?.updateStudioFeatures() + } + + studioImprovementsFlag?.refresh() + } + } + public weak var sizeDelegate: CoreWebViewSizeDelegate? public weak var errorDelegate: CoreWebViewErrorDelegate? public var isLinkNavigationEnabled = true @@ -71,6 +90,8 @@ open class CoreWebView: WKWebView { private var env: AppEnvironment = .shared private var subscriptions = Set() + private var studioImprovementsFlag: Store? + public required init?(coder: NSCoder) { super.init(coder: coder) setup() @@ -454,6 +475,16 @@ open class CoreWebView: WKWebView { ) .store(in: &subscriptions) } + + func updateStudioFeatures() { + let isStudioImprovementsEnabled = studioImprovementsFlag?.first?.enabled ?? false + if isStudioImprovementsEnabled { + addFeature(.insertStudioDetailView) + } else if features.contains(where: { $0 is InsertStudioDetailView }) { + features.removeAll(where: { $0 is InsertStudioDetailView }) + reload() + } + } } // MARK: - WKNavigationDelegate @@ -465,6 +496,14 @@ extension CoreWebView: WKNavigationDelegate { decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void ) { + + print() + print("HANDLING ACTION: ") + print(action.request) + print(action.navigationType == .linkActivated) + print(action.navigationType == .other) + print() + if action.navigationType == .linkActivated && !isLinkNavigationEnabled { decisionHandler(.cancel) return @@ -823,6 +862,12 @@ extension CoreWebView { content: String?, originalBaseURL: URL? ) { + + print() + print("HTML CONTENT LOADING: ") + print(content) + print() + if let filePath, isOffline == true, FileManager.default.fileExists(atPath: filePath.path) { loadFileURL( diff --git a/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebViewController.swift b/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebViewController.swift index 19e096fdde..611a287668 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebViewController.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebViewController.swift @@ -22,6 +22,8 @@ import UIKit public class CoreWebViewController: UIViewController, CoreWebViewLinkDelegate { public var webView: CoreWebView + public var featuresContext: Context? + var limitedInteractionView: NotificationView? private var subscriptions = Set() @@ -88,4 +90,8 @@ public class CoreWebViewController: UIViewController, CoreWebViewLinkDelegate { } .store(in: &subscriptions) } + + public var coreWebViewFeaturesContext: Context? { + featuresContext + } } diff --git a/Core/Core/Common/CommonUI/SwiftUIViews/WebView.swift b/Core/Core/Common/CommonUI/SwiftUIViews/WebView.swift index 5b503ac82d..f67189e400 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 { @@ -247,6 +254,8 @@ extension WebView { public var routeLinksFrom: UIViewController { view.controller.value } + public var coreWebViewFeaturesContext: Context? { view.featuresContext } + public func handleLink(_ url: URL) -> Bool { if let handleLink = view.handleLink { return handleLink(url) 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/Features/Discussions/DiscussionDetails/DiscussionDetailsViewController.swift b/Core/Core/Features/Discussions/DiscussionDetails/DiscussionDetailsViewController.swift index 6194521392..f02765f192 100644 --- a/Core/Core/Features/Discussions/DiscussionDetails/DiscussionDetailsViewController.swift +++ b/Core/Core/Features/Discussions/DiscussionDetails/DiscussionDetailsViewController.swift @@ -613,6 +613,11 @@ extension DiscussionDetailsViewController: UIScrollViewDelegate { } extension DiscussionDetailsViewController: CoreWebViewLinkDelegate { + + public var coreWebViewFeaturesContext: Context? { + context + } + 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..125eec7fe1 100644 --- a/Core/Core/Features/Discussions/DiscussionReply/DiscussionReplyViewController.swift +++ b/Core/Core/Features/Discussions/DiscussionReply/DiscussionReplyViewController.swift @@ -390,4 +390,8 @@ extension DiscussionReplyViewController: CoreWebViewLinkDelegate { } return true } + + public var coreWebViewFeaturesContext: Context? { + context + } } diff --git a/Core/Core/Features/Files/View/FileDetails/FileDetailsViewController.swift b/Core/Core/Features/Files/View/FileDetails/FileDetailsViewController.swift index 3114fe33d9..8af4bf3211 100644 --- a/Core/Core/Features/Files/View/FileDetails/FileDetailsViewController.swift +++ b/Core/Core/Features/Files/View/FileDetails/FileDetailsViewController.swift @@ -417,6 +417,10 @@ public class FileDetailsViewController: ScreenViewTrackableViewController, CoreW } } } + + public var coreWebViewFeaturesContext: Context? { + context + } } // MARK: - URLSessionDownloadDelegate diff --git a/Core/Core/Features/LTI/LTITools.swift b/Core/Core/Features/LTI/LTITools.swift index 00cd2f45f4..a8b5cc3453 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.featuresContext = context 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..528dd6896b 100644 --- a/Core/Core/Features/LTI/View/StudioViewController.swift +++ b/Core/Core/Features/LTI/View/StudioViewController.swift @@ -25,7 +25,10 @@ class StudioViewController: UINavigationController { let controller = CoreWebViewController() controller.webView.load(URLRequest(url: url)) controller.addDoneButton() - controller.title = String(localized: "Studio", bundle: .core) + + + print( url.queryValue(for: "title") ) + 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..08589723dc 100644 --- a/Core/Core/Features/Pages/PageDetails/PageDetailsViewController.swift +++ b/Core/Core/Features/Pages/PageDetails/PageDetailsViewController.swift @@ -207,6 +207,8 @@ open class PageDetailsViewController: UIViewController, ColoredNavViewProtocol, extension PageDetailsViewController: CoreWebViewLinkDelegate { + public var coreWebViewFeaturesContext: Context? { context } + public func handleLink(_ url: URL) -> Bool { env.router.route(to: url, from: self) return true diff --git a/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizDetailsViewController.swift b/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizDetailsViewController.swift index 00fb78db2e..823cfb0000 100644 --- a/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizDetailsViewController.swift +++ b/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizDetailsViewController.swift @@ -224,4 +224,8 @@ public class StudentQuizDetailsViewController: ScreenViewTrackableViewController env.router.route(to: url, from: self, options: .modal(embedInNav: true)) } } + + public var coreWebViewFeaturesContext: Context? { + .course(courseID) + } } diff --git a/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizWebViewController.swift b/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizWebViewController.swift index d96d5e4973..024b273d0f 100644 --- a/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizWebViewController.swift +++ b/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizWebViewController.swift @@ -115,4 +115,8 @@ extension StudentQuizWebViewController: CoreWebViewLinkDelegate { env.router.route(to: url, from: self) return true } + + public var coreWebViewFeaturesContext: Context? { + .course(courseID) + } } diff --git a/Core/Core/Features/Syllabus/SyllabusTabViewController.swift b/Core/Core/Features/Syllabus/SyllabusTabViewController.swift index 230bf5e256..3f425eef63 100644 --- a/Core/Core/Features/Syllabus/SyllabusTabViewController.swift +++ b/Core/Core/Features/Syllabus/SyllabusTabViewController.swift @@ -129,6 +129,10 @@ open class SyllabusTabViewController: ScreenViewTrackableHorizontalMenuViewContr env.router.route( to: "\(context?.pathComponent ?? "")/syllabus/edit", from: self, options: .modal(isDismissable: false, embedInNav: true)) } + + public var coreWebViewFeaturesContext: Context? { + .course(courseID) + } } extension SyllabusTabViewController: HorizontalPagedMenuDelegate { diff --git a/Core/Core/Features/Syllabus/SyllabusViewController.swift b/Core/Core/Features/Syllabus/SyllabusViewController.swift index 4c997da6dd..c8f4ff2457 100644 --- a/Core/Core/Features/Syllabus/SyllabusViewController.swift +++ b/Core/Core/Features/Syllabus/SyllabusViewController.swift @@ -69,4 +69,8 @@ open class SyllabusViewController: UIViewController, CoreWebViewLinkDelegate { self?.refreshControl.endRefreshing() } } + + public var coreWebViewFeaturesContext: Context? { + .course(courseID) + } } diff --git a/Core/Core/Resources/Localizable.xcstrings b/Core/Core/Resources/Localizable.xcstrings index 3234fd8edd..e511518332 100644 --- a/Core/Core/Resources/Localizable.xcstrings +++ b/Core/Core/Resources/Localizable.xcstrings @@ -259042,6 +259042,10 @@ } } }, + "Open in Detail View" : { + "comment" : "JavaScript to be injected into web views to add buttons that allow users to open embedded videos in a separate, detailed view.", + "isCommentAutoGenerated" : true + }, "Open in Safari" : { "localizations" : { "ar" : { diff --git a/Core/CoreTests/Common/CommonModels/AppEnvironment/FeatureFlags/FeatureFlagTests.swift b/Core/CoreTests/Common/CommonModels/AppEnvironment/FeatureFlags/FeatureFlagTests.swift index a671ad677c..77e8f48f73 100644 --- a/Core/CoreTests/Common/CommonModels/AppEnvironment/FeatureFlags/FeatureFlagTests.swift +++ b/Core/CoreTests/Common/CommonModels/AppEnvironment/FeatureFlags/FeatureFlagTests.swift @@ -22,6 +22,6 @@ import XCTest class FeatureFlagTests: XCTestCase { func testFeatureFlagKeys() { - XCTAssertEqual(APIFeatureFlag.Key.assignmentEnhancements.rawValue, "assignments_2_student") + XCTAssertEqual(FeatureFlagName.assignmentEnhancements.rawValue, "assignments_2_student") } } diff --git a/Parent/Parent/Assignments/View/ParentSubmissionViewController.swift b/Parent/Parent/Assignments/View/ParentSubmissionViewController.swift index 0c12fb99e9..86e369e18b 100644 --- a/Parent/Parent/Assignments/View/ParentSubmissionViewController.swift +++ b/Parent/Parent/Assignments/View/ParentSubmissionViewController.swift @@ -197,4 +197,6 @@ extension ParentSubmissionViewController { ) viewModel.router.show(shareSheet, from: self, options: routeOptions) } + + var coreWebViewFeaturesContext: Context? { nil } } diff --git a/Parent/Parent/Courses/CourseDetailsViewController.swift b/Parent/Parent/Courses/CourseDetailsViewController.swift index e28ea06a75..f4a0999a73 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.featuresContext = .course(courseID) 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 dc964f1e34..6dc1d03ec7 100644 --- a/Student/Student/Assignments/AssignmentDetails/StudentAssignmentDetailsViewController.swift +++ b/Student/Student/Assignments/AssignmentDetails/StudentAssignmentDetailsViewController.swift @@ -768,6 +768,8 @@ extension StudentAssignmentDetailsViewController: CoreWebViewLinkDelegate { guard let presenter = presenter else { return false } return presenter.route(to: url, from: self) } + + public var coreWebViewFeaturesContext: Context? { .course(courseID) } } // MARK: - Events diff --git a/Student/Student/Submissions/SubmissionDetails/SubmissionDetailsPresenter.swift b/Student/Student/Submissions/SubmissionDetails/SubmissionDetailsPresenter.swift index daae3d5232..cfa413b1af 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.featuresContext = .course(assignment.courseID) controller.webView.accessibilityIdentifier = "SubmissionDetails.onlineQuizWebView" controller.webView.load(URLRequest(url: url)) return controller } case .some(.online_text_entry): let controller = CoreWebViewController() + controller.featuresContext = .course(assignment.courseID) controller.webView.accessibilityIdentifier = "SubmissionDetails.onlineTextEntryWebView" controller.webView.loadHTMLString(submission.body ?? "") return controller @@ -245,6 +247,7 @@ class SubmissionDetailsPresenter { ) } let controller = CoreWebViewController(features: [.invertColorsInDarkMode]) + controller.featuresContext = .course(assignment.courseID) 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.featuresContext = .course(assignment.courseID) controller.webView.accessibilityIdentifier = "SubmissionDetails.discussionWebView" controller.webView.load(URLRequest(url: previewUrl)) return controller diff --git a/Student/Student/Submissions/SubmissionRubric/RubricViewController.swift b/Student/Student/Submissions/SubmissionRubric/RubricViewController.swift index 75415f1f6a..e68763384b 100644 --- a/Student/Student/Submissions/SubmissionRubric/RubricViewController.swift +++ b/Student/Student/Submissions/SubmissionRubric/RubricViewController.swift @@ -224,6 +224,8 @@ extension RubricViewController: CoreWebViewLinkDelegate { } return true } + + public var coreWebViewFeaturesContext: Context? { .course(presenter.courseID) } } extension RubricViewController: RubricViewProtocol { 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 From 7210c7bba9ce4833fb7d4790acb4b773f1260eeb Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Tue, 14 Oct 2025 13:37:35 +0200 Subject: [PATCH 02/45] Add handler to present immersive view in a dedicated modal. --- .../CoreWebView/View/CoreWebView.swift | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift b/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift index 2c1dba81b4..c12d9901a9 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift @@ -568,6 +568,11 @@ extension CoreWebView: WKNavigationDelegate { return decisionHandler(.cancel) } + // Handle Studio Immersive Player links (media_attachments/:id/immersive_view) + if action.handleStudioImmersiveViewIfNeeded(from: linkDelegate?.routeLinksFrom, router: env.router) { + return decisionHandler(.cancel) + } + // Forward decision to delegate if action.navigationType == .linkActivated, let url = action.request.url, linkDelegate?.handleLink(url) == true { @@ -893,4 +898,25 @@ extension WKNavigationAction { var isCanvasUserContentLinkTap: Bool { navigationType == .linkActivated && request.url?.host()?.hasSuffix(".canvas-user-content.com") == true } + + var isStudioImmersiveViewLinkTap: Bool { + navigationType == .other && + request.url?.path.contains("/media_attachments/") == true && + request.url?.path.hasSuffix("/immersive_view") == true && + sourceFrame.isMainFrame == false + } + + func handleStudioImmersiveViewIfNeeded(from viewController: UIViewController?, router: Router) -> Bool { + guard isStudioImmersiveViewLinkTap, + var url = request.url, + let viewController + else { + return false + } + + url.append(queryItems: [.init(name: "embedded", value: "true")]) + let controller = StudioViewController(url: url) + router.show(controller, from: viewController, options: .modal(.overFullScreen)) + return true + } } From a49f33c2f7a59d2106fb64259322db4b4ef8c2f7 Mon Sep 17 00:00:00 2001 From: Suhaib Al-Absi Date: Mon, 10 Nov 2025 02:36:53 +0300 Subject: [PATCH 03/45] Fix merge conflicts --- .../CoreWebView/View/CoreWebView.swift | 27 +++++++++---------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift b/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift index c12d9901a9..0440b3d44e 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift @@ -497,13 +497,6 @@ extension CoreWebView: WKNavigationDelegate { decisionHandler: @escaping (WKNavigationActionPolicy) -> Void ) { - print() - print("HANDLING ACTION: ") - print(action.request) - print(action.navigationType == .linkActivated) - print(action.navigationType == .other) - print() - if action.navigationType == .linkActivated && !isLinkNavigationEnabled { decisionHandler(.cancel) return @@ -867,12 +860,6 @@ extension CoreWebView { content: String?, originalBaseURL: URL? ) { - - print() - print("HTML CONTENT LOADING: ") - print(content) - print() - if let filePath, isOffline == true, FileManager.default.fileExists(atPath: filePath.path) { loadFileURL( @@ -900,10 +887,17 @@ extension WKNavigationAction { } var isStudioImmersiveViewLinkTap: Bool { - navigationType == .other && + let isExpandLink = navigationType == .other && request.url?.path.contains("/media_attachments/") == true && request.url?.path.hasSuffix("/immersive_view") == true && sourceFrame.isMainFrame == false + + let isDetailsLink = navigationType == .linkActivated && + request.url?.path.contains("/media_attachments/") == true && + request.url?.path.hasSuffix("/immersive_view") == true && + (targetFrame?.isMainFrame ?? false) == false + + return isExpandLink || isDetailsLink } func handleStudioImmersiveViewIfNeeded(from viewController: UIViewController?, router: Router) -> Bool { @@ -914,7 +908,10 @@ extension WKNavigationAction { return false } - url.append(queryItems: [.init(name: "embedded", value: "true")]) + if url.containsQueryItem(named: "embedded") == false { + url.append(queryItems: [.init(name: "embedded", value: "true")]) + } + let controller = StudioViewController(url: url) router.show(controller, from: viewController, options: .modal(.overFullScreen)) return true From 2aac27537b824705060228c08924ccacf4a5c2d5 Mon Sep 17 00:00:00 2001 From: Suhaib Al-Absi Date: Mon, 10 Nov 2025 02:42:12 +0300 Subject: [PATCH 04/45] Full functional requirements --- Core/Core/Features/LTI/View/StudioViewController.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/Core/Core/Features/LTI/View/StudioViewController.swift b/Core/Core/Features/LTI/View/StudioViewController.swift index 528dd6896b..938813c5ec 100644 --- a/Core/Core/Features/LTI/View/StudioViewController.swift +++ b/Core/Core/Features/LTI/View/StudioViewController.swift @@ -25,9 +25,6 @@ class StudioViewController: UINavigationController { let controller = CoreWebViewController() controller.webView.load(URLRequest(url: url)) controller.addDoneButton() - - - print( url.queryValue(for: "title") ) controller.title = url.queryValue(for: "title") ?? String(localized: "Studio", bundle: .core) super.init(rootViewController: controller) From 7a2ea285597d6072f8b6964cad627cb3472b393d Mon Sep 17 00:00:00 2001 From: Suhaib Al-Absi Date: Mon, 10 Nov 2025 03:49:16 +0300 Subject: [PATCH 05/45] Fix issues with flag loading --- .../FeatureFlags/GetFeatureFlagState.swift | 5 +++++ .../GetFeatureFlagStateRequest.swift | 2 +- .../CommonUI/CoreWebView/View/CoreWebView.swift | 17 +++++++++++++++-- .../Common/CommonUI/SwiftUIViews/WebView.swift | 1 + .../PageDetails/PageDetailsViewController.swift | 1 + 5 files changed, 23 insertions(+), 3 deletions(-) diff --git a/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagState.swift b/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagState.swift index 422ee63a4f..69de767224 100644 --- a/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagState.swift +++ b/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagState.swift @@ -54,4 +54,9 @@ public class GetFeatureFlagState: APIUseCase { guard let response = response else { return } FeatureFlag.save(response, in: client) } + + public func reset(context: NSManagedObjectContext) { + let all = context.fetch(scope: scope) as [Model] + context.delete(all) + } } diff --git a/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagStateRequest.swift b/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagStateRequest.swift index 4ca3ee1102..ed56d893be 100644 --- a/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagStateRequest.swift +++ b/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagStateRequest.swift @@ -60,6 +60,6 @@ public struct APIFeatureFlagState: Codable { private let context_type: String public var canvasContextID: String { - return "\(context_type)_\(context_id)" + return "\(context_type.lowercased())_\(context_id)" } } diff --git a/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift b/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift index 0440b3d44e..1c507eff4f 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift @@ -48,7 +48,9 @@ open class CoreWebView: WKWebView { public weak var linkDelegate: CoreWebViewLinkDelegate? { didSet { - guard let context = self.linkDelegate?.coreWebViewFeaturesContext else { + guard + let context = self.linkDelegate?.coreWebViewFeaturesContext, + studioImprovementsFlag == nil else { studioImprovementsFlag = nil updateStudioFeatures() return @@ -125,6 +127,15 @@ open class CoreWebView: WKWebView { configuration.userContentController.removeAllUserScripts() } + open override func reload() -> WKNavigation? { + prepareForReload() + return super.reload() + } + + public func prepareForReload() { + studioImprovementsFlag?.refresh(force: true) + } + /** 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`. @@ -482,7 +493,9 @@ open class CoreWebView: WKWebView { addFeature(.insertStudioDetailView) } else if features.contains(where: { $0 is InsertStudioDetailView }) { features.removeAll(where: { $0 is InsertStudioDetailView }) - reload() + configuration.userContentController.removeAllUserScripts() + features.forEach({ $0.apply(on: self) }) + _ = reload() } } } diff --git a/Core/Core/Common/CommonUI/SwiftUIViews/WebView.swift b/Core/Core/Common/CommonUI/SwiftUIViews/WebView.swift index f67189e400..c2ec34c596 100644 --- a/Core/Core/Common/CommonUI/SwiftUIViews/WebView.swift +++ b/Core/Core/Common/CommonUI/SwiftUIViews/WebView.swift @@ -212,6 +212,7 @@ extension WebView { ) { reloadObserver?.cancel() reloadObserver = trigger?.sink { + webView.prepareForReload() webView.reload() } } diff --git a/Core/Core/Features/Pages/PageDetails/PageDetailsViewController.swift b/Core/Core/Features/Pages/PageDetails/PageDetailsViewController.swift index 08589723dc..bc3e8d2c55 100644 --- a/Core/Core/Features/Pages/PageDetails/PageDetailsViewController.swift +++ b/Core/Core/Features/Pages/PageDetails/PageDetailsViewController.swift @@ -117,6 +117,7 @@ open class PageDetailsViewController: UIViewController, ColoredNavViewProtocol, } @objc private func refresh() { + webView.prepareForReload() pages.refresh(force: true) { [weak self] _ in self?.refreshControl.endRefreshing() } From 7c07632e95ccc7eaca66731f34cca020baa247da Mon Sep 17 00:00:00 2001 From: Suhaib Al-Absi Date: Mon, 10 Nov 2025 04:07:09 +0300 Subject: [PATCH 06/45] Remove context from delegate refs: MBL-19480 affects: Student, Teacher, Parent builds: Student, Teacher, Parent release note: Introduced immersive experience for video player. --- .../Delegates/CoreWebViewLinkDelegate.swift | 1 - .../CoreWebView/View/CoreWebView.swift | 27 +++++++++---------- .../View/CoreWebViewController.swift | 6 ----- .../CommonUI/SwiftUIViews/WebView.swift | 4 +-- .../DiscussionDetailsViewController.swift | 6 +---- .../DiscussionReplyViewController.swift | 5 +--- .../FileDetailsViewController.swift | 5 +--- Core/Core/Features/LTI/LTITools.swift | 2 +- .../PageDetailsViewController.swift | 3 +-- .../StudentQuizDetailsViewController.swift | 5 +--- .../StudentQuizWebViewController.swift | 5 +--- .../Syllabus/SyllabusTabViewController.swift | 4 --- .../Syllabus/SyllabusViewController.swift | 5 +--- .../View/ParentSubmissionViewController.swift | 2 -- ...udentAssignmentDetailsViewController.swift | 3 +-- .../SubmissionDetailsPresenter.swift | 8 +++--- .../RubricViewController.swift | 2 -- 17 files changed, 28 insertions(+), 65 deletions(-) diff --git a/Core/Core/Common/CommonUI/CoreWebView/Model/Delegates/CoreWebViewLinkDelegate.swift b/Core/Core/Common/CommonUI/CoreWebView/Model/Delegates/CoreWebViewLinkDelegate.swift index 251d06fe8e..bdd7e6331d 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/Model/Delegates/CoreWebViewLinkDelegate.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/Model/Delegates/CoreWebViewLinkDelegate.swift @@ -20,7 +20,6 @@ import WebKit public protocol CoreWebViewLinkDelegate: AnyObject { var routeLinksFrom: UIViewController { get } - var coreWebViewFeaturesContext: Context? { get } func handleLink(_ url: URL) -> Bool func finishedNavigation() diff --git a/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift b/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift index 1c507eff4f..fe59b2fba7 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift @@ -45,19 +45,27 @@ open class CoreWebView: WKWebView { @IBInspectable public var autoresizesHeight: Bool = false - public weak var linkDelegate: CoreWebViewLinkDelegate? { + public weak var linkDelegate: CoreWebViewLinkDelegate? + public weak var sizeDelegate: CoreWebViewSizeDelegate? + public weak var errorDelegate: CoreWebViewErrorDelegate? + public var isLinkNavigationEnabled = true + public var contentInputAccessoryView: UIView? { + didSet { + addContentInputAccessoryView() + } + } + + public var featuresContext: Context? { didSet { - guard - let context = self.linkDelegate?.coreWebViewFeaturesContext, - studioImprovementsFlag == nil else { + guard let featuresContext, studioImprovementsFlag == nil else { studioImprovementsFlag = nil updateStudioFeatures() return } studioImprovementsFlag = env.subscribe( - GetFeatureFlagState(featureName: .studioEmbedImprovements, context: context) + GetFeatureFlagState(featureName: .studioEmbedImprovements, context: featuresContext) ) { [weak self] in self?.updateStudioFeatures() } @@ -66,15 +74,6 @@ open class CoreWebView: WKWebView { } } - public weak var sizeDelegate: CoreWebViewSizeDelegate? - public weak var errorDelegate: CoreWebViewErrorDelegate? - public var isLinkNavigationEnabled = true - public var contentInputAccessoryView: UIView? { - didSet { - addContentInputAccessoryView() - } - } - var downloadingAttachment: CoreWebAttachment? internal var a11yHelper = CoreWebViewAccessibilityHelper() diff --git a/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebViewController.swift b/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebViewController.swift index 611a287668..19e096fdde 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebViewController.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebViewController.swift @@ -22,8 +22,6 @@ import UIKit public class CoreWebViewController: UIViewController, CoreWebViewLinkDelegate { public var webView: CoreWebView - public var featuresContext: Context? - var limitedInteractionView: NotificationView? private var subscriptions = Set() @@ -90,8 +88,4 @@ public class CoreWebViewController: UIViewController, CoreWebViewLinkDelegate { } .store(in: &subscriptions) } - - public var coreWebViewFeaturesContext: Context? { - featuresContext - } } diff --git a/Core/Core/Common/CommonUI/SwiftUIViews/WebView.swift b/Core/Core/Common/CommonUI/SwiftUIViews/WebView.swift index c2ec34c596..26ae998f5b 100644 --- a/Core/Core/Common/CommonUI/SwiftUIViews/WebView.swift +++ b/Core/Core/Common/CommonUI/SwiftUIViews/WebView.swift @@ -158,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.featuresContext = featuresContext + // 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() @@ -255,8 +257,6 @@ extension WebView { public var routeLinksFrom: UIViewController { view.controller.value } - public var coreWebViewFeaturesContext: Context? { view.featuresContext } - public func handleLink(_ url: URL) -> Bool { if let handleLink = view.handleLink { return handleLink(url) diff --git a/Core/Core/Features/Discussions/DiscussionDetails/DiscussionDetailsViewController.swift b/Core/Core/Features/Discussions/DiscussionDetails/DiscussionDetailsViewController.swift index f02765f192..4fe10dbf40 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.featuresContext = context webView.addScript(DiscussionHTML.preact) webView.addScript(DiscussionHTML.js) webView.handle("like") { [weak self] message in self?.handleLike(message) } @@ -614,10 +614,6 @@ extension DiscussionDetailsViewController: UIScrollViewDelegate { extension DiscussionDetailsViewController: CoreWebViewLinkDelegate { - public var coreWebViewFeaturesContext: Context? { - context - } - 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 125eec7fe1..16c9c4dc6d 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.featuresContext = context webView.scrollView.isScrollEnabled = false contentHeight.priority = .defaultHigh // webViewHeight will win contentHeight.isActive = true @@ -390,8 +391,4 @@ extension DiscussionReplyViewController: CoreWebViewLinkDelegate { } return true } - - public var coreWebViewFeaturesContext: Context? { - context - } } diff --git a/Core/Core/Features/Files/View/FileDetails/FileDetailsViewController.swift b/Core/Core/Features/Files/View/FileDetails/FileDetailsViewController.swift index 8af4bf3211..02b4cf0b7d 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.featuresContext = context webView.accessibilityLabel = "FileDetails.webView" progressView.progress = 0 setupLoadObservation(for: webView) @@ -417,10 +418,6 @@ public class FileDetailsViewController: ScreenViewTrackableViewController, CoreW } } } - - public var coreWebViewFeaturesContext: Context? { - context - } } // MARK: - URLSessionDownloadDelegate diff --git a/Core/Core/Features/LTI/LTITools.swift b/Core/Core/Features/LTI/LTITools.swift index a8b5cc3453..04c1f1cc7c 100644 --- a/Core/Core/Features/LTI/LTITools.swift +++ b/Core/Core/Features/LTI/LTITools.swift @@ -193,7 +193,7 @@ public class LTITools: NSObject { .hideReturnButtonInQuizLTI, .disableLinksOverlayPreviews ]) - controller.featuresContext = context + controller.webView.featuresContext = context controller.webView.load(URLRequest(url: url)) controller.title = String(localized: "Quiz", bundle: .core) controller.addDoneButton(side: .right) diff --git a/Core/Core/Features/Pages/PageDetails/PageDetailsViewController.swift b/Core/Core/Features/Pages/PageDetails/PageDetailsViewController.swift index bc3e8d2c55..e043de5ee9 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.featuresContext = context if context.contextType == .course { webView.addScript("window.ENV={COURSE:{id:\(CoreWebView.jsString(context.id))}}") @@ -208,8 +209,6 @@ open class PageDetailsViewController: UIViewController, ColoredNavViewProtocol, extension PageDetailsViewController: CoreWebViewLinkDelegate { - public var coreWebViewFeaturesContext: Context? { context } - public func handleLink(_ url: URL) -> Bool { env.router.route(to: url, from: self) return true diff --git a/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizDetailsViewController.swift b/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizDetailsViewController.swift index 823cfb0000..9a3bcf5ea1 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.featuresContext = .course(courseID) loadingView.color = nil refreshControl.color = nil @@ -224,8 +225,4 @@ public class StudentQuizDetailsViewController: ScreenViewTrackableViewController env.router.route(to: url, from: self, options: .modal(embedInNav: true)) } } - - public var coreWebViewFeaturesContext: Context? { - .course(courseID) - } } diff --git a/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizWebViewController.swift b/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizWebViewController.swift index 024b273d0f..4f2bfcddd4 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.featuresContext = .course(courseID) title = String(localized: "Take Quiz", bundle: .core) navigationItem.rightBarButtonItem = UIBarButtonItem( @@ -115,8 +116,4 @@ extension StudentQuizWebViewController: CoreWebViewLinkDelegate { env.router.route(to: url, from: self) return true } - - public var coreWebViewFeaturesContext: Context? { - .course(courseID) - } } diff --git a/Core/Core/Features/Syllabus/SyllabusTabViewController.swift b/Core/Core/Features/Syllabus/SyllabusTabViewController.swift index 3f425eef63..230bf5e256 100644 --- a/Core/Core/Features/Syllabus/SyllabusTabViewController.swift +++ b/Core/Core/Features/Syllabus/SyllabusTabViewController.swift @@ -129,10 +129,6 @@ open class SyllabusTabViewController: ScreenViewTrackableHorizontalMenuViewContr env.router.route( to: "\(context?.pathComponent ?? "")/syllabus/edit", from: self, options: .modal(isDismissable: false, embedInNav: true)) } - - public var coreWebViewFeaturesContext: Context? { - .course(courseID) - } } extension SyllabusTabViewController: HorizontalPagedMenuDelegate { diff --git a/Core/Core/Features/Syllabus/SyllabusViewController.swift b/Core/Core/Features/Syllabus/SyllabusViewController.swift index c8f4ff2457..d4c4717f7e 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.featuresContext = .course(courseID) view.addSubview(webView) webView.pinWithThemeSwitchButton(inside: view) @@ -69,8 +70,4 @@ open class SyllabusViewController: UIViewController, CoreWebViewLinkDelegate { self?.refreshControl.endRefreshing() } } - - public var coreWebViewFeaturesContext: Context? { - .course(courseID) - } } diff --git a/Parent/Parent/Assignments/View/ParentSubmissionViewController.swift b/Parent/Parent/Assignments/View/ParentSubmissionViewController.swift index 86e369e18b..0c12fb99e9 100644 --- a/Parent/Parent/Assignments/View/ParentSubmissionViewController.swift +++ b/Parent/Parent/Assignments/View/ParentSubmissionViewController.swift @@ -197,6 +197,4 @@ extension ParentSubmissionViewController { ) viewModel.router.show(shareSheet, from: self, options: routeOptions) } - - var coreWebViewFeaturesContext: Context? { nil } } diff --git a/Student/Student/Assignments/AssignmentDetails/StudentAssignmentDetailsViewController.swift b/Student/Student/Assignments/AssignmentDetails/StudentAssignmentDetailsViewController.swift index 6dc1d03ec7..a464426a60 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.featuresContext = .course(courseID) webView.linkDelegate = self webView.autoresizesHeight = true webView.heightAnchor.constraint(equalToConstant: 0).isActive = true @@ -768,8 +769,6 @@ extension StudentAssignmentDetailsViewController: CoreWebViewLinkDelegate { guard let presenter = presenter else { return false } return presenter.route(to: url, from: self) } - - public var coreWebViewFeaturesContext: Context? { .course(courseID) } } // MARK: - Events diff --git a/Student/Student/Submissions/SubmissionDetails/SubmissionDetailsPresenter.swift b/Student/Student/Submissions/SubmissionDetails/SubmissionDetailsPresenter.swift index cfa413b1af..d3da87f002 100644 --- a/Student/Student/Submissions/SubmissionDetails/SubmissionDetailsPresenter.swift +++ b/Student/Student/Submissions/SubmissionDetails/SubmissionDetailsPresenter.swift @@ -210,14 +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.featuresContext = .course(assignment.courseID) + controller.webView.featuresContext = .course(assignment.courseID) controller.webView.accessibilityIdentifier = "SubmissionDetails.onlineQuizWebView" controller.webView.load(URLRequest(url: url)) return controller } case .some(.online_text_entry): let controller = CoreWebViewController() - controller.featuresContext = .course(assignment.courseID) + controller.webView.featuresContext = .course(assignment.courseID) controller.webView.accessibilityIdentifier = "SubmissionDetails.onlineTextEntryWebView" controller.webView.loadHTMLString(submission.body ?? "") return controller @@ -247,7 +247,7 @@ class SubmissionDetailsPresenter { ) } let controller = CoreWebViewController(features: [.invertColorsInDarkMode]) - controller.featuresContext = .course(assignment.courseID) + controller.webView.featuresContext = .course(assignment.courseID) controller.webView.accessibilityIdentifier = "SubmissionDetails.webView" controller.webView.load(URLRequest(url: url)) return controller @@ -256,7 +256,7 @@ class SubmissionDetailsPresenter { guard let previewUrl = submission.previewUrl else { break } let controller = CoreWebViewController(features: [.invertColorsInDarkMode]) - controller.featuresContext = .course(assignment.courseID) + controller.webView.featuresContext = .course(assignment.courseID) controller.webView.accessibilityIdentifier = "SubmissionDetails.discussionWebView" controller.webView.load(URLRequest(url: previewUrl)) return controller diff --git a/Student/Student/Submissions/SubmissionRubric/RubricViewController.swift b/Student/Student/Submissions/SubmissionRubric/RubricViewController.swift index e68763384b..75415f1f6a 100644 --- a/Student/Student/Submissions/SubmissionRubric/RubricViewController.swift +++ b/Student/Student/Submissions/SubmissionRubric/RubricViewController.swift @@ -224,8 +224,6 @@ extension RubricViewController: CoreWebViewLinkDelegate { } return true } - - public var coreWebViewFeaturesContext: Context? { .course(presenter.courseID) } } extension RubricViewController: RubricViewProtocol { From 92c52827dc969d2728ee2f4f697be2fc4c7ef3e7 Mon Sep 17 00:00:00 2001 From: Suhaib Al-Absi Date: Mon, 10 Nov 2025 17:19:34 +0300 Subject: [PATCH 07/45] Organising things --- .../Common/CommonUI/CoreWebView/View/CoreWebView.swift | 6 +++--- Core/Core/Common/CommonUI/SwiftUIViews/WebView.swift | 2 +- .../DiscussionDetailsViewController.swift | 2 +- .../DiscussionReply/DiscussionReplyViewController.swift | 2 +- .../View/FileDetails/FileDetailsViewController.swift | 2 +- Core/Core/Features/LTI/LTITools.swift | 2 +- .../Pages/PageDetails/PageDetailsViewController.swift | 2 +- .../Student/StudentQuizDetailsViewController.swift | 2 +- .../Student/StudentQuizWebViewController.swift | 2 +- Core/Core/Features/Syllabus/SyllabusViewController.swift | 2 +- .../StudentAssignmentDetailsViewController.swift | 2 +- .../SubmissionDetails/SubmissionDetailsPresenter.swift | 8 ++++---- 12 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift b/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift index fe59b2fba7..4db32ee684 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift @@ -55,17 +55,17 @@ open class CoreWebView: WKWebView { } } - public var featuresContext: Context? { + public var studioFeaturesContext: Context? { didSet { - guard let featuresContext, studioImprovementsFlag == nil else { + guard let studioFeaturesContext, studioImprovementsFlag == nil else { studioImprovementsFlag = nil updateStudioFeatures() return } studioImprovementsFlag = env.subscribe( - GetFeatureFlagState(featureName: .studioEmbedImprovements, context: featuresContext) + GetFeatureFlagState(featureName: .studioEmbedImprovements, context: studioFeaturesContext) ) { [weak self] in self?.updateStudioFeatures() } diff --git a/Core/Core/Common/CommonUI/SwiftUIViews/WebView.swift b/Core/Core/Common/CommonUI/SwiftUIViews/WebView.swift index 26ae998f5b..ab5035ba0a 100644 --- a/Core/Core/Common/CommonUI/SwiftUIViews/WebView.swift +++ b/Core/Core/Common/CommonUI/SwiftUIViews/WebView.swift @@ -158,7 +158,7 @@ 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.featuresContext = featuresContext + webView.studioFeaturesContext = featuresContext // 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. diff --git a/Core/Core/Features/Discussions/DiscussionDetails/DiscussionDetailsViewController.swift b/Core/Core/Features/Discussions/DiscussionDetails/DiscussionDetailsViewController.swift index 4fe10dbf40..5d51a41505 100644 --- a/Core/Core/Features/Discussions/DiscussionDetails/DiscussionDetailsViewController.swift +++ b/Core/Core/Features/Discussions/DiscussionDetails/DiscussionDetailsViewController.swift @@ -154,7 +154,7 @@ public class DiscussionDetailsViewController: ScreenViewTrackableViewController, webView.backgroundColor = .backgroundLightest webView.linkDelegate = self webView.errorDelegate = self - webView.featuresContext = context + webView.studioFeaturesContext = context webView.addScript(DiscussionHTML.preact) webView.addScript(DiscussionHTML.js) webView.handle("like") { [weak self] message in self?.handleLike(message) } diff --git a/Core/Core/Features/Discussions/DiscussionReply/DiscussionReplyViewController.swift b/Core/Core/Features/Discussions/DiscussionReply/DiscussionReplyViewController.swift index 16c9c4dc6d..2c8af7dd59 100644 --- a/Core/Core/Features/Discussions/DiscussionReply/DiscussionReplyViewController.swift +++ b/Core/Core/Features/Discussions/DiscussionReply/DiscussionReplyViewController.swift @@ -167,7 +167,7 @@ public class DiscussionReplyViewController: ScreenViewTrackableViewController, E webView.autoresizesHeight = true webView.backgroundColor = .backgroundLightest webView.linkDelegate = self - webView.featuresContext = context + webView.studioFeaturesContext = context 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 02b4cf0b7d..95ad30ddb5 100644 --- a/Core/Core/Features/Files/View/FileDetails/FileDetailsViewController.swift +++ b/Core/Core/Features/Files/View/FileDetails/FileDetailsViewController.swift @@ -312,7 +312,7 @@ public class FileDetailsViewController: ScreenViewTrackableViewController, CoreW contentView.addSubview(webView) webView.pinWithThemeSwitchButton(inside: contentView) webView.linkDelegate = self - webView.featuresContext = context + webView.studioFeaturesContext = context 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 04c1f1cc7c..ac42cab63e 100644 --- a/Core/Core/Features/LTI/LTITools.swift +++ b/Core/Core/Features/LTI/LTITools.swift @@ -193,7 +193,7 @@ public class LTITools: NSObject { .hideReturnButtonInQuizLTI, .disableLinksOverlayPreviews ]) - controller.webView.featuresContext = context + controller.webView.studioFeaturesContext = context controller.webView.load(URLRequest(url: url)) controller.title = String(localized: "Quiz", bundle: .core) controller.addDoneButton(side: .right) diff --git a/Core/Core/Features/Pages/PageDetails/PageDetailsViewController.swift b/Core/Core/Features/Pages/PageDetails/PageDetailsViewController.swift index e043de5ee9..320f658584 100644 --- a/Core/Core/Features/Pages/PageDetails/PageDetailsViewController.swift +++ b/Core/Core/Features/Pages/PageDetails/PageDetailsViewController.swift @@ -84,7 +84,7 @@ open class PageDetailsViewController: UIViewController, ColoredNavViewProtocol, webViewContainer.addSubview(webView) webView.pinWithThemeSwitchButton(inside: webViewContainer) webView.linkDelegate = self - webView.featuresContext = context + webView.studioFeaturesContext = context if context.contextType == .course { webView.addScript("window.ENV={COURSE:{id:\(CoreWebView.jsString(context.id))}}") diff --git a/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizDetailsViewController.swift b/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizDetailsViewController.swift index 9a3bcf5ea1..57a51ea849 100644 --- a/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizDetailsViewController.swift +++ b/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizDetailsViewController.swift @@ -94,7 +94,7 @@ public class StudentQuizDetailsViewController: ScreenViewTrackableViewController instructionsWebView.scrollView.alwaysBounceVertical = false instructionsWebView.backgroundColor = .backgroundLightest instructionsWebView.linkDelegate = self - instructionsWebView.featuresContext = .course(courseID) + instructionsWebView.studioFeaturesContext = .course(courseID) 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 4f2bfcddd4..da5c9be627 100644 --- a/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizWebViewController.swift +++ b/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizWebViewController.swift @@ -47,7 +47,7 @@ public class StudentQuizWebViewController: UIViewController { webView.linkDelegate = self webView.uiDelegate = self - webView.featuresContext = .course(courseID) + webView.studioFeaturesContext = .course(courseID) 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 d4c4717f7e..ac23ae9e82 100644 --- a/Core/Core/Features/Syllabus/SyllabusViewController.swift +++ b/Core/Core/Features/Syllabus/SyllabusViewController.swift @@ -44,7 +44,7 @@ open class SyllabusViewController: UIViewController, CoreWebViewLinkDelegate { webView.backgroundColor = .backgroundLightest webView.scrollView.refreshControl = refreshControl webView.linkDelegate = self - webView.featuresContext = .course(courseID) + webView.studioFeaturesContext = .course(courseID) view.addSubview(webView) webView.pinWithThemeSwitchButton(inside: view) diff --git a/Student/Student/Assignments/AssignmentDetails/StudentAssignmentDetailsViewController.swift b/Student/Student/Assignments/AssignmentDetails/StudentAssignmentDetailsViewController.swift index a464426a60..c4a6114bc8 100644 --- a/Student/Student/Assignments/AssignmentDetails/StudentAssignmentDetailsViewController.swift +++ b/Student/Student/Assignments/AssignmentDetails/StudentAssignmentDetailsViewController.swift @@ -238,7 +238,7 @@ class StudentAssignmentDetailsViewController: ScreenViewTrackableViewController, lockedIconImageView.image = UIImage(named: Panda.Locked.name, in: .core, compatibleWith: nil) // Routing from description - webView.featuresContext = .course(courseID) + webView.studioFeaturesContext = .course(courseID) 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 d3da87f002..829e4a456f 100644 --- a/Student/Student/Submissions/SubmissionDetails/SubmissionDetailsPresenter.swift +++ b/Student/Student/Submissions/SubmissionDetails/SubmissionDetailsPresenter.swift @@ -210,14 +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.featuresContext = .course(assignment.courseID) + controller.webView.studioFeaturesContext = .course(assignment.courseID) controller.webView.accessibilityIdentifier = "SubmissionDetails.onlineQuizWebView" controller.webView.load(URLRequest(url: url)) return controller } case .some(.online_text_entry): let controller = CoreWebViewController() - controller.webView.featuresContext = .course(assignment.courseID) + controller.webView.studioFeaturesContext = .course(assignment.courseID) controller.webView.accessibilityIdentifier = "SubmissionDetails.onlineTextEntryWebView" controller.webView.loadHTMLString(submission.body ?? "") return controller @@ -247,7 +247,7 @@ class SubmissionDetailsPresenter { ) } let controller = CoreWebViewController(features: [.invertColorsInDarkMode]) - controller.webView.featuresContext = .course(assignment.courseID) + controller.webView.studioFeaturesContext = .course(assignment.courseID) controller.webView.accessibilityIdentifier = "SubmissionDetails.webView" controller.webView.load(URLRequest(url: url)) return controller @@ -256,7 +256,7 @@ class SubmissionDetailsPresenter { guard let previewUrl = submission.previewUrl else { break } let controller = CoreWebViewController(features: [.invertColorsInDarkMode]) - controller.webView.featuresContext = .course(assignment.courseID) + controller.webView.studioFeaturesContext = .course(assignment.courseID) controller.webView.accessibilityIdentifier = "SubmissionDetails.discussionWebView" controller.webView.load(URLRequest(url: previewUrl)) return controller From 2b4c69701905dc6151bc409a84bb5ee9f3e63256 Mon Sep 17 00:00:00 2001 From: Suhaib Al-Absi Date: Tue, 11 Nov 2025 14:14:01 +0300 Subject: [PATCH 08/45] Fix title extraction for expand button, enhanced separation of concerns --- .../FeatureFlags/GetFeatureFlagState.swift | 15 +- .../GetFeatureFlagStateRequest.swift | 6 +- ...=> InsertStudioOpenDetailViewButton.swift} | 17 +- .../Model/StudioFeaturesInteractor.swift | 151 ++++++++++++++++++ .../CoreWebView/View/CoreWebView.swift | 93 +++-------- .../CommonUI/SwiftUIViews/WebView.swift | 3 +- .../DiscussionDetailsViewController.swift | 2 +- .../DiscussionReplyViewController.swift | 2 +- .../FileDetailsViewController.swift | 2 +- Core/Core/Features/LTI/LTITools.swift | 2 +- .../PageDetailsViewController.swift | 4 +- .../StudentQuizDetailsViewController.swift | 2 +- .../StudentQuizWebViewController.swift | 2 +- .../Syllabus/SyllabusViewController.swift | 2 +- .../Courses/CourseDetailsViewController.swift | 2 +- ...udentAssignmentDetailsViewController.swift | 2 +- .../SubmissionDetailsPresenter.swift | 8 +- 17 files changed, 219 insertions(+), 96 deletions(-) rename Core/Core/Common/CommonUI/CoreWebView/Model/Features/{InsertStudioDetailView.swift => InsertStudioOpenDetailViewButton.swift} (84%) create mode 100644 Core/Core/Common/CommonUI/CoreWebView/Model/StudioFeaturesInteractor.swift diff --git a/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagState.swift b/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagState.swift index 69de767224..10ae599d23 100644 --- a/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagState.swift +++ b/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagState.swift @@ -52,7 +52,20 @@ public class GetFeatureFlagState: APIUseCase { public func write(response: APIFeatureFlagState?, urlResponse: URLResponse?, to client: NSManagedObjectContext) { guard let response = response else { return } - FeatureFlag.save(response, in: client) + + var item = response + + /// This as walkaround 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 { + if case .account = item.contextType { + item.state = item.state == .allowed_on ? .on : item.state + } + } + + FeatureFlag.save(item, in: client) } public func reset(context: NSManagedObjectContext) { diff --git a/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagStateRequest.swift b/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagStateRequest.swift index ed56d893be..878aa08210 100644 --- a/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagStateRequest.swift +++ b/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagStateRequest.swift @@ -53,12 +53,16 @@ public struct APIFeatureFlagState: Codable { } public let feature: String - public let state: State + public var state: State public let locked: Bool private let context_id: String private let context_type: String + public var contextType: ContextType? { + return ContextType(rawValue: context_type.lowercased()) + } + public var canvasContextID: String { return "\(context_type.lowercased())_\(context_id)" } diff --git a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioDetailView.swift b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift similarity index 84% rename from Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioDetailView.swift rename to Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift index 8083cec64a..f0335e573a 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioDetailView.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift @@ -16,7 +16,7 @@ // along with this program. If not, see . // -class InsertStudioDetailView: CoreWebViewFeature { +class InsertStudioOpenInDetailButtons: CoreWebViewFeature { private let insertScript: String = { let title = String(localized: "Open in Detail View", bundle: .core) @@ -27,22 +27,25 @@ class InsertStudioDetailView: CoreWebViewFeature { frameElements.forEach(elm => { let next = elm.nextElementSibling.nextElementSibling; - let wasInjected = next.getAttribute("web-injected"); + let wasInjected = next.getAttribute("ios-injected"); if(wasInjected == 1) { return } const videoTitle = elm.getAttribute("title"); + const ariaTitle = elm.getAttribute("aria-title"); + const title = videoTitle ?? ariaTitle; + var frameLink = elm.getAttribute("src"); frameLink = frameLink.replace("media_attachments_iframe", "media_attachments"); var linkSuffix = "/immersive_view"; - if(videoTitle){ - linkSuffix = "/immersive_view?title=" + encodeURIComponent(videoTitle); + if(title){ + linkSuffix = "/immersive_view?title=" + encodeURIComponent(title); } const newLine = document.createElement('br'); const newParagraph = document.createElement('p'); - newParagraph.setAttribute("web-injected", 1); + newParagraph.setAttribute("ios-injected", 1); const detailButton = document.createElement('a'); detailButton.className = "details_view_link"; @@ -71,7 +74,7 @@ class InsertStudioDetailView: CoreWebViewFeature { public extension CoreWebViewFeature { - static var insertStudioDetailView: CoreWebViewFeature { - InsertStudioDetailView() + static var insertStudioOpenInDetailButtons: CoreWebViewFeature { + InsertStudioOpenInDetailButtons() } } diff --git a/Core/Core/Common/CommonUI/CoreWebView/Model/StudioFeaturesInteractor.swift b/Core/Core/Common/CommonUI/CoreWebView/Model/StudioFeaturesInteractor.swift new file mode 100644 index 0000000000..675d082163 --- /dev/null +++ b/Core/Core/Common/CommonUI/CoreWebView/Model/StudioFeaturesInteractor.swift @@ -0,0 +1,151 @@ +// +// 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 WebKit +import UIKit + +public class StudioFeaturesInteractor { + 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(); + """ + + unowned let webView: CoreWebView + private var studioImprovementsFlag: Store? + private var videoFramesTitleMap: [String: String] = [:] + + init(webView: CoreWebView) { + self.webView = webView + } + + func setupFeatureFlagStore(context: Context?, env: AppEnvironment) { + guard let context else { + studioImprovementsFlag = nil + return + } + + studioImprovementsFlag = env.subscribe( + GetFeatureFlagState(featureName: .studioEmbedImprovements, context: context) + ) { [weak self] in + self?.updateStudioFeatures() + } + + studioImprovementsFlag?.refresh() + } + + public func refresh() { + studioImprovementsFlag?.refresh(force: true) + } + + private func updateStudioFeatures() { + let isStudioImprovementsEnabled = studioImprovementsFlag?.first?.enabled ?? false + if isStudioImprovementsEnabled { + webView.addFeature(.insertStudioOpenInDetailButtons) + } else { + webView.removeFeatures(ofType: InsertStudioOpenInDetailButtons.self) + } + } + + func handleStudioImmersiveViewIfNeeded(_ action: WKNavigationAction, from viewController: UIViewController?, router: Router) -> Bool { + guard action.isStudioImmersiveViewLinkTap, + var url = action.request.url, + let viewController + else { + return false + } + + if url.containsQueryItem(named: "title") == false, + let title = videoPlayerFrameTitle(matching: url) { + url.append(queryItems: [.init(name: "title", value: title)]) + } + + if url.containsQueryItem(named: "embedded") == false { + url.append(queryItems: [.init(name: "embedded", value: "true")]) + } + + let controller = StudioViewController(url: url) + router.show(controller, from: viewController, options: .modal(.overFullScreen)) + return true + } + + func scanVideoFrames() { + + videoFramesTitleMap.removeAll() + webView.evaluateJavaScript(Self.scanFramesScript) { [weak self] result, _ in + + 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 + }) + + self?.videoFramesTitleMap = mapped + } + } + + private func videoPlayerFrameTitle(matching url: URL) -> String? { + let path = url.removingQueryAndFragment().absoluteString + return videoFramesTitleMap.first(where: { path.hasPrefix($0.key) })?.value + } +} + +// MARK: - WKNavigationAction Extensions + +private extension WKNavigationAction { + + var isStudioImmersiveViewLinkTap: Bool { + let isExpandLink = navigationType == .other && + request.url?.path.contains("/media_attachments/") == true && + request.url?.path.hasSuffix("/immersive_view") == true && + sourceFrame.isMainFrame == false + + let isDetailsLink = navigationType == .linkActivated && + request.url?.path.contains("/media_attachments/") == true && + request.url?.path.hasSuffix("/immersive_view") == true && + (targetFrame?.isMainFrame ?? false) == false + + return isExpandLink || isDetailsLink + } +} diff --git a/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift b/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift index 4db32ee684..52c5c2c763 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift @@ -55,25 +55,6 @@ open class CoreWebView: WKWebView { } } - public var studioFeaturesContext: Context? { - didSet { - - guard let studioFeaturesContext, studioImprovementsFlag == nil else { - studioImprovementsFlag = nil - updateStudioFeatures() - return - } - - studioImprovementsFlag = env.subscribe( - GetFeatureFlagState(featureName: .studioEmbedImprovements, context: studioFeaturesContext) - ) { [weak self] in - self?.updateStudioFeatures() - } - - studioImprovementsFlag?.refresh() - } - } - var downloadingAttachment: CoreWebAttachment? internal var a11yHelper = CoreWebViewAccessibilityHelper() @@ -90,8 +71,7 @@ open class CoreWebView: WKWebView { private var env: AppEnvironment = .shared private var subscriptions = Set() - - private var studioImprovementsFlag: Store? + private(set) lazy var studioInteractor = StudioFeaturesInteractor(webView: self) public required init?(coder: NSCoder) { super.init(coder: coder) @@ -121,20 +101,20 @@ open class CoreWebView: WKWebView { setup() } + public func setStudioFeatures(context: Context?, env: AppEnvironment) { + studioInteractor.setupFeatureFlagStore(context: context, env: env) + } + deinit { configuration.userContentController.removeAllScriptMessageHandlers() configuration.userContentController.removeAllUserScripts() } open override func reload() -> WKNavigation? { - prepareForReload() + studioInteractor.refresh() return super.reload() } - public func prepareForReload() { - studioImprovementsFlag?.refresh(force: true) - } - /** 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`. @@ -144,6 +124,15 @@ open class CoreWebView: WKWebView { feature.apply(on: self) } + public func removeFeatures(ofType: T.Type) { + if features.contains(where: { $0 is T }) { + features.removeAll(where: { $0 is T }) + configuration.userContentController.removeAllUserScripts() + features.forEach({ $0.apply(on: self) }) + _ = reload() + } + } + public func scrollIntoView(fragment: String, then: ((Bool) -> Void)? = nil) { guard autoresizesHeight else { return } let script = """ @@ -485,18 +474,6 @@ open class CoreWebView: WKWebView { ) .store(in: &subscriptions) } - - func updateStudioFeatures() { - let isStudioImprovementsEnabled = studioImprovementsFlag?.first?.enabled ?? false - if isStudioImprovementsEnabled { - addFeature(.insertStudioDetailView) - } else if features.contains(where: { $0 is InsertStudioDetailView }) { - features.removeAll(where: { $0 is InsertStudioDetailView }) - configuration.userContentController.removeAllUserScripts() - features.forEach({ $0.apply(on: self) }) - _ = reload() - } - } } // MARK: - WKNavigationDelegate @@ -574,7 +551,7 @@ extension CoreWebView: WKNavigationDelegate { } // Handle Studio Immersive Player links (media_attachments/:id/immersive_view) - if action.handleStudioImmersiveViewIfNeeded(from: linkDelegate?.routeLinksFrom, router: env.router) { + if studioInteractor.handleStudioImmersiveViewIfNeeded(action, from: linkDelegate?.routeLinksFrom, router: env.router) { return decisionHandler(.cancel) } @@ -599,6 +576,7 @@ extension CoreWebView: WKNavigationDelegate { } features.forEach { $0.webView(webView, didFinish: navigation) } + studioInteractor.scanVideoFrames() } public func webView( @@ -872,6 +850,12 @@ extension CoreWebView { content: String?, originalBaseURL: URL? ) { + + print() + print("WEBVIEW - Loading Content:") + print(content ?? "") + print() + if let filePath, isOffline == true, FileManager.default.fileExists(atPath: filePath.path) { loadFileURL( @@ -897,35 +881,4 @@ extension WKNavigationAction { var isCanvasUserContentLinkTap: Bool { navigationType == .linkActivated && request.url?.host()?.hasSuffix(".canvas-user-content.com") == true } - - var isStudioImmersiveViewLinkTap: Bool { - let isExpandLink = navigationType == .other && - request.url?.path.contains("/media_attachments/") == true && - request.url?.path.hasSuffix("/immersive_view") == true && - sourceFrame.isMainFrame == false - - let isDetailsLink = navigationType == .linkActivated && - request.url?.path.contains("/media_attachments/") == true && - request.url?.path.hasSuffix("/immersive_view") == true && - (targetFrame?.isMainFrame ?? false) == false - - return isExpandLink || isDetailsLink - } - - func handleStudioImmersiveViewIfNeeded(from viewController: UIViewController?, router: Router) -> Bool { - guard isStudioImmersiveViewLinkTap, - var url = request.url, - let viewController - else { - return false - } - - if url.containsQueryItem(named: "embedded") == false { - url.append(queryItems: [.init(name: "embedded", value: "true")]) - } - - let controller = StudioViewController(url: url) - router.show(controller, from: viewController, options: .modal(.overFullScreen)) - return true - } } diff --git a/Core/Core/Common/CommonUI/SwiftUIViews/WebView.swift b/Core/Core/Common/CommonUI/SwiftUIViews/WebView.swift index ab5035ba0a..d982076cc2 100644 --- a/Core/Core/Common/CommonUI/SwiftUIViews/WebView.swift +++ b/Core/Core/Common/CommonUI/SwiftUIViews/WebView.swift @@ -158,7 +158,7 @@ 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.studioFeaturesContext = featuresContext + webView.setStudioFeatures(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. @@ -214,7 +214,6 @@ extension WebView { ) { reloadObserver?.cancel() reloadObserver = trigger?.sink { - webView.prepareForReload() webView.reload() } } diff --git a/Core/Core/Features/Discussions/DiscussionDetails/DiscussionDetailsViewController.swift b/Core/Core/Features/Discussions/DiscussionDetails/DiscussionDetailsViewController.swift index 5d51a41505..3ace061f41 100644 --- a/Core/Core/Features/Discussions/DiscussionDetails/DiscussionDetailsViewController.swift +++ b/Core/Core/Features/Discussions/DiscussionDetails/DiscussionDetailsViewController.swift @@ -154,7 +154,7 @@ public class DiscussionDetailsViewController: ScreenViewTrackableViewController, webView.backgroundColor = .backgroundLightest webView.linkDelegate = self webView.errorDelegate = self - webView.studioFeaturesContext = context + webView.setStudioFeatures(context: context, env: env) webView.addScript(DiscussionHTML.preact) webView.addScript(DiscussionHTML.js) webView.handle("like") { [weak self] message in self?.handleLike(message) } diff --git a/Core/Core/Features/Discussions/DiscussionReply/DiscussionReplyViewController.swift b/Core/Core/Features/Discussions/DiscussionReply/DiscussionReplyViewController.swift index 2c8af7dd59..d9a4644731 100644 --- a/Core/Core/Features/Discussions/DiscussionReply/DiscussionReplyViewController.swift +++ b/Core/Core/Features/Discussions/DiscussionReply/DiscussionReplyViewController.swift @@ -167,7 +167,7 @@ public class DiscussionReplyViewController: ScreenViewTrackableViewController, E webView.autoresizesHeight = true webView.backgroundColor = .backgroundLightest webView.linkDelegate = self - webView.studioFeaturesContext = context + webView.setStudioFeatures(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 95ad30ddb5..3b120dfea0 100644 --- a/Core/Core/Features/Files/View/FileDetails/FileDetailsViewController.swift +++ b/Core/Core/Features/Files/View/FileDetails/FileDetailsViewController.swift @@ -312,7 +312,7 @@ public class FileDetailsViewController: ScreenViewTrackableViewController, CoreW contentView.addSubview(webView) webView.pinWithThemeSwitchButton(inside: contentView) webView.linkDelegate = self - webView.studioFeaturesContext = context + webView.setStudioFeatures(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 ac42cab63e..b6fead54db 100644 --- a/Core/Core/Features/LTI/LTITools.swift +++ b/Core/Core/Features/LTI/LTITools.swift @@ -193,7 +193,7 @@ public class LTITools: NSObject { .hideReturnButtonInQuizLTI, .disableLinksOverlayPreviews ]) - controller.webView.studioFeaturesContext = context + controller.webView.setStudioFeatures(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/Pages/PageDetails/PageDetailsViewController.swift b/Core/Core/Features/Pages/PageDetails/PageDetailsViewController.swift index 320f658584..022350ed11 100644 --- a/Core/Core/Features/Pages/PageDetails/PageDetailsViewController.swift +++ b/Core/Core/Features/Pages/PageDetails/PageDetailsViewController.swift @@ -84,7 +84,7 @@ open class PageDetailsViewController: UIViewController, ColoredNavViewProtocol, webViewContainer.addSubview(webView) webView.pinWithThemeSwitchButton(inside: webViewContainer) webView.linkDelegate = self - webView.studioFeaturesContext = context + webView.setStudioFeatures(context: context, env: env) if context.contextType == .course { webView.addScript("window.ENV={COURSE:{id:\(CoreWebView.jsString(context.id))}}") @@ -118,7 +118,7 @@ open class PageDetailsViewController: UIViewController, ColoredNavViewProtocol, } @objc private func refresh() { - webView.prepareForReload() + webView.studioInteractor.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 57a51ea849..00b39daf97 100644 --- a/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizDetailsViewController.swift +++ b/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizDetailsViewController.swift @@ -94,7 +94,7 @@ public class StudentQuizDetailsViewController: ScreenViewTrackableViewController instructionsWebView.scrollView.alwaysBounceVertical = false instructionsWebView.backgroundColor = .backgroundLightest instructionsWebView.linkDelegate = self - instructionsWebView.studioFeaturesContext = .course(courseID) + instructionsWebView.setStudioFeatures(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 da5c9be627..5eb6a8e500 100644 --- a/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizWebViewController.swift +++ b/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizWebViewController.swift @@ -47,7 +47,7 @@ public class StudentQuizWebViewController: UIViewController { webView.linkDelegate = self webView.uiDelegate = self - webView.studioFeaturesContext = .course(courseID) + webView.setStudioFeatures(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 ac23ae9e82..2a005b0f55 100644 --- a/Core/Core/Features/Syllabus/SyllabusViewController.swift +++ b/Core/Core/Features/Syllabus/SyllabusViewController.swift @@ -44,7 +44,7 @@ open class SyllabusViewController: UIViewController, CoreWebViewLinkDelegate { webView.backgroundColor = .backgroundLightest webView.scrollView.refreshControl = refreshControl webView.linkDelegate = self - webView.studioFeaturesContext = .course(courseID) + webView.setStudioFeatures(context: .course(courseID), env: env) view.addSubview(webView) webView.pinWithThemeSwitchButton(inside: view) diff --git a/Parent/Parent/Courses/CourseDetailsViewController.swift b/Parent/Parent/Courses/CourseDetailsViewController.swift index f4a0999a73..ea82b08714 100644 --- a/Parent/Parent/Courses/CourseDetailsViewController.swift +++ b/Parent/Parent/Courses/CourseDetailsViewController.swift @@ -135,7 +135,7 @@ class CourseDetailsViewController: HorizontalMenuViewController { func configureFrontPage() { let vc = CoreWebViewController() vc.webView.resetEnvironment(env) - vc.featuresContext = .course(courseID) + vc.webView.setStudioInteractor(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 c4a6114bc8..a9c7ac9cea 100644 --- a/Student/Student/Assignments/AssignmentDetails/StudentAssignmentDetailsViewController.swift +++ b/Student/Student/Assignments/AssignmentDetails/StudentAssignmentDetailsViewController.swift @@ -238,7 +238,7 @@ class StudentAssignmentDetailsViewController: ScreenViewTrackableViewController, lockedIconImageView.image = UIImage(named: Panda.Locked.name, in: .core, compatibleWith: nil) // Routing from description - webView.studioFeaturesContext = .course(courseID) + webView.setStudioFeatures(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 829e4a456f..a27533496f 100644 --- a/Student/Student/Submissions/SubmissionDetails/SubmissionDetailsPresenter.swift +++ b/Student/Student/Submissions/SubmissionDetails/SubmissionDetailsPresenter.swift @@ -210,14 +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.studioFeaturesContext = .course(assignment.courseID) + controller.webView.setStudioFeatures(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.studioFeaturesContext = .course(assignment.courseID) + controller.webView.setStudioFeatures(context: .course(assignment.courseID), env: env) controller.webView.accessibilityIdentifier = "SubmissionDetails.onlineTextEntryWebView" controller.webView.loadHTMLString(submission.body ?? "") return controller @@ -247,7 +247,7 @@ class SubmissionDetailsPresenter { ) } let controller = CoreWebViewController(features: [.invertColorsInDarkMode]) - controller.webView.studioFeaturesContext = .course(assignment.courseID) + controller.webView.setStudioFeatures(context: .course(assignment.courseID), env: env) controller.webView.accessibilityIdentifier = "SubmissionDetails.webView" controller.webView.load(URLRequest(url: url)) return controller @@ -256,7 +256,7 @@ class SubmissionDetailsPresenter { guard let previewUrl = submission.previewUrl else { break } let controller = CoreWebViewController(features: [.invertColorsInDarkMode]) - controller.webView.studioFeaturesContext = .course(assignment.courseID) + controller.webView.setStudioFeatures(context: .course(assignment.courseID), env: env) controller.webView.accessibilityIdentifier = "SubmissionDetails.discussionWebView" controller.webView.load(URLRequest(url: previewUrl)) return controller From 7c48f36bc5ecd0531d828f0d62329846c2def48a Mon Sep 17 00:00:00 2001 From: Suhaib Al-Absi Date: Tue, 11 Nov 2025 23:22:37 +0300 Subject: [PATCH 09/45] Fix feature removal issues --- .../Model/Features/CoreWebViewFeature.swift | 1 + .../InsertStudioOpenDetailViewButton.swift | 4 ++++ .../CoreWebView/View/CoreWebView.swift | 20 +++++++++---------- .../WebKit/WKWebViewExtensions.swift | 10 ++++++++++ .../Courses/CourseDetailsViewController.swift | 2 +- 5 files changed, 25 insertions(+), 12 deletions(-) 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 index f0335e573a..4bcac1d625 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift @@ -70,6 +70,10 @@ class InsertStudioOpenInDetailButtons: CoreWebViewFeature { override func apply(on webView: CoreWebView) { webView.addScript(insertScript) } + + override func remove(from: CoreWebView) { + webView.removeScript(insertScript) + } } public extension CoreWebViewFeature { diff --git a/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift b/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift index 52c5c2c763..4217704522 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift @@ -124,13 +124,17 @@ open class CoreWebView: WKWebView { feature.apply(on: self) } - public func removeFeatures(ofType: T.Type) { - if features.contains(where: { $0 is T }) { - features.removeAll(where: { $0 is T }) - configuration.userContentController.removeAllUserScripts() - features.forEach({ $0.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) _ = reload() + return true } + return false } public func scrollIntoView(fragment: String, then: ((Bool) -> Void)? = nil) { @@ -850,12 +854,6 @@ extension CoreWebView { content: String?, originalBaseURL: URL? ) { - - print() - print("WEBVIEW - Loading Content:") - print(content ?? "") - print() - if let filePath, isOffline == true, FileManager.default.fileExists(atPath: filePath.path) { loadFileURL( 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/Parent/Parent/Courses/CourseDetailsViewController.swift b/Parent/Parent/Courses/CourseDetailsViewController.swift index ea82b08714..93d2bfa615 100644 --- a/Parent/Parent/Courses/CourseDetailsViewController.swift +++ b/Parent/Parent/Courses/CourseDetailsViewController.swift @@ -135,7 +135,7 @@ class CourseDetailsViewController: HorizontalMenuViewController { func configureFrontPage() { let vc = CoreWebViewController() vc.webView.resetEnvironment(env) - vc.webView.setStudioInteractor(context: .course(courseID), env: env) + vc.webView.setStudioFeatures(context: .course(courseID), env: env) vc.webView.loadHTMLString(frontPages.first?.body ?? "", baseURL: frontPages.first?.htmlURL) viewControllers.append(vc) } From 1844e19dc73667446b4888b483d8b7a3af289608 Mon Sep 17 00:00:00 2001 From: Suhaib Al-Absi Date: Tue, 11 Nov 2025 23:27:12 +0300 Subject: [PATCH 10/45] Update InsertStudioOpenDetailViewButton.swift --- .../Model/Features/InsertStudioOpenDetailViewButton.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift index 4bcac1d625..2ea4cabb12 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift @@ -71,7 +71,7 @@ class InsertStudioOpenInDetailButtons: CoreWebViewFeature { webView.addScript(insertScript) } - override func remove(from: CoreWebView) { + override func remove(from webView: CoreWebView) { webView.removeScript(insertScript) } } From 7b262c8144b59cdf6ac951c3b1e250eb5974357d Mon Sep 17 00:00:00 2001 From: Suhaib Al-Absi Date: Wed, 12 Nov 2025 01:30:12 +0300 Subject: [PATCH 11/45] Button with icon --- .../FeatureFlags/GetFeatureFlagState.swift | 5 +- .../GetFeatureFlagStateRequest.swift | 12 +++- .../InsertStudioOpenDetailViewButton.swift | 63 +++++++++++++++++-- .../Model/StudioFeaturesInteractor.swift | 6 +- .../externalLinkData.dataset/Contents.json | 13 ++++ .../externalLinkData.dataset/externalLink.svg | 3 + 6 files changed, 93 insertions(+), 9 deletions(-) create mode 100644 Core/Core/Resources/Assets.xcassets/Images/externalLinkData.dataset/Contents.json create mode 100644 Core/Core/Resources/Assets.xcassets/Images/externalLinkData.dataset/externalLink.svg diff --git a/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagState.swift b/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagState.swift index 10ae599d23..7dd67cf0bd 100644 --- a/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagState.swift +++ b/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagState.swift @@ -61,7 +61,10 @@ public class GetFeatureFlagState: APIUseCase { /// the most higher level (which is the account) if item.contextType != context.contextType { if case .account = item.contextType { - item.state = item.state == .allowed_on ? .on : item.state + item = item.overriden( + state: item.state == .allowed_on ? .on : item.state, + context: context + ) } } diff --git a/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagStateRequest.swift b/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagStateRequest.swift index 878aa08210..6cbad12f98 100644 --- a/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagStateRequest.swift +++ b/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagStateRequest.swift @@ -53,7 +53,7 @@ public struct APIFeatureFlagState: Codable { } public let feature: String - public var state: State + public let state: State public let locked: Bool private let context_id: String @@ -66,4 +66,14 @@ public struct APIFeatureFlagState: Codable { 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/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift index 2ea4cabb12..29453c64ce 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift @@ -16,30 +16,70 @@ // along with this program. If not, see . // +import UIKit + class InsertStudioOpenInDetailButtons: CoreWebViewFeature { + private let insertStyle: String = { + let css = """ + p[ios-injected] { + text-align: center; + } + + .open_details_button { + font-weight: 400; + font-size: 15px; + text-decoration: none; + color: #2B7ABC; + } + + .open_details_button_icon { + display: inline-block; + height: 100%; + vertical-align: middle; + padding-right: 6px; + padding-left: 6px; + } + """ + + let cssString = css.components(separatedBy: .newlines).joined() + return """ + function insertStudioDetailLinksStyle() { + var element = document.createElement('style'); + element.innerHTML = '\(cssString)'; + document.head.appendChild(element); + } + insertStudioDetailLinksStyle(); + """ + }() + 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 insertStudioDetailsLinks() { const frameElements = document.querySelectorAll('iframe[data-media-id]'); frameElements.forEach(elm => { - let next = elm.nextElementSibling.nextElementSibling; - let wasInjected = next.getAttribute("ios-injected"); + 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 title = videoTitle ?? ariaTitle; var frameLink = elm.getAttribute("src"); frameLink = frameLink.replace("media_attachments_iframe", "media_attachments"); var linkSuffix = "/immersive_view"; if(title){ + title = title.replace("Video player for ", "").replace(".mp4", ""); linkSuffix = "/immersive_view?title=" + encodeURIComponent(title); } @@ -47,13 +87,22 @@ class InsertStudioOpenInDetailButtons: CoreWebViewFeature { 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 = '\(iconSVG)'; + const detailButton = document.createElement('a'); - detailButton.className = "details_view_link"; + detailButton.className = "open_details_button"; detailButton.href = frameLink + linkSuffix; - detailButton.target = "_detail_view"; + detailButton.target = "_blank"; detailButton.textContent = '\(title)'; - newParagraph.appendChild(detailButton); + buttonContainer.appendChild(icon); + buttonContainer.appendChild(detailButton); + newParagraph.appendChild(buttonContainer); elm.insertAdjacentElement('afterend', newLine); newLine.insertAdjacentElement('afterend', newParagraph); @@ -68,10 +117,12 @@ class InsertStudioOpenInDetailButtons: CoreWebViewFeature { 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) } } diff --git a/Core/Core/Common/CommonUI/CoreWebView/Model/StudioFeaturesInteractor.swift b/Core/Core/Common/CommonUI/CoreWebView/Model/StudioFeaturesInteractor.swift index 675d082163..0c8465f1ba 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/Model/StudioFeaturesInteractor.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/Model/StudioFeaturesInteractor.swift @@ -71,7 +71,9 @@ public class StudioFeaturesInteractor { } private func updateStudioFeatures() { - let isStudioImprovementsEnabled = studioImprovementsFlag?.first?.enabled ?? false + guard let studioImprovementsFlag else { return } + + let isStudioImprovementsEnabled = studioImprovementsFlag.first?.enabled ?? false if isStudioImprovementsEnabled { webView.addFeature(.insertStudioOpenInDetailButtons) } else { @@ -119,6 +121,8 @@ public class StudioFeaturesInteractor { else { return } mapped[urlCleanPath] = title + .replacingOccurrences(of: "Video player for ", with: "") + .replacingOccurrences(of: ".mp4", with: "") }) self?.videoFramesTitleMap = mapped 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 @@ + + + From a5daf3b2e427f8a57588b227eaa70808e40c4ce2 Mon Sep 17 00:00:00 2001 From: Suhaib Al-Absi Date: Wed, 12 Nov 2025 05:27:45 +0300 Subject: [PATCH 12/45] Unit tests --- .../GetFeatureFlagStateRequest.swift | 8 + ... => CoreWebStudioFeaturesInteractor.swift} | 21 +- .../CoreWebView/View/CoreWebView.swift | 17 +- .../PageDetailsViewController.swift | 2 +- .../GetFeatureFlagStateRequestTests.swift | 36 ++++ .../GetFeatureFlagStateTests.swift | 154 ++++++++++++++ ...CoreWebStudioFeaturesInteractorTests.swift | 190 ++++++++++++++++++ ...nsertStudioOpenDetailViewButtonTests.swift | 90 +++++++++ .../CoreWebView/View/CoreWebViewTests.swift | 59 ++++-- 9 files changed, 542 insertions(+), 35 deletions(-) rename Core/Core/Common/CommonUI/CoreWebView/Model/{StudioFeaturesInteractor.swift => CoreWebStudioFeaturesInteractor.swift} (89%) create mode 100644 Core/CoreTests/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagStateRequestTests.swift create mode 100644 Core/CoreTests/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagStateTests.swift create mode 100644 Core/CoreTests/Common/CommonUI/CoreWebView/Model/CoreWebStudioFeaturesInteractorTests.swift create mode 100644 Core/CoreTests/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButtonTests.swift diff --git a/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagStateRequest.swift b/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagStateRequest.swift index 6cbad12f98..2bf4785f03 100644 --- a/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagStateRequest.swift +++ b/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagStateRequest.swift @@ -59,6 +59,14 @@ public struct APIFeatureFlagState: Codable { 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()) } diff --git a/Core/Core/Common/CommonUI/CoreWebView/Model/StudioFeaturesInteractor.swift b/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebStudioFeaturesInteractor.swift similarity index 89% rename from Core/Core/Common/CommonUI/CoreWebView/Model/StudioFeaturesInteractor.swift rename to Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebStudioFeaturesInteractor.swift index 0c8465f1ba..26f6c69719 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/Model/StudioFeaturesInteractor.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebStudioFeaturesInteractor.swift @@ -18,8 +18,9 @@ import WebKit import UIKit +import Combine -public class StudioFeaturesInteractor { +public class CoreWebStudioFeaturesInteractor { private static let scanFramesScript = """ function scanVideoFramesForTitles() { const frameElements = document.querySelectorAll('iframe[data-media-id]'); @@ -45,7 +46,9 @@ public class StudioFeaturesInteractor { unowned let webView: CoreWebView private var studioImprovementsFlag: Store? - private var videoFramesTitleMap: [String: String] = [:] + private(set) var videoFramesTitleMap: [String: String] = [:] + + var onScanFinished: (() -> Void)? init(webView: CoreWebView) { self.webView = webView @@ -81,12 +84,9 @@ public class StudioFeaturesInteractor { } } - func handleStudioImmersiveViewIfNeeded(_ action: WKNavigationAction, from viewController: UIViewController?, router: Router) -> Bool { - guard action.isStudioImmersiveViewLinkTap, - var url = action.request.url, - let viewController - else { - return false + func urlForStudioImmersiveView(of action: WKNavigationAction) -> URL? { + guard action.isStudioImmersiveViewLinkTap, var url = action.request.url else { + return nil } if url.containsQueryItem(named: "title") == false, @@ -98,9 +98,7 @@ public class StudioFeaturesInteractor { url.append(queryItems: [.init(name: "embedded", value: "true")]) } - let controller = StudioViewController(url: url) - router.show(controller, from: viewController, options: .modal(.overFullScreen)) - return true + return url } func scanVideoFrames() { @@ -126,6 +124,7 @@ public class StudioFeaturesInteractor { }) self?.videoFramesTitleMap = mapped + self?.onScanFinished?() } } diff --git a/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift b/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift index 4217704522..19ea5316ec 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift @@ -71,7 +71,7 @@ open class CoreWebView: WKWebView { private var env: AppEnvironment = .shared private var subscriptions = Set() - private(set) lazy var studioInteractor = StudioFeaturesInteractor(webView: self) + private(set) lazy var studioFeaturesInteractor = CoreWebStudioFeaturesInteractor(webView: self) public required init?(coder: NSCoder) { super.init(coder: coder) @@ -102,7 +102,7 @@ open class CoreWebView: WKWebView { } public func setStudioFeatures(context: Context?, env: AppEnvironment) { - studioInteractor.setupFeatureFlagStore(context: context, env: env) + studioFeaturesInteractor.setupFeatureFlagStore(context: context, env: env) } deinit { @@ -111,7 +111,7 @@ open class CoreWebView: WKWebView { } open override func reload() -> WKNavigation? { - studioInteractor.refresh() + studioFeaturesInteractor.refresh() return super.reload() } @@ -555,7 +555,14 @@ extension CoreWebView: WKNavigationDelegate { } // Handle Studio Immersive Player links (media_attachments/:id/immersive_view) - if studioInteractor.handleStudioImmersiveViewIfNeeded(action, from: linkDelegate?.routeLinksFrom, router: env.router) { + if let immersiveURL = studioFeaturesInteractor.urlForStudioImmersiveView(of: action), + let controller = linkDelegate?.routeLinksFrom { + + env.router.show( + StudioViewController(url: immersiveURL), + from: controller, + options: .modal(.overFullScreen) + ) return decisionHandler(.cancel) } @@ -580,7 +587,7 @@ extension CoreWebView: WKNavigationDelegate { } features.forEach { $0.webView(webView, didFinish: navigation) } - studioInteractor.scanVideoFrames() + studioFeaturesInteractor.scanVideoFrames() } public func webView( diff --git a/Core/Core/Features/Pages/PageDetails/PageDetailsViewController.swift b/Core/Core/Features/Pages/PageDetails/PageDetailsViewController.swift index 022350ed11..6da4d5c2e1 100644 --- a/Core/Core/Features/Pages/PageDetails/PageDetailsViewController.swift +++ b/Core/Core/Features/Pages/PageDetails/PageDetailsViewController.swift @@ -118,7 +118,7 @@ open class PageDetailsViewController: UIViewController, ColoredNavViewProtocol, } @objc private func refresh() { - webView.studioInteractor.refresh() + webView.studioFeaturesInteractor.refresh() pages.refresh(force: true) { [weak self] _ in self?.refreshControl.endRefreshing() } diff --git a/Core/CoreTests/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagStateRequestTests.swift b/Core/CoreTests/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagStateRequestTests.swift new file mode 100644 index 0000000000..0b691ffd77 --- /dev/null +++ b/Core/CoreTests/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagStateRequestTests.swift @@ -0,0 +1,36 @@ +// +// 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 GetFeatureFlagStateRequestTests: CoreTestCase { + + 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/CoreWebStudioFeaturesInteractorTests.swift b/Core/CoreTests/Common/CommonUI/CoreWebView/Model/CoreWebStudioFeaturesInteractorTests.swift new file mode 100644 index 0000000000..b061e66983 --- /dev/null +++ b/Core/CoreTests/Common/CommonUI/CoreWebView/Model/CoreWebStudioFeaturesInteractorTests.swift @@ -0,0 +1,190 @@ +// +// 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 CoreWebStudioFeaturesInteractorTests: 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 + interactor.setupFeatureFlagStore(context: context, env: environment) + + // 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 + interactor.setupFeatureFlagStore(context: context, env: environment) + + // 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 + interactor.setupFeatureFlagStore(context: context, env: environment) + + // 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..8c7fe0fb6c --- /dev/null +++ b/Core/CoreTests/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButtonTests.swift @@ -0,0 +1,90 @@ +// +// 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 { 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 Video Title") + 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 ea07b0b452..44ebc85b7b 100644 --- a/Core/CoreTests/Common/CommonUI/CoreWebView/View/CoreWebViewTests.swift +++ b/Core/CoreTests/Common/CommonUI/CoreWebView/View/CoreWebViewTests.swift @@ -119,24 +119,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 - } - - init(url: String, type: WKNavigationType) { - mockRequest = URLRequest(url: URL(string: url)!) - mockType = type - super.init() - } - } - class MockNavigationResponse: WKNavigationResponse { let mockResponse: URLResponse override var response: URLResponse { mockResponse } @@ -405,3 +387,44 @@ 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? = nil, targetFrame: MockFrameInfo? = nil) { + mockRequest = URLRequest(url: URL(string: url)!) + mockType = type + mockSourceFrame = sourceFrame + mockTargetFrame = targetFrame + super.init() + } + + override var sourceFrame: WKFrameInfo { + mockSourceFrame ?? super.sourceFrame + } + + override var targetFrame: WKFrameInfo? { + mockTargetFrame ?? super.targetFrame + } +} + +class MockFrameInfo: WKFrameInfo { + + let mockIsMainFrame: Bool + init(isMainFrame: Bool) { + mockIsMainFrame = isMainFrame + super.init() + } + + override var isMainFrame: Bool { mockIsMainFrame } +} From 6e4cd3bdc35c8939846e903ab2c67268be1811b3 Mon Sep 17 00:00:00 2001 From: Suhaib Al-Absi Date: Sun, 16 Nov 2025 18:30:50 +0300 Subject: [PATCH 13/45] Address review comments --- .../FeatureFlags/APIFeatureFlagState.swift | 66 +++++++++++++ .../FeatureFlags/GetFeatureFlagState.swift | 25 ++--- .../GetFeatureFlagStateRequest.swift | 46 +-------- .../CoreWebStudioFeaturesInteractor.swift | 99 ++++++++++++------- Core/Core/Resources/Localizable.xcstrings | 3 +- .../FeatureFlags/FeatureFlagTests.swift | 27 ----- ...nsertStudioOpenDetailViewButtonTests.swift | 5 +- 7 files changed, 143 insertions(+), 128 deletions(-) create mode 100644 Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/APIFeatureFlagState.swift delete mode 100644 Core/CoreTests/Common/CommonModels/AppEnvironment/FeatureFlags/FeatureFlagTests.swift 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/GetFeatureFlagState.swift b/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagState.swift index 7dd67cf0bd..04ea7fbe7e 100644 --- a/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagState.swift +++ b/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagState.swift @@ -19,7 +19,7 @@ import Foundation import CoreData -public class GetFeatureFlagState: APIUseCase { +public class GetFeatureFlagState: CollectionUseCase { public typealias Model = FeatureFlag public let featureName: FeatureFlagName @@ -51,28 +51,19 @@ public class GetFeatureFlagState: APIUseCase { } public func write(response: APIFeatureFlagState?, urlResponse: URLResponse?, to client: NSManagedObjectContext) { - guard let response = response else { return } + guard var item = response else { return } - var item = response - - /// This as walkaround for an API limitation, where + /// 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 { - if case .account = item.contextType { - item = item.overriden( - state: item.state == .allowed_on ? .on : item.state, - context: context - ) - } + 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) } - - public func reset(context: NSManagedObjectContext) { - let all = context.fetch(scope: scope) as [Model] - context.delete(all) - } } diff --git a/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagStateRequest.swift b/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagStateRequest.swift index 2bf4785f03..7b4ad2989f 100644 --- a/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagStateRequest.swift +++ b/Core/Core/Common/CommonModels/AppEnvironment/FeatureFlags/GetFeatureFlagStateRequest.swift @@ -18,6 +18,7 @@ import Foundation +// https://canvas.instructure.com/doc/api/feature_flags.html#method.feature_flags.show public struct GetFeatureFlagStateRequest: APIRequestable { public typealias Response = APIFeatureFlagState @@ -40,48 +41,3 @@ public enum FeatureFlagName: String { case assignmentEnhancements = "assignments_2_student" case studioEmbedImprovements = "rce_studio_embed_improvements" } - -// MARK: - Response - -public struct APIFeatureFlagState: Codable { - - public enum State: String, Codable { - case allowed - case allowed_on - case on - 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/CommonUI/CoreWebView/Model/CoreWebStudioFeaturesInteractor.swift b/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebStudioFeaturesInteractor.swift index 26f6c69719..ae6911e928 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebStudioFeaturesInteractor.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebStudioFeaturesInteractor.swift @@ -16,9 +16,8 @@ // along with this program. If not, see . // -import WebKit -import UIKit import Combine +import WebKit public class CoreWebStudioFeaturesInteractor { private static let scanFramesScript = """ @@ -45,7 +44,11 @@ public class CoreWebStudioFeaturesInteractor { """ unowned let webView: CoreWebView - private var studioImprovementsFlag: Store? + 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] = [:] var onScanFinished: (() -> Void)? @@ -56,32 +59,21 @@ public class CoreWebStudioFeaturesInteractor { func setupFeatureFlagStore(context: Context?, env: AppEnvironment) { guard let context else { - studioImprovementsFlag = nil + storeSubscription?.cancel() + storeSubscription = nil + studioImprovementsFlagStore = nil return } - studioImprovementsFlag = env.subscribe( - GetFeatureFlagState(featureName: .studioEmbedImprovements, context: context) - ) { [weak self] in - self?.updateStudioFeatures() - } + studioImprovementsFlagStore = ReactiveStore( + useCase: GetFeatureFlagState( + featureName: .studioEmbedImprovements, + context: context + ), + environment: env + ) - studioImprovementsFlag?.refresh() - } - - public func refresh() { - studioImprovementsFlag?.refresh(force: true) - } - - private func updateStudioFeatures() { - guard let studioImprovementsFlag else { return } - - let isStudioImprovementsEnabled = studioImprovementsFlag.first?.enabled ?? false - if isStudioImprovementsEnabled { - webView.addFeature(.insertStudioOpenInDetailButtons) - } else { - webView.removeFeatures(ofType: InsertStudioOpenInDetailButtons.self) - } + resetStoreSubscription() } func urlForStudioImmersiveView(of action: WKNavigationAction) -> URL? { @@ -101,6 +93,11 @@ public class CoreWebStudioFeaturesInteractor { 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() { videoFramesTitleMap.removeAll() @@ -128,26 +125,56 @@ public class CoreWebStudioFeaturesInteractor { } } + 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) { + if isEnabled { + webView.addFeature(.insertStudioOpenInDetailButtons) + } else { + webView.removeFeatures(ofType: InsertStudioOpenInDetailButtons.self) + } + } + private func videoPlayerFrameTitle(matching url: URL) -> String? { let path = url.removingQueryAndFragment().absoluteString - return videoFramesTitleMap.first(where: { path.hasPrefix($0.key) })?.value + return videoFramesTitleMap.first(where: { path.hasPrefix($0.key) })? + .value } } // MARK: - WKNavigationAction Extensions -private extension WKNavigationAction { +extension WKNavigationAction { + + fileprivate var isStudioImmersiveViewLinkTap: Bool { + guard let path = request.url?.path else { return false } - var isStudioImmersiveViewLinkTap: Bool { - let isExpandLink = navigationType == .other && - request.url?.path.contains("/media_attachments/") == true && - request.url?.path.hasSuffix("/immersive_view") == true && - sourceFrame.isMainFrame == false + let isExpandLink = + navigationType == .other + && path.contains("/media_attachments/") == true + && path.hasSuffix("/immersive_view") == true + && sourceFrame.isMainFrame == false - let isDetailsLink = navigationType == .linkActivated && - request.url?.path.contains("/media_attachments/") == true && - request.url?.path.hasSuffix("/immersive_view") == true && - (targetFrame?.isMainFrame ?? false) == 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/Resources/Localizable.xcstrings b/Core/Core/Resources/Localizable.xcstrings index e511518332..9d6937e31a 100644 --- a/Core/Core/Resources/Localizable.xcstrings +++ b/Core/Core/Resources/Localizable.xcstrings @@ -259043,8 +259043,7 @@ } }, "Open in Detail View" : { - "comment" : "JavaScript to be injected into web views to add buttons that allow users to open embedded videos in a separate, detailed view.", - "isCommentAutoGenerated" : true + "comment" : "Label for button that opens an embedded video in a separate, detailed view." }, "Open in Safari" : { "localizations" : { diff --git a/Core/CoreTests/Common/CommonModels/AppEnvironment/FeatureFlags/FeatureFlagTests.swift b/Core/CoreTests/Common/CommonModels/AppEnvironment/FeatureFlags/FeatureFlagTests.swift deleted file mode 100644 index 77e8f48f73..0000000000 --- a/Core/CoreTests/Common/CommonModels/AppEnvironment/FeatureFlags/FeatureFlagTests.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// This file is part of Canvas. -// Copyright (C) 2022-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 FeatureFlagTests: XCTestCase { - - func testFeatureFlagKeys() { - XCTAssertEqual(FeatureFlagName.assignmentEnhancements.rawValue, "assignments_2_student") - } -} diff --git a/Core/CoreTests/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButtonTests.swift b/Core/CoreTests/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButtonTests.swift index 8c7fe0fb6c..7e73be0fe1 100644 --- a/Core/CoreTests/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButtonTests.swift +++ b/Core/CoreTests/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButtonTests.swift @@ -69,7 +69,10 @@ class InsertStudioOpenDetailViewButtonTests: XCTestCase { let list = result as? [String] let urls = list?.compactMap({ URL(string: $0) }) ?? [] - guard urls.count == 2 else { return } + guard urls.count == 2 else { + XCTFail("Expecting 2 URLs to be evaluated") + return + } XCTAssertEqual( urls[0].removingQueryAndFragment().absoluteString, From 304d8659f9c64e1ba37a65c3ef3253fe8dbc92e2 Mon Sep 17 00:00:00 2001 From: Suhaib Al-Absi Date: Sun, 16 Nov 2025 18:52:46 +0300 Subject: [PATCH 14/45] Naming methods --- .../Model/CoreWebStudioFeaturesInteractor.swift | 2 +- .../Common/CommonUI/CoreWebView/View/CoreWebView.swift | 4 ++-- Core/Core/Common/CommonUI/SwiftUIViews/WebView.swift | 2 +- .../DiscussionDetailsViewController.swift | 2 +- .../DiscussionReply/DiscussionReplyViewController.swift | 2 +- .../View/FileDetails/FileDetailsViewController.swift | 2 +- Core/Core/Features/LTI/LTITools.swift | 2 +- .../Pages/PageDetails/PageDetailsViewController.swift | 2 +- .../Student/StudentQuizDetailsViewController.swift | 2 +- .../Student/StudentQuizWebViewController.swift | 2 +- Core/Core/Features/Syllabus/SyllabusViewController.swift | 2 +- .../Model/CoreWebStudioFeaturesInteractorTests.swift | 6 +++--- Parent/Parent/Courses/CourseDetailsViewController.swift | 2 +- .../StudentAssignmentDetailsViewController.swift | 2 +- .../SubmissionDetails/SubmissionDetailsPresenter.swift | 8 ++++---- 15 files changed, 21 insertions(+), 21 deletions(-) diff --git a/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebStudioFeaturesInteractor.swift b/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebStudioFeaturesInteractor.swift index ae6911e928..622ab87b1f 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebStudioFeaturesInteractor.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebStudioFeaturesInteractor.swift @@ -57,7 +57,7 @@ public class CoreWebStudioFeaturesInteractor { self.webView = webView } - func setupFeatureFlagStore(context: Context?, env: AppEnvironment) { + func resetFeatureFlagStore(context: Context?, env: AppEnvironment) { guard let context else { storeSubscription?.cancel() storeSubscription = nil diff --git a/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift b/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift index 19ea5316ec..811a0faebe 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift @@ -101,8 +101,8 @@ open class CoreWebView: WKWebView { setup() } - public func setStudioFeatures(context: Context?, env: AppEnvironment) { - studioFeaturesInteractor.setupFeatureFlagStore(context: context, env: env) + public func resetStudioFeatures(context: Context?, env: AppEnvironment) { + studioFeaturesInteractor.resetFeatureFlagStore(context: context, env: env) } deinit { diff --git a/Core/Core/Common/CommonUI/SwiftUIViews/WebView.swift b/Core/Core/Common/CommonUI/SwiftUIViews/WebView.swift index d982076cc2..1e3c426589 100644 --- a/Core/Core/Common/CommonUI/SwiftUIViews/WebView.swift +++ b/Core/Core/Common/CommonUI/SwiftUIViews/WebView.swift @@ -158,7 +158,7 @@ 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.setStudioFeatures(context: featuresContext, env: env) + webView.resetStudioFeatures(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. diff --git a/Core/Core/Features/Discussions/DiscussionDetails/DiscussionDetailsViewController.swift b/Core/Core/Features/Discussions/DiscussionDetails/DiscussionDetailsViewController.swift index 3ace061f41..714b417dbb 100644 --- a/Core/Core/Features/Discussions/DiscussionDetails/DiscussionDetailsViewController.swift +++ b/Core/Core/Features/Discussions/DiscussionDetails/DiscussionDetailsViewController.swift @@ -154,7 +154,7 @@ public class DiscussionDetailsViewController: ScreenViewTrackableViewController, webView.backgroundColor = .backgroundLightest webView.linkDelegate = self webView.errorDelegate = self - webView.setStudioFeatures(context: context, env: env) + webView.resetStudioFeatures(context: context, env: env) webView.addScript(DiscussionHTML.preact) webView.addScript(DiscussionHTML.js) webView.handle("like") { [weak self] message in self?.handleLike(message) } diff --git a/Core/Core/Features/Discussions/DiscussionReply/DiscussionReplyViewController.swift b/Core/Core/Features/Discussions/DiscussionReply/DiscussionReplyViewController.swift index d9a4644731..5dc5c95d09 100644 --- a/Core/Core/Features/Discussions/DiscussionReply/DiscussionReplyViewController.swift +++ b/Core/Core/Features/Discussions/DiscussionReply/DiscussionReplyViewController.swift @@ -167,7 +167,7 @@ public class DiscussionReplyViewController: ScreenViewTrackableViewController, E webView.autoresizesHeight = true webView.backgroundColor = .backgroundLightest webView.linkDelegate = self - webView.setStudioFeatures(context: context, env: env) + webView.resetStudioFeatures(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 3b120dfea0..d6ceea247c 100644 --- a/Core/Core/Features/Files/View/FileDetails/FileDetailsViewController.swift +++ b/Core/Core/Features/Files/View/FileDetails/FileDetailsViewController.swift @@ -312,7 +312,7 @@ public class FileDetailsViewController: ScreenViewTrackableViewController, CoreW contentView.addSubview(webView) webView.pinWithThemeSwitchButton(inside: contentView) webView.linkDelegate = self - webView.setStudioFeatures(context: context, env: env) + webView.resetStudioFeatures(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 b6fead54db..6cf72fe4d8 100644 --- a/Core/Core/Features/LTI/LTITools.swift +++ b/Core/Core/Features/LTI/LTITools.swift @@ -193,7 +193,7 @@ public class LTITools: NSObject { .hideReturnButtonInQuizLTI, .disableLinksOverlayPreviews ]) - controller.webView.setStudioFeatures(context: context, env: env) + controller.webView.resetStudioFeatures(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/Pages/PageDetails/PageDetailsViewController.swift b/Core/Core/Features/Pages/PageDetails/PageDetailsViewController.swift index 6da4d5c2e1..024c110024 100644 --- a/Core/Core/Features/Pages/PageDetails/PageDetailsViewController.swift +++ b/Core/Core/Features/Pages/PageDetails/PageDetailsViewController.swift @@ -84,7 +84,7 @@ open class PageDetailsViewController: UIViewController, ColoredNavViewProtocol, webViewContainer.addSubview(webView) webView.pinWithThemeSwitchButton(inside: webViewContainer) webView.linkDelegate = self - webView.setStudioFeatures(context: context, env: env) + webView.resetStudioFeatures(context: context, env: env) if context.contextType == .course { webView.addScript("window.ENV={COURSE:{id:\(CoreWebView.jsString(context.id))}}") diff --git a/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizDetailsViewController.swift b/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizDetailsViewController.swift index 00b39daf97..77d80ff75a 100644 --- a/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizDetailsViewController.swift +++ b/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizDetailsViewController.swift @@ -94,7 +94,7 @@ public class StudentQuizDetailsViewController: ScreenViewTrackableViewController instructionsWebView.scrollView.alwaysBounceVertical = false instructionsWebView.backgroundColor = .backgroundLightest instructionsWebView.linkDelegate = self - instructionsWebView.setStudioFeatures(context: .course(courseID), env: env) + instructionsWebView.resetStudioFeatures(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 5eb6a8e500..d32e5dd1b5 100644 --- a/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizWebViewController.swift +++ b/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizWebViewController.swift @@ -47,7 +47,7 @@ public class StudentQuizWebViewController: UIViewController { webView.linkDelegate = self webView.uiDelegate = self - webView.setStudioFeatures(context: .course(courseID), env: env) + webView.resetStudioFeatures(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 2a005b0f55..0e114034e2 100644 --- a/Core/Core/Features/Syllabus/SyllabusViewController.swift +++ b/Core/Core/Features/Syllabus/SyllabusViewController.swift @@ -44,7 +44,7 @@ open class SyllabusViewController: UIViewController, CoreWebViewLinkDelegate { webView.backgroundColor = .backgroundLightest webView.scrollView.refreshControl = refreshControl webView.linkDelegate = self - webView.setStudioFeatures(context: .course(courseID), env: env) + webView.resetStudioFeatures(context: .course(courseID), env: env) view.addSubview(webView) webView.pinWithThemeSwitchButton(inside: view) diff --git a/Core/CoreTests/Common/CommonUI/CoreWebView/Model/CoreWebStudioFeaturesInteractorTests.swift b/Core/CoreTests/Common/CommonUI/CoreWebView/Model/CoreWebStudioFeaturesInteractorTests.swift index b061e66983..460321ae19 100644 --- a/Core/CoreTests/Common/CommonUI/CoreWebView/Model/CoreWebStudioFeaturesInteractorTests.swift +++ b/Core/CoreTests/Common/CommonUI/CoreWebView/Model/CoreWebStudioFeaturesInteractorTests.swift @@ -78,7 +78,7 @@ class CoreWebStudioFeaturesInteractorTests: CoreTestCase { ) // when - interactor.setupFeatureFlagStore(context: context, env: environment) + interactor.resetFeatureFlagStore(context: context, env: environment) // Then XCTAssertTrue( webView.features.contains(where: { $0 is InsertStudioOpenInDetailButtons }) ) @@ -103,7 +103,7 @@ class CoreWebStudioFeaturesInteractorTests: CoreTestCase { ) // when - interactor.setupFeatureFlagStore(context: context, env: environment) + interactor.resetFeatureFlagStore(context: context, env: environment) // Then XCTAssertFalse( webView.features.contains(where: { $0 is InsertStudioOpenInDetailButtons }) ) @@ -128,7 +128,7 @@ class CoreWebStudioFeaturesInteractorTests: CoreTestCase { ) // when - interactor.setupFeatureFlagStore(context: context, env: environment) + interactor.resetFeatureFlagStore(context: context, env: environment) // Then XCTAssertTrue( webView.features.contains(where: { $0 is InsertStudioOpenInDetailButtons }) ) diff --git a/Parent/Parent/Courses/CourseDetailsViewController.swift b/Parent/Parent/Courses/CourseDetailsViewController.swift index 93d2bfa615..c532ef0d05 100644 --- a/Parent/Parent/Courses/CourseDetailsViewController.swift +++ b/Parent/Parent/Courses/CourseDetailsViewController.swift @@ -135,7 +135,7 @@ class CourseDetailsViewController: HorizontalMenuViewController { func configureFrontPage() { let vc = CoreWebViewController() vc.webView.resetEnvironment(env) - vc.webView.setStudioFeatures(context: .course(courseID), env: env) + vc.webView.resetStudioFeatures(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 a9c7ac9cea..8c3d2062e0 100644 --- a/Student/Student/Assignments/AssignmentDetails/StudentAssignmentDetailsViewController.swift +++ b/Student/Student/Assignments/AssignmentDetails/StudentAssignmentDetailsViewController.swift @@ -238,7 +238,7 @@ class StudentAssignmentDetailsViewController: ScreenViewTrackableViewController, lockedIconImageView.image = UIImage(named: Panda.Locked.name, in: .core, compatibleWith: nil) // Routing from description - webView.setStudioFeatures(context: .course(courseID), env: env) + webView.resetStudioFeatures(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 a27533496f..96dac444a3 100644 --- a/Student/Student/Submissions/SubmissionDetails/SubmissionDetailsPresenter.swift +++ b/Student/Student/Submissions/SubmissionDetails/SubmissionDetailsPresenter.swift @@ -210,14 +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.setStudioFeatures(context: .course(assignment.courseID), env: env) + controller.webView.resetStudioFeatures(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.setStudioFeatures(context: .course(assignment.courseID), env: env) + controller.webView.resetStudioFeatures(context: .course(assignment.courseID), env: env) controller.webView.accessibilityIdentifier = "SubmissionDetails.onlineTextEntryWebView" controller.webView.loadHTMLString(submission.body ?? "") return controller @@ -247,7 +247,7 @@ class SubmissionDetailsPresenter { ) } let controller = CoreWebViewController(features: [.invertColorsInDarkMode]) - controller.webView.setStudioFeatures(context: .course(assignment.courseID), env: env) + controller.webView.resetStudioFeatures(context: .course(assignment.courseID), env: env) controller.webView.accessibilityIdentifier = "SubmissionDetails.webView" controller.webView.load(URLRequest(url: url)) return controller @@ -256,7 +256,7 @@ class SubmissionDetailsPresenter { guard let previewUrl = submission.previewUrl else { break } let controller = CoreWebViewController(features: [.invertColorsInDarkMode]) - controller.webView.setStudioFeatures(context: .course(assignment.courseID), env: env) + controller.webView.resetStudioFeatures(context: .course(assignment.courseID), env: env) controller.webView.accessibilityIdentifier = "SubmissionDetails.discussionWebView" controller.webView.load(URLRequest(url: previewUrl)) return controller From d459e0aaa7ee8057a04082f8ca5cc4c21e55ff48 Mon Sep 17 00:00:00 2001 From: Suhaib Al-Absi Date: Sun, 16 Nov 2025 18:55:39 +0300 Subject: [PATCH 15/45] Resolve memory leak --- .../CoreWebView/Model/CoreWebStudioFeaturesInteractor.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebStudioFeaturesInteractor.swift b/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebStudioFeaturesInteractor.swift index 622ab87b1f..943a3c32f1 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebStudioFeaturesInteractor.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebStudioFeaturesInteractor.swift @@ -58,8 +58,9 @@ public class CoreWebStudioFeaturesInteractor { } func resetFeatureFlagStore(context: Context?, env: AppEnvironment) { + storeSubscription?.cancel() + guard let context else { - storeSubscription?.cancel() storeSubscription = nil studioImprovementsFlagStore = nil return From 84abcf4eff2a739f75b940381ca57b7c7a3a3464 Mon Sep 17 00:00:00 2001 From: Suhaib Al-Absi Date: Sun, 16 Nov 2025 19:00:37 +0300 Subject: [PATCH 16/45] Revert "Resolve memory leak" This reverts commit d459e0aaa7ee8057a04082f8ca5cc4c21e55ff48. --- .../CoreWebView/Model/CoreWebStudioFeaturesInteractor.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebStudioFeaturesInteractor.swift b/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebStudioFeaturesInteractor.swift index 943a3c32f1..622ab87b1f 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebStudioFeaturesInteractor.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebStudioFeaturesInteractor.swift @@ -58,9 +58,8 @@ public class CoreWebStudioFeaturesInteractor { } func resetFeatureFlagStore(context: Context?, env: AppEnvironment) { - storeSubscription?.cancel() - guard let context else { + storeSubscription?.cancel() storeSubscription = nil studioImprovementsFlagStore = nil return From 6a0059f8744ad47e1fe6da1a88e51437e3f0ecd7 Mon Sep 17 00:00:00 2001 From: Suhaib Al-Absi Date: Mon, 17 Nov 2025 16:51:25 +0300 Subject: [PATCH 17/45] Address code review comments --- ...or.swift => CoreWebViewStudioFeaturesInteractor.swift} | 2 +- .../Model/Features/InsertStudioOpenDetailViewButton.swift | 4 ++-- .../Common/CommonUI/CoreWebView/View/CoreWebView.swift | 5 ++++- Core/Core/Common/CommonUI/SwiftUIViews/WebView.swift | 2 +- .../DiscussionDetailsViewController.swift | 2 +- .../DiscussionReply/DiscussionReplyViewController.swift | 2 +- .../View/FileDetails/FileDetailsViewController.swift | 2 +- Core/Core/Features/LTI/LTITools.swift | 2 +- .../Pages/PageDetails/PageDetailsViewController.swift | 2 +- .../Student/StudentQuizDetailsViewController.swift | 2 +- .../Student/StudentQuizWebViewController.swift | 2 +- Core/Core/Features/Syllabus/SyllabusViewController.swift | 2 +- ...ift => CoreWebViewStudioFeaturesInteractorTests.swift} | 2 +- Parent/Parent/Courses/CourseDetailsViewController.swift | 2 +- .../StudentAssignmentDetailsViewController.swift | 2 +- .../SubmissionDetails/SubmissionDetailsPresenter.swift | 8 ++++---- 16 files changed, 23 insertions(+), 20 deletions(-) rename Core/Core/Common/CommonUI/CoreWebView/Model/{CoreWebStudioFeaturesInteractor.swift => CoreWebViewStudioFeaturesInteractor.swift} (99%) rename Core/CoreTests/Common/CommonUI/CoreWebView/Model/{CoreWebStudioFeaturesInteractorTests.swift => CoreWebViewStudioFeaturesInteractorTests.swift} (99%) diff --git a/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebStudioFeaturesInteractor.swift b/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractor.swift similarity index 99% rename from Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebStudioFeaturesInteractor.swift rename to Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractor.swift index 622ab87b1f..9e5a7027a8 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebStudioFeaturesInteractor.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractor.swift @@ -19,7 +19,7 @@ import Combine import WebKit -public class CoreWebStudioFeaturesInteractor { +public class CoreWebViewStudioFeaturesInteractor { private static let scanFramesScript = """ function scanVideoFramesForTitles() { const frameElements = document.querySelectorAll('iframe[data-media-id]'); diff --git a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift index 29453c64ce..6d9285af6f 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift @@ -92,13 +92,13 @@ class InsertStudioOpenInDetailButtons: CoreWebViewFeature { const icon = document.createElement('div'); icon.className = "open_details_button_icon"; - icon.innerHTML = '\(iconSVG)'; + icon.innerHTML = DOMPurify.sanitize('\(iconSVG)'); const detailButton = document.createElement('a'); detailButton.className = "open_details_button"; detailButton.href = frameLink + linkSuffix; detailButton.target = "_blank"; - detailButton.textContent = '\(title)'; + detailButton.textContent = DOMPurify.sanitize('\(title)'); buttonContainer.appendChild(icon); buttonContainer.appendChild(detailButton); diff --git a/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift b/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift index 811a0faebe..76a26e5baa 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift @@ -101,7 +101,10 @@ open class CoreWebView: WKWebView { setup() } - public func resetStudioFeatures(context: Context?, env: AppEnvironment) { + /// 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) } diff --git a/Core/Core/Common/CommonUI/SwiftUIViews/WebView.swift b/Core/Core/Common/CommonUI/SwiftUIViews/WebView.swift index 1e3c426589..6420849c9b 100644 --- a/Core/Core/Common/CommonUI/SwiftUIViews/WebView.swift +++ b/Core/Core/Common/CommonUI/SwiftUIViews/WebView.swift @@ -158,7 +158,7 @@ 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.resetStudioFeatures(context: featuresContext, env: env) + 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. diff --git a/Core/Core/Features/Discussions/DiscussionDetails/DiscussionDetailsViewController.swift b/Core/Core/Features/Discussions/DiscussionDetails/DiscussionDetailsViewController.swift index 714b417dbb..e73e7ff2e7 100644 --- a/Core/Core/Features/Discussions/DiscussionDetails/DiscussionDetailsViewController.swift +++ b/Core/Core/Features/Discussions/DiscussionDetails/DiscussionDetailsViewController.swift @@ -154,7 +154,7 @@ public class DiscussionDetailsViewController: ScreenViewTrackableViewController, webView.backgroundColor = .backgroundLightest webView.linkDelegate = self webView.errorDelegate = self - webView.resetStudioFeatures(context: context, env: env) + webView.setupStudioFeatures(context: context, env: env) webView.addScript(DiscussionHTML.preact) webView.addScript(DiscussionHTML.js) webView.handle("like") { [weak self] message in self?.handleLike(message) } diff --git a/Core/Core/Features/Discussions/DiscussionReply/DiscussionReplyViewController.swift b/Core/Core/Features/Discussions/DiscussionReply/DiscussionReplyViewController.swift index 5dc5c95d09..e5e788c1b4 100644 --- a/Core/Core/Features/Discussions/DiscussionReply/DiscussionReplyViewController.swift +++ b/Core/Core/Features/Discussions/DiscussionReply/DiscussionReplyViewController.swift @@ -167,7 +167,7 @@ public class DiscussionReplyViewController: ScreenViewTrackableViewController, E webView.autoresizesHeight = true webView.backgroundColor = .backgroundLightest webView.linkDelegate = self - webView.resetStudioFeatures(context: context, env: env) + 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 d6ceea247c..a170b97f08 100644 --- a/Core/Core/Features/Files/View/FileDetails/FileDetailsViewController.swift +++ b/Core/Core/Features/Files/View/FileDetails/FileDetailsViewController.swift @@ -312,7 +312,7 @@ public class FileDetailsViewController: ScreenViewTrackableViewController, CoreW contentView.addSubview(webView) webView.pinWithThemeSwitchButton(inside: contentView) webView.linkDelegate = self - webView.resetStudioFeatures(context: context, env: env) + 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 6cf72fe4d8..56040a3dfa 100644 --- a/Core/Core/Features/LTI/LTITools.swift +++ b/Core/Core/Features/LTI/LTITools.swift @@ -193,7 +193,7 @@ public class LTITools: NSObject { .hideReturnButtonInQuizLTI, .disableLinksOverlayPreviews ]) - controller.webView.resetStudioFeatures(context: context, env: env) + 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/Pages/PageDetails/PageDetailsViewController.swift b/Core/Core/Features/Pages/PageDetails/PageDetailsViewController.swift index 024c110024..f2e6c24f56 100644 --- a/Core/Core/Features/Pages/PageDetails/PageDetailsViewController.swift +++ b/Core/Core/Features/Pages/PageDetails/PageDetailsViewController.swift @@ -84,7 +84,7 @@ open class PageDetailsViewController: UIViewController, ColoredNavViewProtocol, webViewContainer.addSubview(webView) webView.pinWithThemeSwitchButton(inside: webViewContainer) webView.linkDelegate = self - webView.resetStudioFeatures(context: context, env: env) + webView.setupStudioFeatures(context: context, env: env) if context.contextType == .course { webView.addScript("window.ENV={COURSE:{id:\(CoreWebView.jsString(context.id))}}") diff --git a/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizDetailsViewController.swift b/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizDetailsViewController.swift index 77d80ff75a..b3b3114349 100644 --- a/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizDetailsViewController.swift +++ b/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizDetailsViewController.swift @@ -94,7 +94,7 @@ public class StudentQuizDetailsViewController: ScreenViewTrackableViewController instructionsWebView.scrollView.alwaysBounceVertical = false instructionsWebView.backgroundColor = .backgroundLightest instructionsWebView.linkDelegate = self - instructionsWebView.resetStudioFeatures(context: .course(courseID), env: env) + 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 d32e5dd1b5..0325f1ae32 100644 --- a/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizWebViewController.swift +++ b/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizWebViewController.swift @@ -47,7 +47,7 @@ public class StudentQuizWebViewController: UIViewController { webView.linkDelegate = self webView.uiDelegate = self - webView.resetStudioFeatures(context: .course(courseID), env: env) + 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 0e114034e2..d56ef9ea80 100644 --- a/Core/Core/Features/Syllabus/SyllabusViewController.swift +++ b/Core/Core/Features/Syllabus/SyllabusViewController.swift @@ -44,7 +44,7 @@ open class SyllabusViewController: UIViewController, CoreWebViewLinkDelegate { webView.backgroundColor = .backgroundLightest webView.scrollView.refreshControl = refreshControl webView.linkDelegate = self - webView.resetStudioFeatures(context: .course(courseID), env: env) + webView.setupStudioFeatures(context: .course(courseID), env: env) view.addSubview(webView) webView.pinWithThemeSwitchButton(inside: view) diff --git a/Core/CoreTests/Common/CommonUI/CoreWebView/Model/CoreWebStudioFeaturesInteractorTests.swift b/Core/CoreTests/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractorTests.swift similarity index 99% rename from Core/CoreTests/Common/CommonUI/CoreWebView/Model/CoreWebStudioFeaturesInteractorTests.swift rename to Core/CoreTests/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractorTests.swift index 460321ae19..2b03dc296a 100644 --- a/Core/CoreTests/Common/CommonUI/CoreWebView/Model/CoreWebStudioFeaturesInteractorTests.swift +++ b/Core/CoreTests/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractorTests.swift @@ -21,7 +21,7 @@ import WebKit import XCTest import Combine -class CoreWebStudioFeaturesInteractorTests: CoreTestCase { +class CoreWebViewStudioFeaturesInteractorTests: CoreTestCase { private enum TestConstants { static let context = Context(.course, id: "32342") diff --git a/Parent/Parent/Courses/CourseDetailsViewController.swift b/Parent/Parent/Courses/CourseDetailsViewController.swift index c532ef0d05..708dd8e76b 100644 --- a/Parent/Parent/Courses/CourseDetailsViewController.swift +++ b/Parent/Parent/Courses/CourseDetailsViewController.swift @@ -135,7 +135,7 @@ class CourseDetailsViewController: HorizontalMenuViewController { func configureFrontPage() { let vc = CoreWebViewController() vc.webView.resetEnvironment(env) - vc.webView.resetStudioFeatures(context: .course(courseID), env: 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 8c3d2062e0..7cdf6b4b91 100644 --- a/Student/Student/Assignments/AssignmentDetails/StudentAssignmentDetailsViewController.swift +++ b/Student/Student/Assignments/AssignmentDetails/StudentAssignmentDetailsViewController.swift @@ -238,7 +238,7 @@ class StudentAssignmentDetailsViewController: ScreenViewTrackableViewController, lockedIconImageView.image = UIImage(named: Panda.Locked.name, in: .core, compatibleWith: nil) // Routing from description - webView.resetStudioFeatures(context: .course(courseID), env: env) + 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 96dac444a3..0057256299 100644 --- a/Student/Student/Submissions/SubmissionDetails/SubmissionDetailsPresenter.swift +++ b/Student/Student/Submissions/SubmissionDetails/SubmissionDetailsPresenter.swift @@ -210,14 +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.resetStudioFeatures(context: .course(assignment.courseID), env: env) + 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.resetStudioFeatures(context: .course(assignment.courseID), env: env) + controller.webView.setupStudioFeatures(context: .course(assignment.courseID), env: env) controller.webView.accessibilityIdentifier = "SubmissionDetails.onlineTextEntryWebView" controller.webView.loadHTMLString(submission.body ?? "") return controller @@ -247,7 +247,7 @@ class SubmissionDetailsPresenter { ) } let controller = CoreWebViewController(features: [.invertColorsInDarkMode]) - controller.webView.resetStudioFeatures(context: .course(assignment.courseID), env: env) + controller.webView.setupStudioFeatures(context: .course(assignment.courseID), env: env) controller.webView.accessibilityIdentifier = "SubmissionDetails.webView" controller.webView.load(URLRequest(url: url)) return controller @@ -256,7 +256,7 @@ class SubmissionDetailsPresenter { guard let previewUrl = submission.previewUrl else { break } let controller = CoreWebViewController(features: [.invertColorsInDarkMode]) - controller.webView.resetStudioFeatures(context: .course(assignment.courseID), env: env) + controller.webView.setupStudioFeatures(context: .course(assignment.courseID), env: env) controller.webView.accessibilityIdentifier = "SubmissionDetails.discussionWebView" controller.webView.load(URLRequest(url: previewUrl)) return controller From b3bda43e2df2a5e852fc59a5a9a60e0613d22088 Mon Sep 17 00:00:00 2001 From: Suhaib Al-Absi Date: Mon, 17 Nov 2025 16:57:28 +0300 Subject: [PATCH 18/45] Update CoreWebViewTests.swift --- .../Common/CommonUI/CoreWebView/View/CoreWebViewTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Core/CoreTests/Common/CommonUI/CoreWebView/View/CoreWebViewTests.swift b/Core/CoreTests/Common/CommonUI/CoreWebView/View/CoreWebViewTests.swift index 6eabce2578..26ec87e387 100644 --- a/Core/CoreTests/Common/CommonUI/CoreWebView/View/CoreWebViewTests.swift +++ b/Core/CoreTests/Common/CommonUI/CoreWebView/View/CoreWebViewTests.swift @@ -418,7 +418,7 @@ class MockNavigationAction: WKNavigationAction { } override var targetFrame: WKFrameInfo? { - mockTargetFrame ?? super.targetFrame + mockTargetFrame ?? sourceFrame } } From 243d795e81b9736edaf4447c26bfa1563732a395 Mon Sep 17 00:00:00 2001 From: Suhaib Al-Absi Date: Mon, 17 Nov 2025 17:31:03 +0300 Subject: [PATCH 19/45] Few fixes --- .../Model/Features/InsertStudioOpenDetailViewButton.swift | 8 +++++--- .../Common/CommonUI/CoreWebView/View/CoreWebView.swift | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift index 6d9285af6f..11c5a627d0 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift @@ -59,7 +59,7 @@ class InsertStudioOpenInDetailButtons: CoreWebViewFeature { .flatMap({ String(data: $0.data, encoding: .utf8) ?? "" }) ?? "") .components(separatedBy: .newlines).joined() - return """ + let script = """ function insertStudioDetailsLinks() { const frameElements = document.querySelectorAll('iframe[data-media-id]'); @@ -92,13 +92,13 @@ class InsertStudioOpenInDetailButtons: CoreWebViewFeature { const icon = document.createElement('div'); icon.className = "open_details_button_icon"; - icon.innerHTML = DOMPurify.sanitize('\(iconSVG)'); + icon.innerHTML = '%@'; const detailButton = document.createElement('a'); detailButton.className = "open_details_button"; detailButton.href = frameLink + linkSuffix; detailButton.target = "_blank"; - detailButton.textContent = DOMPurify.sanitize('\(title)'); + detailButton.textContent = '%@'; buttonContainer.appendChild(icon); buttonContainer.appendChild(detailButton); @@ -112,6 +112,8 @@ class InsertStudioOpenInDetailButtons: CoreWebViewFeature { insertStudioDetailsLinks(); window.addEventListener("DOMContentLoaded", insertStudioDetailsLinks); """ + + return String(format: script, iconSVG, title) }() public override init() {} diff --git a/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift b/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift index ce23d7277e..d7ac91271f 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift @@ -69,7 +69,7 @@ open class CoreWebView: WKWebView { private var env: AppEnvironment = .shared private var subscriptions = Set() - private(set) lazy var studioFeaturesInteractor = CoreWebStudioFeaturesInteractor(webView: self) + private(set) lazy var studioFeaturesInteractor = CoreWebViewStudioFeaturesInteractor(webView: self) public required init?(coder: NSCoder) { super.init(coder: coder) @@ -559,7 +559,7 @@ extension CoreWebView: WKNavigationDelegate { // 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, From 5366c8898d8807196bb30c08c759b60023538530 Mon Sep 17 00:00:00 2001 From: Suhaib Al-Absi Date: Tue, 18 Nov 2025 16:01:55 +0300 Subject: [PATCH 20/45] Update InsertStudioOpenDetailViewButton.swift --- .../InsertStudioOpenDetailViewButton.swift | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift index 11c5a627d0..9f8f2cfc26 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift @@ -44,12 +44,11 @@ class InsertStudioOpenInDetailButtons: CoreWebViewFeature { let cssString = css.components(separatedBy: .newlines).joined() return """ - function insertStudioDetailLinksStyle() { + (() => { var element = document.createElement('style'); element.innerHTML = '\(cssString)'; document.head.appendChild(element); - } - insertStudioDetailLinksStyle(); + })() """ }() @@ -59,7 +58,16 @@ class InsertStudioOpenInDetailButtons: CoreWebViewFeature { .flatMap({ String(data: $0.data, encoding: .utf8) ?? "" }) ?? "") .components(separatedBy: .newlines).joined() - let script = """ + return """ + function escapeHTML(text) { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/'/g, ''') + .replace(/"/g, '"') + } + function insertStudioDetailsLinks() { const frameElements = document.querySelectorAll('iframe[data-media-id]'); @@ -92,13 +100,13 @@ class InsertStudioOpenInDetailButtons: CoreWebViewFeature { const icon = document.createElement('div'); icon.className = "open_details_button_icon"; - icon.innerHTML = '%@'; + icon.innerHTML = \(CoreWebView.jsString(iconSVG)); const detailButton = document.createElement('a'); detailButton.className = "open_details_button"; - detailButton.href = frameLink + linkSuffix; + detailButton.href = escapeHTML(frameLink + linkSuffix); detailButton.target = "_blank"; - detailButton.textContent = '%@'; + detailButton.textContent = escapeHTML(\(CoreWebView.jsString(title))); buttonContainer.appendChild(icon); buttonContainer.appendChild(detailButton); @@ -112,8 +120,6 @@ class InsertStudioOpenInDetailButtons: CoreWebViewFeature { insertStudioDetailsLinks(); window.addEventListener("DOMContentLoaded", insertStudioDetailsLinks); """ - - return String(format: script, iconSVG, title) }() public override init() {} From d2c3ae76c4eee22c7635d7698c9dd03f36a8ff2c Mon Sep 17 00:00:00 2001 From: Suhaib Al-Absi Date: Tue, 18 Nov 2025 16:17:36 +0300 Subject: [PATCH 21/45] Address AI feedback --- .../CoreWebViewStudioFeaturesInteractor.swift | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractor.swift b/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractor.swift index 9e5a7027a8..56e8863a63 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractor.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractor.swift @@ -43,7 +43,7 @@ public class CoreWebViewStudioFeaturesInteractor { scanVideoFramesForTitles(); """ - unowned let webView: CoreWebView + private(set) weak var webView: CoreWebView? private var studioImprovementsFlagStore: ReactiveStore? private var storeSubscription: AnyCancellable? @@ -83,11 +83,11 @@ public class CoreWebViewStudioFeaturesInteractor { if url.containsQueryItem(named: "title") == false, let title = videoPlayerFrameTitle(matching: url) { - url.append(queryItems: [.init(name: "title", value: title)]) + url = url.appendingQueryItems(.init(name: "title", value: title)) } if url.containsQueryItem(named: "embedded") == false { - url.append(queryItems: [.init(name: "embedded", value: "true")]) + url = url.appendingQueryItems(.init(name: "embedded", value: "true")) } return url @@ -99,9 +99,17 @@ public class CoreWebViewStudioFeaturesInteractor { /// 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, _ in + 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] = [:] @@ -143,6 +151,8 @@ public class CoreWebViewStudioFeaturesInteractor { } private func updateStudioImprovementFeature(isEnabled: Bool) { + guard let webView else { return } + if isEnabled { webView.addFeature(.insertStudioOpenInDetailButtons) } else { From 242ad15b801803b2bc958a5961dd2e6aab1e1930 Mon Sep 17 00:00:00 2001 From: Suhaib Al-Absi Date: Sun, 23 Nov 2025 16:54:22 +0300 Subject: [PATCH 22/45] Working path for inserting links to studio embeds --- .../InsertStudioOpenDetailViewButton.swift | 143 ------------------ .../InsertStudioOpenInDetailButtons.css | 18 +++ .../InsertStudioOpenInDetailButtons.js | 121 +++++++++++++++ .../InsertStudioOpenInDetailButtons.swift | 92 +++++++++++ 4 files changed, 231 insertions(+), 143 deletions(-) delete mode 100644 Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift create mode 100644 Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.css create mode 100644 Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.js create mode 100644 Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.swift diff --git a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift deleted file mode 100644 index 9f8f2cfc26..0000000000 --- a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift +++ /dev/null @@ -1,143 +0,0 @@ -// -// 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 css = """ - p[ios-injected] { - text-align: center; - } - - .open_details_button { - font-weight: 400; - font-size: 15px; - text-decoration: none; - color: #2B7ABC; - } - - .open_details_button_icon { - display: inline-block; - height: 100%; - vertical-align: middle; - padding-right: 6px; - padding-left: 6px; - } - """ - - 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 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"); - var title = videoTitle ?? ariaTitle; - - var frameLink = elm.getAttribute("src"); - frameLink = frameLink.replace("media_attachments_iframe", "media_attachments"); - - var linkSuffix = "/immersive_view"; - if(title){ - title = title.replace("Video player for ", "").replace(".mp4", ""); - linkSuffix = "/immersive_view?title=" + encodeURIComponent(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 = escapeHTML(frameLink + linkSuffix); - 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/Model/Features/InsertStudioOpenInDetailButtons.css b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.css new file mode 100644 index 0000000000..b067e4fff9 --- /dev/null +++ b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.css @@ -0,0 +1,18 @@ +p[ios-injected] { + text-align: center; +} + +.open_details_button { + font-weight: 400; + font-size: 15px; + text-decoration: none; + color: #2B7ABC; +} + +.open_details_button_icon { + display: inline-block; + height: 100%; + vertical-align: middle; + padding-right: 6px; + padding-left: 6px; +} 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..d0455ff7d3 --- /dev/null +++ b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.js @@ -0,0 +1,121 @@ +// +// 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 } + + var frameLink = elm.getAttribute("src"); + frameLink = frameLink.replace("media_attachments_iframe", "media_attachments"); + + var linkSuffix = "/immersive_view"; + + if(title){ + title = title.replace("Video player for ", "").replace(".mp4", ""); + linkSuffix = "/immersive_view?title=" + encodeURIComponent(title); + } + + return escapeHTML(frameLink + linkSuffix) +} + +function findStudioEmbedLink(elm, title) { + let pageSrc = elm.getAttribute("src"); + let pageURL = new URL(pageSrc); + let playerURL = new URL(pageURL.searchParams.get("url")); + playerURL.searchParams.set("custom_arc_launch_type", "immersive_view"); + + pageURL.searchParams.set("url", playerURL.toString()); + pageURL.searchParams.set("display", "full_width"); + + if(title){ + title = title.replace("Video player for ", "").replace(".mp4", ""); + pageURL.searchParams.set("title", encodeURIComponent(title)); + } + + return pageURL.toString(); +} + +function insertDetailsLinks(elm, method) { + const linkSpecs = window.detailLinkSpecs; + + 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"); + var title = videoTitle ?? ariaTitle; + + var buttonHref; + if(method == "studio") { + buttonHref = findStudioEmbedLink(elm, title); + } else { + buttonHref = 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 = linkSpecs.iconSVG; + + const detailButton = document.createElement('a'); + detailButton.className = "open_details_button"; + detailButton.href = buttonHref; + detailButton.target = "_blank"; + detailButton.textContent = escapeHTML(linkSpecs.title); + + buttonContainer.appendChild(icon); + 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..fcf5d1d821 --- /dev/null +++ b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.swift @@ -0,0 +1,92 @@ +// +// 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 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) { + webView.addScript(insertValues) + + if let insertStyle { + webView.addScript(insertStyle) + } + if let insertScript { + webView.addScript(insertScript) + } + } + + override func remove(from webView: CoreWebView) { + webView.removeScript(insertValues) + + if let insertStyle { + webView.removeScript(insertStyle) + } + if let insertScript { + webView.removeScript(insertScript) + } + } +} + +public extension CoreWebViewFeature { + + static var insertStudioOpenInDetailButtons: CoreWebViewFeature { + InsertStudioOpenInDetailButtons() + } +} From ec1f1a25c5ad8ebbfb6d1a6d270cecd6c2701c0f Mon Sep 17 00:00:00 2001 From: Suhaib Al-Absi Date: Mon, 24 Nov 2025 00:49:07 +0300 Subject: [PATCH 23/45] Studio embed detail link check refs: MBL-19551 affects: Student, Teacher, Parent builds: Student, Teacher, Parent release note: Introduced immersive experience for video players --- .../CoreWebViewStudioFeaturesInteractor.swift | 14 ++++++++--- .../CoreWebView/View/CoreWebView.swift | 24 +++++++++---------- .../LTI/View/StudioViewController.swift | 3 ++- 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractor.swift b/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractor.swift index 56e8863a63..1293a3c6b3 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractor.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractor.swift @@ -97,7 +97,8 @@ public class CoreWebViewStudioFeaturesInteractor { /// 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) + /// from a button that's internal to video-frame. (`Expand` button). + /// Only for Canvas uploads video players. func scanVideoFrames() { guard let webView else { return } @@ -180,12 +181,19 @@ extension WKNavigationAction { && path.hasSuffix("/immersive_view") == true && sourceFrame.isMainFrame == false - let isDetailsLink = + let isCanvasUploadDetailsLink = navigationType == .linkActivated && path.contains("/media_attachments/") == true && path.hasSuffix("/immersive_view") == true && (targetFrame?.isMainFrame ?? false) == false - return isExpandLink || isDetailsLink + 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/View/CoreWebView.swift b/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift index d7ac91271f..da45a50dfa 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift @@ -520,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). @@ -556,18 +568,6 @@ 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 { diff --git a/Core/Core/Features/LTI/View/StudioViewController.swift b/Core/Core/Features/LTI/View/StudioViewController.swift index 938813c5ec..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 = url.queryValue(for: "title") ?? String(localized: "Studio", bundle: .core) + controller.title = url.queryValue(for: "title")?.removingPercentEncoding + ?? String(localized: "Studio", bundle: .core) super.init(rootViewController: controller) From cca614d43d5460edc8b2fcd565da704e0bc4c842 Mon Sep 17 00:00:00 2001 From: Suhaib Al-Absi Date: Mon, 24 Nov 2025 14:38:30 +0300 Subject: [PATCH 24/45] Address AI feedback --- Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift b/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift index d7ac91271f..d3c23b20fa 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/View/CoreWebView.swift @@ -132,7 +132,7 @@ open class CoreWebView: WKWebView { if indexSet.isNotEmpty { filteredList.forEach({ $0.element.remove(from: self) }) features.remove(atOffsets: indexSet) - _ = reload() + _ = super.reload() return true } return false From f0aedb9ff8f8ffbc4253275dfc9ed04edca18a88 Mon Sep 17 00:00:00 2001 From: Suhaib Al-Absi Date: Mon, 24 Nov 2025 15:22:43 +0300 Subject: [PATCH 25/45] More safe javascript code --- .../InsertStudioOpenInDetailButtons.js | 40 ++++++++++++------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.js b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.js index d0455ff7d3..ad1b7f5da1 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.js +++ b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.js @@ -19,38 +19,46 @@ function findCanvasUploadLink(elm, title) { if(elm.hasAttribute("data-media-id") == false) { return null } - var frameLink = elm.getAttribute("src"); - frameLink = frameLink.replace("media_attachments_iframe", "media_attachments"); + var frameSource = elm.getAttribute("src"); + if(!frameSource) { return null } - var linkSuffix = "/immersive_view"; + frameSource = frameSource.replace("media_attachments_iframe", "media_attachments"); - if(title){ + var suffix = "/immersive_view"; + + if(title) { title = title.replace("Video player for ", "").replace(".mp4", ""); - linkSuffix = "/immersive_view?title=" + encodeURIComponent(title); + suffix = "/immersive_view?title=" + encodeURIComponent(title); } - return escapeHTML(frameLink + linkSuffix) + return new URL(frameSource + suffix); } function findStudioEmbedLink(elm, title) { - let pageSrc = elm.getAttribute("src"); - let pageURL = new URL(pageSrc); - let playerURL = new URL(pageURL.searchParams.get("url")); + let frameSource = elm.getAttribute("src"); + if(!frameSource) { return null } + + let frameURL = new URL(frameSource); + let playerSource = frameURL.searchParams.get("url"); + if(!playerSource) { return null } + + let playerURL = new URL(playerSource); playerURL.searchParams.set("custom_arc_launch_type", "immersive_view"); - pageURL.searchParams.set("url", playerURL.toString()); - pageURL.searchParams.set("display", "full_width"); + frameURL.searchParams.set("url", playerURL.toString()); + frameURL.searchParams.set("display", "full_width"); - if(title){ + if(title) { title = title.replace("Video player for ", "").replace(".mp4", ""); - pageURL.searchParams.set("title", encodeURIComponent(title)); + frameURL.searchParams.set("title", encodeURIComponent(title)); } - return pageURL.toString(); + return frameURL; } function insertDetailsLinks(elm, method) { - const linkSpecs = window.detailLinkSpecs; + var linkSpecs = window.detailLinkSpecs; + linkSpecs = (linkSpecs) ? linkSpecs : { iconSVG: '', title: '' }; let nextSibling = elm.nextElementSibling; let nextNextSibling = (nextSibling) ? nextSibling.nextElementSibling : null; @@ -69,6 +77,8 @@ function insertDetailsLinks(elm, method) { buttonHref = findCanvasUploadLink(elm, title); } + if(!buttonHref) { return } + const newLine = document.createElement('br'); const newParagraph = document.createElement('p'); newParagraph.setAttribute("ios-injected", 1); From 062560926f364aae82261546fa054b1986435c24 Mon Sep 17 00:00:00 2001 From: Suhaib Al-Absi Date: Mon, 24 Nov 2025 15:47:09 +0300 Subject: [PATCH 26/45] Update unit tests, enhanced javascript --- .../InsertStudioOpenInDetailButtons.js | 42 +++++++++++-------- ...WebViewStudioFeaturesInteractorTests.swift | 14 +++++++ 2 files changed, 38 insertions(+), 18 deletions(-) diff --git a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.js b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.js index ad1b7f5da1..baf2e42ea8 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.js +++ b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.js @@ -19,19 +19,21 @@ function findCanvasUploadLink(elm, title) { if(elm.hasAttribute("data-media-id") == false) { return null } - var frameSource = elm.getAttribute("src"); + let frameSource = elm.getAttribute("src"); if(!frameSource) { return null } - frameSource = frameSource.replace("media_attachments_iframe", "media_attachments"); + let frameFullPath = frameSource + .replace("media_attachments_iframe", "media_attachments") + .concat("/immersive_view"); - var suffix = "/immersive_view"; + let frameURL = new URL(frameFullPath); if(title) { title = title.replace("Video player for ", "").replace(".mp4", ""); - suffix = "/immersive_view?title=" + encodeURIComponent(title); + frameURL.searchParams.set("title", encodeURIComponent(title)); } - return new URL(frameSource + suffix); + return frameURL; } function findStudioEmbedLink(elm, title) { @@ -58,7 +60,7 @@ function findStudioEmbedLink(elm, title) { function insertDetailsLinks(elm, method) { var linkSpecs = window.detailLinkSpecs; - linkSpecs = (linkSpecs) ? linkSpecs : { iconSVG: '', title: '' }; + linkSpecs = (linkSpecs) ? linkSpecs : { iconSVG: null, title: null }; let nextSibling = elm.nextElementSibling; let nextNextSibling = (nextSibling) ? nextSibling.nextElementSibling : null; @@ -68,7 +70,7 @@ function insertDetailsLinks(elm, method) { const videoTitle = elm.getAttribute("title"); const ariaTitle = elm.getAttribute("aria-title"); - var title = videoTitle ?? ariaTitle; + const title = videoTitle ?? ariaTitle; var buttonHref; if(method == "studio") { @@ -86,19 +88,23 @@ function insertDetailsLinks(elm, method) { const buttonContainer = document.createElement('div'); buttonContainer.className = "open_detail_button_container"; - const icon = document.createElement('div'); - icon.className = "open_details_button_icon"; - icon.innerHTML = linkSpecs.iconSVG; + if(linkSpecs.iconSVG) { + const icon = document.createElement('div'); + icon.className = "open_details_button_icon"; + icon.innerHTML = linkSpecs.iconSVG; + buttonContainer.appendChild(icon); + } - const detailButton = document.createElement('a'); - detailButton.className = "open_details_button"; - detailButton.href = buttonHref; - detailButton.target = "_blank"; - detailButton.textContent = escapeHTML(linkSpecs.title); + 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(icon); - buttonContainer.appendChild(detailButton); - newParagraph.appendChild(buttonContainer); + buttonContainer.appendChild(detailButton); + newParagraph.appendChild(buttonContainer); + } elm.insertAdjacentElement('afterend', newLine); newLine.insertAdjacentElement('afterend', newParagraph); diff --git a/Core/CoreTests/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractorTests.swift b/Core/CoreTests/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractorTests.swift index 2b03dc296a..d92e8cd7c4 100644 --- a/Core/CoreTests/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractorTests.swift +++ b/Core/CoreTests/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractorTests.swift @@ -187,4 +187,18 @@ class CoreWebViewStudioFeaturesInteractorTests: CoreTestCase { // 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%26custom_arc_lock_speed%3Dtrue%26custom_arc_media_id%3D7dc9ffdc-c30a-44e6-869e-a940ea66331e-1%26custom_arc_start_at%3D0%26custom_arc_transcript_downloadable%3Dtrue%26com_instructure_course_canvas_resource_type%3Dwiki_page.body%26com_instructure_course_canvas_resource_id%3D6408%26custom_arc_source_view_type%3Dcollaboration_embed%26platform_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) + } } From 8a85f8015448f740a70b5a416fd3ea1968e80618 Mon Sep 17 00:00:00 2001 From: Suhaib Al-Absi Date: Mon, 24 Nov 2025 17:18:21 +0300 Subject: [PATCH 27/45] Address reviews --- .../InsertStudioOpenInDetailButtons.js | 42 ++++++++++++------- .../InsertStudioOpenInDetailButtons.swift | 20 ++++++--- ...WebViewStudioFeaturesInteractorTests.swift | 6 ++- 3 files changed, 46 insertions(+), 22 deletions(-) diff --git a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.js b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.js index baf2e42ea8..bf54532ded 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.js +++ b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.js @@ -26,14 +26,20 @@ function findCanvasUploadLink(elm, title) { .replace("media_attachments_iframe", "media_attachments") .concat("/immersive_view"); - let frameURL = new URL(frameFullPath); + try { - if(title) { - title = title.replace("Video player for ", "").replace(".mp4", ""); - frameURL.searchParams.set("title", encodeURIComponent(title)); - } + let frameURL = new URL(frameFullPath); + + if(title) { + title = title.replace("Video player for ", "").replace(".mp4", ""); + frameURL.searchParams.set("title", encodeURIComponent(title)); + } - return frameURL; + return frameURL; + + } catch { + return null; + } } function findStudioEmbedLink(elm, title) { @@ -44,18 +50,24 @@ function findStudioEmbedLink(elm, title) { let playerSource = frameURL.searchParams.get("url"); if(!playerSource) { return null } - let playerURL = new URL(playerSource); - playerURL.searchParams.set("custom_arc_launch_type", "immersive_view"); + try { - frameURL.searchParams.set("url", playerURL.toString()); - frameURL.searchParams.set("display", "full_width"); + let playerURL = new URL(playerSource); + playerURL.searchParams.set("custom_arc_launch_type", "immersive_view"); - if(title) { - title = title.replace("Video player for ", "").replace(".mp4", ""); - frameURL.searchParams.set("title", encodeURIComponent(title)); - } + 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; + return frameURL; + + } catch { + return null; + } } function insertDetailsLinks(elm, method) { diff --git a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.swift b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.swift index fcf5d1d821..62328e9de9 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.swift @@ -19,6 +19,7 @@ import UIKit class InsertStudioOpenInDetailButtons: CoreWebViewFeature { + private var isAdded: Bool = false private let insertValues: String = { let title = String(localized: "Open in Detail View", bundle: .core) @@ -39,7 +40,6 @@ class InsertStudioOpenInDetailButtons: CoreWebViewFeature { let jsSource = try? String(contentsOf: url, encoding: .utf8) { return jsSource } - return nil }() @@ -55,20 +55,25 @@ class InsertStudioOpenInDetailButtons: CoreWebViewFeature { })() """ } - return nil }() public override init() { } override func apply(on webView: CoreWebView) { - webView.addScript(insertValues) + guard + let insertScript, + let insertStyle + else { + RemoteLogger.shared.logError(name: "WebView: Missing immersive player links script or stylesheet") + return + } - if let insertStyle { + if isAdded == false { + webView.addScript(insertValues) webView.addScript(insertStyle) - } - if let insertScript { webView.addScript(insertScript) + isAdded = true } } @@ -78,9 +83,12 @@ class InsertStudioOpenInDetailButtons: CoreWebViewFeature { if let insertStyle { webView.removeScript(insertStyle) } + if let insertScript { webView.removeScript(insertScript) } + + isAdded = false } } diff --git a/Core/CoreTests/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractorTests.swift b/Core/CoreTests/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractorTests.swift index d92e8cd7c4..2344381523 100644 --- a/Core/CoreTests/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractorTests.swift +++ b/Core/CoreTests/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractorTests.swift @@ -194,7 +194,11 @@ class CoreWebViewStudioFeaturesInteractorTests: CoreTestCase { 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%26custom_arc_lock_speed%3Dtrue%26custom_arc_media_id%3D7dc9ffdc-c30a-44e6-869e-a940ea66331e-1%26custom_arc_start_at%3D0%26custom_arc_transcript_downloadable%3Dtrue%26com_instructure_course_canvas_resource_type%3Dwiki_page.body%26com_instructure_course_canvas_resource_id%3D6408%26custom_arc_source_view_type%3Dcollaboration_embed%26platform_redirect_url%3Dhttps%253A%252F%252Fsuhaibalabsi.instructure.com%252Fcourses%252F991%252Fpages%252Ftest-both-embed-types%26full_win_launch_requested%3D1" + 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) From c9bd133f509b61f41da8f2f9f8df675a11552385 Mon Sep 17 00:00:00 2001 From: Suhaib Al-Absi Date: Tue, 25 Nov 2025 11:59:52 +0300 Subject: [PATCH 28/45] Fixed an issue with Offline syncing --- .../Studio/Model/StudioIFrameReplaceInteractor.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 { From 9dfb196f5b3b6b5dd91403f09698e72bdbd93a86 Mon Sep 17 00:00:00 2001 From: Suhaib Al-Absi Date: Tue, 25 Nov 2025 12:37:45 +0300 Subject: [PATCH 29/45] Update unit tests --- .../CoreWebViewStudioFeaturesInteractor.swift | 3 +++ ...eWebViewStudioFeaturesInteractorTests.swift | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractor.swift b/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractor.swift index 56e8863a63..f2622c9deb 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractor.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractor.swift @@ -52,6 +52,7 @@ public class CoreWebViewStudioFeaturesInteractor { private(set) var videoFramesTitleMap: [String: String] = [:] var onScanFinished: (() -> Void)? + var onFeatureUpdate: (() -> Void)? init(webView: CoreWebView) { self.webView = webView @@ -158,6 +159,8 @@ public class CoreWebViewStudioFeaturesInteractor { } else { webView.removeFeatures(ofType: InsertStudioOpenInDetailButtons.self) } + + onFeatureUpdate?() } private func videoPlayerFrameTitle(matching url: URL) -> String? { diff --git a/Core/CoreTests/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractorTests.swift b/Core/CoreTests/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractorTests.swift index 2b03dc296a..6bbbf6fad4 100644 --- a/Core/CoreTests/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractorTests.swift +++ b/Core/CoreTests/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractorTests.swift @@ -78,7 +78,13 @@ class CoreWebViewStudioFeaturesInteractorTests: CoreTestCase { ) // 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 }) ) @@ -103,7 +109,13 @@ class CoreWebViewStudioFeaturesInteractorTests: CoreTestCase { ) // 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 }) ) @@ -128,7 +140,13 @@ class CoreWebViewStudioFeaturesInteractorTests: CoreTestCase { ) // 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 }) ) From ba73d630719f5182e1940ebbc57cd4ca43e56d61 Mon Sep 17 00:00:00 2001 From: Suhaib Al-Absi Date: Wed, 26 Nov 2025 10:05:18 +0300 Subject: [PATCH 30/45] Address code review comments --- .../CoreWebViewStudioFeaturesInteractor.swift | 7 ++++--- .../InsertStudioOpenDetailViewButton.swift | 20 ++++++++++++++++--- .../CommonUI/SwiftUIViews/WebView.swift | 2 +- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractor.swift b/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractor.swift index f2622c9deb..da72d65145 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractor.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractor.swift @@ -20,6 +20,7 @@ import Combine import WebKit public class CoreWebViewStudioFeaturesInteractor { + private static let scanFramesScript = """ function scanVideoFramesForTitles() { const frameElements = document.querySelectorAll('iframe[data-media-id]'); @@ -43,6 +44,9 @@ public class CoreWebViewStudioFeaturesInteractor { scanVideoFramesForTitles(); """ + var onScanFinished: (() -> Void)? + var onFeatureUpdate: (() -> Void)? + private(set) weak var webView: CoreWebView? private var studioImprovementsFlagStore: ReactiveStore? private var storeSubscription: AnyCancellable? @@ -51,9 +55,6 @@ public class CoreWebViewStudioFeaturesInteractor { /// of CoreWebView. Supposed to be updated (or emptied) on each page load. private(set) var videoFramesTitleMap: [String: String] = [:] - var onScanFinished: (() -> Void)? - var onFeatureUpdate: (() -> Void)? - init(webView: CoreWebView) { self.webView = webView } diff --git a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift index 9f8f2cfc26..78c07f96f6 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift @@ -21,6 +21,7 @@ import UIKit class InsertStudioOpenInDetailButtons: CoreWebViewFeature { private let insertStyle: String = { + let fontSize = UIFont.scaledNamedFont(.regular14) let css = """ p[ios-injected] { text-align: center; @@ -28,17 +29,30 @@ class InsertStudioOpenInDetailButtons: CoreWebViewFeature { .open_details_button { font-weight: 400; - font-size: 15px; + 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: 6px; - padding-left: 6px; + 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/SwiftUIViews/WebView.swift b/Core/Core/Common/CommonUI/SwiftUIViews/WebView.swift index 6420849c9b..76266e1ede 100644 --- a/Core/Core/Common/CommonUI/SwiftUIViews/WebView.swift +++ b/Core/Core/Common/CommonUI/SwiftUIViews/WebView.swift @@ -214,7 +214,7 @@ extension WebView { ) { reloadObserver?.cancel() reloadObserver = trigger?.sink { - webView.reload() + _ = webView.reload() } } From a84594c9afd2b8ea7d3e9f9a3a25c5730ed931ef Mon Sep 17 00:00:00 2001 From: Suhaib Al-Absi Date: Wed, 26 Nov 2025 10:34:15 +0300 Subject: [PATCH 31/45] Fix issue with link forming --- .../InsertStudioOpenDetailViewButton.swift | 38 ++++++++++++++----- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift index 78c07f96f6..74e61a41be 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift @@ -82,6 +82,31 @@ class InsertStudioOpenInDetailButtons: CoreWebViewFeature { .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]'); @@ -94,16 +119,9 @@ class InsertStudioOpenInDetailButtons: CoreWebViewFeature { const videoTitle = elm.getAttribute("title"); const ariaTitle = elm.getAttribute("aria-title"); - var title = videoTitle ?? ariaTitle; - var frameLink = elm.getAttribute("src"); - frameLink = frameLink.replace("media_attachments_iframe", "media_attachments"); - - var linkSuffix = "/immersive_view"; - if(title){ - title = title.replace("Video player for ", "").replace(".mp4", ""); - linkSuffix = "/immersive_view?title=" + encodeURIComponent(title); - } + let title = videoTitle ?? ariaTitle; + let frameLink = findCanvasUploadLink(elm, title); const newLine = document.createElement('br'); const newParagraph = document.createElement('p'); @@ -118,7 +136,7 @@ class InsertStudioOpenInDetailButtons: CoreWebViewFeature { const detailButton = document.createElement('a'); detailButton.className = "open_details_button"; - detailButton.href = escapeHTML(frameLink + linkSuffix); + detailButton.href = frameLink; detailButton.target = "_blank"; detailButton.textContent = escapeHTML(\(CoreWebView.jsString(title))); From 67d118f9c4fe71e45a98547d37346fd1a9933ab3 Mon Sep 17 00:00:00 2001 From: Suhaib Al-Absi Date: Wed, 26 Nov 2025 10:49:57 +0300 Subject: [PATCH 32/45] Fix merge issues --- .../CoreWebViewStudioFeaturesInteractor.swift | 1 - .../InsertStudioOpenInDetailButtons.css | 19 ++++++++++++++++--- .../InsertStudioOpenInDetailButtons.js | 9 ++++----- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractor.swift b/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractor.swift index 577877b85b..037f03acd3 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractor.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractor.swift @@ -20,7 +20,6 @@ import Combine import WebKit public class CoreWebViewStudioFeaturesInteractor { - private static let scanFramesScript = """ function scanVideoFramesForTitles() { const frameElements = document.querySelectorAll('iframe[data-media-id]'); diff --git a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.css b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.css index b067e4fff9..a5ee33209c 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.css +++ b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.css @@ -4,15 +4,28 @@ p[ios-injected] { .open_details_button { font-weight: 400; - font-size: 15px; + 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: 6px; - padding-left: 6px; + 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 index bf54532ded..60962d33e2 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.js +++ b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.js @@ -17,18 +17,18 @@ // function findCanvasUploadLink(elm, title) { - if(elm.hasAttribute("data-media-id") == false) { return null } + if (elm.hasAttribute("data-media-id") == false) { return null } let frameSource = elm.getAttribute("src"); - if(!frameSource) { return null } + if (!frameSource) { return null } let frameFullPath = frameSource - .replace("media_attachments_iframe", "media_attachments") - .concat("/immersive_view"); + .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", ""); @@ -36,7 +36,6 @@ function findCanvasUploadLink(elm, title) { } return frameURL; - } catch { return null; } From c6d99a2d1e14a826c8c440b1f594f82f52bc6034 Mon Sep 17 00:00:00 2001 From: Suhaib Al-Absi Date: Wed, 26 Nov 2025 12:43:33 +0300 Subject: [PATCH 33/45] Update CoreWebViewTests.swift --- .../Common/CommonUI/CoreWebView/View/CoreWebViewTests.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Core/CoreTests/Common/CommonUI/CoreWebView/View/CoreWebViewTests.swift b/Core/CoreTests/Common/CommonUI/CoreWebView/View/CoreWebViewTests.swift index 26ec87e387..244e272f10 100644 --- a/Core/CoreTests/Common/CommonUI/CoreWebView/View/CoreWebViewTests.swift +++ b/Core/CoreTests/Common/CommonUI/CoreWebView/View/CoreWebViewTests.swift @@ -403,18 +403,18 @@ class MockNavigationAction: WKNavigationAction { return mockType } - let mockSourceFrame: MockFrameInfo? + let mockSourceFrame: MockFrameInfo let mockTargetFrame: MockFrameInfo? init(url: String, type: WKNavigationType, sourceFrame: MockFrameInfo? = nil, targetFrame: MockFrameInfo? = nil) { mockRequest = URLRequest(url: URL(string: url)!) mockType = type - mockSourceFrame = sourceFrame + mockSourceFrame = sourceFrame ?? MockFrameInfo(isMainFrame: true) mockTargetFrame = targetFrame super.init() } override var sourceFrame: WKFrameInfo { - mockSourceFrame ?? super.sourceFrame + mockSourceFrame } override var targetFrame: WKFrameInfo? { From bb35c0f7d5c3aa854acc5713bc2dc39b45f1f07c Mon Sep 17 00:00:00 2001 From: Suhaib Al-Absi Date: Thu, 27 Nov 2025 00:38:36 +0300 Subject: [PATCH 34/45] Inject script to fix LTI iframes resizing --- .../Features/CanvasLTIPostMessageHandler.js | 299 ++++++++++++++++++ .../CanvasLTIPostMessageHandler.swift | 47 +++ .../PageDetailsViewController.swift | 2 +- .../StudentQuizDetailsViewController.swift | 2 +- ...udentAssignmentDetailsViewController.swift | 2 +- 5 files changed, 349 insertions(+), 3 deletions(-) create mode 100644 Core/Core/Common/CommonUI/CoreWebView/Model/Features/CanvasLTIPostMessageHandler.js create mode 100644 Core/Core/Common/CommonUI/CoreWebView/Model/Features/CanvasLTIPostMessageHandler.swift 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..670256ae06 --- /dev/null +++ b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/CanvasLTIPostMessageHandler.js @@ -0,0 +1,299 @@ +/** + * 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 + * + * Copyright (C) 2024 - present Instructure, Inc. + */ + +(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/Features/Pages/PageDetails/PageDetailsViewController.swift b/Core/Core/Features/Pages/PageDetails/PageDetailsViewController.swift index f2e6c24f56..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() diff --git a/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizDetailsViewController.swift b/Core/Core/Features/Quizzes/QuizDetails/Student/StudentQuizDetailsViewController.swift index b3b3114349..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! diff --git a/Student/Student/Assignments/AssignmentDetails/StudentAssignmentDetailsViewController.swift b/Student/Student/Assignments/AssignmentDetails/StudentAssignmentDetailsViewController.swift index 45aacf6db4..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? From 8f72e57386d4ce069a32840c88b76b976c4d70a5 Mon Sep 17 00:00:00 2001 From: Suhaib Al-Absi Date: Thu, 27 Nov 2025 00:57:50 +0300 Subject: [PATCH 35/45] Update InsertStudioOpenDetailViewButtonTests.swift --- .../Model/Features/InsertStudioOpenDetailViewButtonTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Core/CoreTests/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButtonTests.swift b/Core/CoreTests/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButtonTests.swift index 7e73be0fe1..7f0879b8ab 100644 --- a/Core/CoreTests/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButtonTests.swift +++ b/Core/CoreTests/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButtonTests.swift @@ -84,7 +84,7 @@ class InsertStudioOpenDetailViewButtonTests: XCTestCase { "https://suhaibalabsi.instructure.com/media_attachments/546734/immersive_view" ) - XCTAssertEqual(urls[0].queryValue(for: "title"), "Example Video Title") + XCTAssertEqual(urls[0].queryValue(for: "title")?.removingPercentEncoding, "Example Video Title") XCTAssertEqual(urls[1].queryValue(for: "title"), "Some_File_Name") } From 15a0c4982de8cdd74d31f865d4cd611557cf5156 Mon Sep 17 00:00:00 2001 From: Suhaib Al-Absi Date: Thu, 27 Nov 2025 01:02:20 +0300 Subject: [PATCH 36/45] Update InsertStudioOpenDetailViewButtonTests.swift --- .../Model/Features/InsertStudioOpenDetailViewButtonTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Core/CoreTests/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButtonTests.swift b/Core/CoreTests/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButtonTests.swift index 7e73be0fe1..d160281a8e 100644 --- a/Core/CoreTests/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButtonTests.swift +++ b/Core/CoreTests/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButtonTests.swift @@ -84,7 +84,7 @@ class InsertStudioOpenDetailViewButtonTests: XCTestCase { "https://suhaibalabsi.instructure.com/media_attachments/546734/immersive_view" ) - XCTAssertEqual(urls[0].queryValue(for: "title"), "Example Video Title") + XCTAssertEqual(urls[0].queryValue(for: "title"), "Example%20Video%20Title") XCTAssertEqual(urls[1].queryValue(for: "title"), "Some_File_Name") } From d16b2cbb166ba589f773d3114bf2ed1c274dc0de Mon Sep 17 00:00:00 2001 From: Suhaib Al-Absi Date: Thu, 27 Nov 2025 01:08:25 +0300 Subject: [PATCH 37/45] Update CoreWebViewTests.swift --- .../CommonUI/CoreWebView/View/CoreWebViewTests.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Core/CoreTests/Common/CommonUI/CoreWebView/View/CoreWebViewTests.swift b/Core/CoreTests/Common/CommonUI/CoreWebView/View/CoreWebViewTests.swift index 244e272f10..073cb62d66 100644 --- a/Core/CoreTests/Common/CommonUI/CoreWebView/View/CoreWebViewTests.swift +++ b/Core/CoreTests/Common/CommonUI/CoreWebView/View/CoreWebViewTests.swift @@ -405,10 +405,16 @@ class MockNavigationAction: WKNavigationAction { let mockSourceFrame: MockFrameInfo let mockTargetFrame: MockFrameInfo? - init(url: String, type: WKNavigationType, sourceFrame: MockFrameInfo? = nil, targetFrame: MockFrameInfo? = nil) { + + init( + url: String, + type: WKNavigationType, + sourceFrame: MockFrameInfo = MockFrameInfo(isMainFrame: true), + targetFrame: MockFrameInfo? = nil + ) { mockRequest = URLRequest(url: URL(string: url)!) mockType = type - mockSourceFrame = sourceFrame ?? MockFrameInfo(isMainFrame: true) + mockSourceFrame = sourceFrame mockTargetFrame = targetFrame super.init() } From e18ad064bd5ab9d97f8464c7c6146ab2d71e35b7 Mon Sep 17 00:00:00 2001 From: Suhaib Al-Absi Date: Thu, 27 Nov 2025 01:09:30 +0300 Subject: [PATCH 38/45] Update CoreWebViewTests.swift --- .../CommonUI/CoreWebView/View/CoreWebViewTests.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Core/CoreTests/Common/CommonUI/CoreWebView/View/CoreWebViewTests.swift b/Core/CoreTests/Common/CommonUI/CoreWebView/View/CoreWebViewTests.swift index 244e272f10..073cb62d66 100644 --- a/Core/CoreTests/Common/CommonUI/CoreWebView/View/CoreWebViewTests.swift +++ b/Core/CoreTests/Common/CommonUI/CoreWebView/View/CoreWebViewTests.swift @@ -405,10 +405,16 @@ class MockNavigationAction: WKNavigationAction { let mockSourceFrame: MockFrameInfo let mockTargetFrame: MockFrameInfo? - init(url: String, type: WKNavigationType, sourceFrame: MockFrameInfo? = nil, targetFrame: MockFrameInfo? = nil) { + + init( + url: String, + type: WKNavigationType, + sourceFrame: MockFrameInfo = MockFrameInfo(isMainFrame: true), + targetFrame: MockFrameInfo? = nil + ) { mockRequest = URLRequest(url: URL(string: url)!) mockType = type - mockSourceFrame = sourceFrame ?? MockFrameInfo(isMainFrame: true) + mockSourceFrame = sourceFrame mockTargetFrame = targetFrame super.init() } From 354e9c4692e7b34dc09e915dbbcd548b2bf872f8 Mon Sep 17 00:00:00 2001 From: Suhaib Al-Absi Date: Thu, 27 Nov 2025 13:52:19 +0300 Subject: [PATCH 39/45] Update CanvasLTIPostMessageHandler.js --- .../Features/CanvasLTIPostMessageHandler.js | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/CanvasLTIPostMessageHandler.js b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/CanvasLTIPostMessageHandler.js index 670256ae06..b38a000f86 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/CanvasLTIPostMessageHandler.js +++ b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/CanvasLTIPostMessageHandler.js @@ -1,3 +1,21 @@ +// +// 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 * @@ -15,7 +33,6 @@ * - lti.frameResize: Resizes iframe containing LTI tool * - lti.fetchWindowSize: Returns window dimensions and scroll position * - * Copyright (C) 2024 - present Instructure, Inc. */ (function() { From 6e449890b216877ded34cf7ddff8e1b7abd2d25b Mon Sep 17 00:00:00 2001 From: Suhaib Al-Absi Date: Thu, 27 Nov 2025 16:57:41 +0300 Subject: [PATCH 40/45] Exclude quiz embeds --- .../Model/Features/InsertStudioOpenInDetailButtons.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.js b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.js index 60962d33e2..2b02e76585 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.js +++ b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.js @@ -52,6 +52,10 @@ function findStudioEmbedLink(elm, title) { 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()); From cf6feddc4a925622e8042ac391e8d206bdf8e779 Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Thu, 27 Nov 2025 18:20:11 +0100 Subject: [PATCH 41/45] Fix font size. --- .../InsertStudioOpenDetailViewButton.swift | 2 +- ...nsertStudioOpenDetailViewButtonTests.swift | 41 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift index 74e61a41be..c4b4cbe9bd 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift @@ -29,7 +29,7 @@ class InsertStudioOpenInDetailButtons: CoreWebViewFeature { .open_details_button { font-weight: 400; - font-size: \(fontSize)px; + font-size: \(fontSize.pointSize)px; text-decoration: none; color: #2B7ABC; } diff --git a/Core/CoreTests/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButtonTests.swift b/Core/CoreTests/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButtonTests.swift index d160281a8e..d69fe1d152 100644 --- a/Core/CoreTests/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButtonTests.swift +++ b/Core/CoreTests/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButtonTests.swift @@ -90,4 +90,45 @@ class InsertStudioOpenDetailViewButtonTests: XCTestCase { 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) + } } From b045bb91b7aa9487e2d1abf600ef30e84cd60747 Mon Sep 17 00:00:00 2001 From: Attila Varga Date: Thu, 27 Nov 2025 18:25:02 +0100 Subject: [PATCH 42/45] Add better name. --- .../Model/Features/InsertStudioOpenDetailViewButton.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift index c4b4cbe9bd..6fd07db80b 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButton.swift @@ -21,7 +21,7 @@ import UIKit class InsertStudioOpenInDetailButtons: CoreWebViewFeature { private let insertStyle: String = { - let fontSize = UIFont.scaledNamedFont(.regular14) + let fontSize = UIFont.scaledNamedFont(.regular14).pointSize let css = """ p[ios-injected] { text-align: center; @@ -29,7 +29,7 @@ class InsertStudioOpenInDetailButtons: CoreWebViewFeature { .open_details_button { font-weight: 400; - font-size: \(fontSize.pointSize)px; + font-size: \(fontSize)px; text-decoration: none; color: #2B7ABC; } From e17cc04df677c038bf723a48a794b2d0a5346092 Mon Sep 17 00:00:00 2001 From: Suhaib Al-Absi Date: Thu, 27 Nov 2025 23:49:31 +0300 Subject: [PATCH 43/45] Update InsertStudioOpenInDetailButtons.css --- .../Model/Features/InsertStudioOpenInDetailButtons.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.css b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.css index a5ee33209c..5932b8f1c1 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.css +++ b/Core/Core/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtons.css @@ -4,7 +4,7 @@ p[ios-injected] { .open_details_button { font-weight: 400; - font-size: \(fontSize)px; + font-size: 14px; text-decoration: none; color: #2B7ABC; } From 643678b6dbfc16dab87712c252ac9e1af744c41d Mon Sep 17 00:00:00 2001 From: Suhaib Al-Absi Date: Fri, 28 Nov 2025 00:10:51 +0300 Subject: [PATCH 44/45] Update CoreWebViewStudioFeaturesInteractor.swift --- .../Model/CoreWebViewStudioFeaturesInteractor.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractor.swift b/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractor.swift index 037f03acd3..7a99bc2f2b 100644 --- a/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractor.swift +++ b/Core/Core/Common/CommonUI/CoreWebView/Model/CoreWebViewStudioFeaturesInteractor.swift @@ -97,9 +97,9 @@ public class CoreWebViewStudioFeaturesInteractor { /// 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). - /// Only for Canvas uploads video players. + /// 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 } From 8046ae259166370295ccfa4f068c9d00a9a9f6a6 Mon Sep 17 00:00:00 2001 From: Suhaib Al-Absi Date: Fri, 28 Nov 2025 00:22:31 +0300 Subject: [PATCH 45/45] Update InsertStudioOpenInDetailButtonsTests.swift --- ...nsertStudioOpenInDetailButtonsTests.swift} | 43 +------------------ 1 file changed, 1 insertion(+), 42 deletions(-) rename Core/CoreTests/Common/CommonUI/CoreWebView/Model/Features/{InsertStudioOpenDetailViewButtonTests.swift => InsertStudioOpenInDetailButtonsTests.swift} (65%) diff --git a/Core/CoreTests/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButtonTests.swift b/Core/CoreTests/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtonsTests.swift similarity index 65% rename from Core/CoreTests/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButtonTests.swift rename to Core/CoreTests/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtonsTests.swift index d69fe1d152..ec5b07bbbc 100644 --- a/Core/CoreTests/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenDetailViewButtonTests.swift +++ b/Core/CoreTests/Common/CommonUI/CoreWebView/Model/Features/InsertStudioOpenInDetailButtonsTests.swift @@ -19,7 +19,7 @@ import Core import XCTest -class InsertStudioOpenDetailViewButtonTests: XCTestCase { +class InsertStudioOpenInDetailButtonsTests: XCTestCase { func testInsertion() { let mockLinkDelegate = MockCoreWebViewLinkDelegate() @@ -90,45 +90,4 @@ class InsertStudioOpenDetailViewButtonTests: XCTestCase { 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) - } }