diff --git a/ios/BUILD.gn b/ios/BUILD.gn index 066f97ed6038..af0cccb9ec35 100644 --- a/ios/BUILD.gn +++ b/ios/BUILD.gn @@ -34,6 +34,7 @@ import("//brave/ios/browser/api/query_filter/headers.gni") import("//brave/ios/browser/api/session_restore/headers.gni") import("//brave/ios/browser/api/skus/headers.gni") import("//brave/ios/browser/api/storekit_receipt/headers.gni") +import("//brave/ios/browser/api/translate/headers.gni") import("//brave/ios/browser/api/unicode/headers.gni") import("//brave/ios/browser/api/url/headers.gni") import("//brave/ios/browser/api/url_sanitizer/headers.gni") @@ -128,6 +129,7 @@ brave_core_public_headers += credential_provider_public_headers brave_core_public_headers += developer_options_code_public_headers brave_core_public_headers += browser_api_storekit_receipt_public_headers brave_core_public_headers += webcompat_reporter_public_headers +brave_core_public_headers += browser_api_translate_public_headers brave_core_public_headers += browser_api_unicode_public_headers action("brave_core_umbrella_header") { diff --git a/ios/app/brave_core_main.h b/ios/app/brave_core_main.h index 13b11e3e81a6..a0ded6f902f7 100644 --- a/ios/app/brave_core_main.h +++ b/ios/app/brave_core_main.h @@ -111,6 +111,8 @@ OBJC_EXPORT /// Should only be called in unit tests + (bool)initializeICUForTesting; ++ (void)initializeResourceBundleForTesting; + @end NS_ASSUME_NONNULL_END diff --git a/ios/app/brave_core_main.mm b/ios/app/brave_core_main.mm index b6d0e9690342..7ccb5ba9276d 100644 --- a/ios/app/brave_core_main.mm +++ b/ios/app/brave_core_main.mm @@ -10,6 +10,8 @@ #include "base/apple/bundle_locations.h" #include "base/apple/foundation_util.h" +#include "base/at_exit.h" +#include "base/command_line.h" #include "base/compiler_specific.h" #include "base/files/file_path.h" #include "base/i18n/icu_util.h" @@ -77,6 +79,8 @@ #include "ios/public/provider/chrome/browser/ui_utils/ui_utils_api.h" #include "ios/web/public/init/web_main.h" #include "services/network/public/cpp/shared_url_loader_factory.h" +#include "ui/base/resource/resource_bundle.h" +#include "ui/base/ui_base_paths.h" #if BUILDFLAG(IOS_CREDENTIAL_PROVIDER_ENABLED) #include "ios/chrome/browser/credential_provider/model/credential_provider_service_factory.h" @@ -516,6 +520,35 @@ + (bool)initializeICUForTesting { return base::i18n::InitializeICU(); } ++ (void)initializeResourceBundleForTesting { + @autoreleasepool { + ios::RegisterPathProvider(); + ui::RegisterPathProvider(); + } + + base::AtExitManager exit_manager; + base::CommandLine::Init(0, nullptr); + + [BraveCoreMain initializeICUForTesting]; + + NSBundle* baseBundle = base::apple::OuterBundle(); + base::apple::SetBaseBundleID( + base::SysNSStringToUTF8([baseBundle bundleIdentifier]).c_str()); + + // Register all providers before calling any Chromium code. + [ProviderRegistration registerProviders]; + + ui::ResourceBundle::InitSharedInstanceWithLocale( + "en-US", nullptr, ui::ResourceBundle::LOAD_COMMON_RESOURCES); + + // Add Brave Resource Pack + base::FilePath brave_pack_path; + base::PathService::Get(base::DIR_ASSETS, &brave_pack_path); + brave_pack_path = brave_pack_path.AppendASCII("brave_resources.pak"); + ui::ResourceBundle::GetSharedInstance().AddDataPackFromPath( + brave_pack_path, ui::kScaleFactorNone); +} + #if BUILDFLAG(IOS_CREDENTIAL_PROVIDER_ENABLED) - (void)performFaviconsCleanup { ProfileIOS* browserState = _main_profile; diff --git a/ios/brave-ios/Package.swift b/ios/brave-ios/Package.swift index 0716999bcf58..655cc77c7e80 100644 --- a/ios/brave-ios/Package.swift +++ b/ios/brave-ios/Package.swift @@ -585,6 +585,9 @@ var braveTarget: PackageDescription.Target = .target( .copy("Frontend/UserContent/UserScripts/Scripts_Dynamic/Scripts/Paged/YoutubeQualityScript.js"), .copy("Frontend/UserContent/UserScripts/Scripts_Dynamic/Scripts/Sandboxed/BraveLeoScript.js"), .copy("Frontend/UserContent/UserScripts/Scripts_Dynamic/Scripts/Sandboxed/DarkReaderScript.js"), + .copy( + "Frontend/UserContent/UserScripts/Scripts_Dynamic/Scripts/Sandboxed/BraveTranslateScript.js" + ), .copy("Frontend/UserContent/UserScripts/Scripts_Dynamic/Scripts/Sandboxed/DeAmpScript.js"), .copy("Frontend/UserContent/UserScripts/Scripts_Dynamic/Scripts/Sandboxed/FaviconScript.js"), .copy( diff --git a/ios/brave-ios/Sources/Brave/Frontend/Brave Translate/BraveTranslateSession.swift b/ios/brave-ios/Sources/Brave/Frontend/Brave Translate/BraveTranslateSession.swift new file mode 100644 index 000000000000..97e35b699f56 --- /dev/null +++ b/ios/brave-ios/Sources/Brave/Frontend/Brave Translate/BraveTranslateSession.swift @@ -0,0 +1,150 @@ +// Copyright 2024 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Foundation +import SwiftUI +import Translation +import os.log + +class BraveTranslateSession { + struct RequestMessage: Codable { + let method: String + let url: URL + let headers: [String: String] + let body: String + } + + static func isPhraseTranslationRequest( + _ request: RequestMessage + ) -> Bool { + return request.url.path.starts(with: "/translate_a/") + } + + class func isTranslationSupported( + from source: Locale.Language, + to target: Locale.Language + ) async -> Bool { + if #available(iOS 18.0, *) { + #if !targetEnvironment(simulator) + let availability = LanguageAvailability() + let status = await availability.status(from: source, to: target) + switch status { + case .installed, .supported: + return true + case .unsupported: + return false + @unknown default: + return false + } + #endif + } + + guard let sourceLanguage = source.languageCode?.identifier, + let targetLanguage = target.languageCode?.identifier + else { + return false + } + return sourceLanguage != targetLanguage + } + + func translate( + _ request: RequestMessage + ) async throws -> (data: Data, response: URLResponse) { + var urlRequest = URLRequest(url: request.url) + urlRequest.httpMethod = request.method + urlRequest.httpBody = request.body.data(using: .utf8) + request.headers.forEach { (key, value) in + urlRequest.setValue(value, forHTTPHeaderField: key) + } + + let session = URLSession(configuration: .ephemeral) + defer { session.finishTasksAndInvalidate() } + return try await session.data(for: urlRequest) + } +} + +struct BraveTranslateContainerView: View { + var onTranslationSessionUpdated: ((BraveTranslateSession?) async -> Void)? + + @ObservedObject + var languageInfo: BraveTranslateLanguageInfo + + var body: some View { + Color.clear + .osAvailabilityModifiers({ view in + if #available(iOS 18.0, *) { + #if !targetEnvironment(simulator) + view + .translationTask( + .init(source: languageInfo.pageLanguage, target: languageInfo.currentLanguage), + action: { session in + do { + try await session.prepareTranslation() + await onTranslationSessionUpdated?(BraveTranslateSessionApple(session: session)) + } catch { + Logger.module.error("Translate Session Unavailable: \(error)") + await onTranslationSessionUpdated?(nil) + } + } + ) + #else + view.task { + await onTranslationSessionUpdated?(BraveTranslateSession()) + } + #endif + } else { + view.task { + await onTranslationSessionUpdated?(BraveTranslateSession()) + } + } + }) + } +} + +#if !targetEnvironment(simulator) +@available(iOS 18.0, *) +private class BraveTranslateSessionApple: BraveTranslateSession { + private weak var session: TranslationSession? + + init(session: TranslationSession) { + self.session = session + } + + override func translate( + _ request: RequestMessage + ) async throws -> (data: Data, response: URLResponse) { + // Do not attempt to translate requests to html and css from Brave-Translate script + guard Self.isPhraseTranslationRequest(request) else { + return try await super.translate(request) + } + + guard let session = session else { + throw BraveTranslateError.otherError + } + + let components = URLComponents(string: "https://translate.brave.com?\(request.body)") + let phrases = components!.queryItems!.map({ "\($0.value ?? "")" }) + let results = try await session.translations( + from: phrases.map({ .init(sourceText: $0) }) + ).map({ $0.targetText }) + + let data = try JSONSerialization.data(withJSONObject: results, options: []) + let response = + HTTPURLResponse( + url: request.url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "text/html"] + ) + ?? URLResponse( + url: request.url, + mimeType: "text/html", + expectedContentLength: data.count, + textEncodingName: "UTF-8" + ) + return (data, response) + } +} +#endif diff --git a/ios/brave-ios/Sources/Brave/Frontend/Brave Translate/BraveTranslateTabHelper.swift b/ios/brave-ios/Sources/Brave/Frontend/Brave Translate/BraveTranslateTabHelper.swift new file mode 100644 index 000000000000..1064dfcfb872 --- /dev/null +++ b/ios/brave-ios/Sources/Brave/Frontend/Brave Translate/BraveTranslateTabHelper.swift @@ -0,0 +1,343 @@ +// Copyright 2024 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Foundation +import NaturalLanguage +import SwiftUI +import WebKit +import os.log + +enum BraveTranslateError: String, Error { + case invalidURL + case invalidLanguage + case sameLanguage + case invalidPageSource + case invalidTranslationResponseCode + case invalidTranslationResponse + case translateDisabled + case otherError +} + +class BraveTranslateTabHelper: NSObject { + private weak var tab: Tab? + private weak var delegate: BraveTranslateScriptHandlerDelegate? + private let recognizer = NLLanguageRecognizer() + + private var url: URL? + private var isTranslationReady = false + private var translationController: UIHostingController! + private var translationSession: BraveTranslateSession? + private var urlObserver: NSObjectProtocol? + private var translationTask: (() async throws -> Void)? + private var canShowToast = false + + var currentLanguageInfo = BraveTranslateLanguageInfo() + + // All TabHelpers in Chromium have a `WebState* web_state` parameter in their constructor + // WebState in Brave, is the same as `Tab`. + init(tab: Tab, delegate: BraveTranslateScriptHandlerDelegate) { + self.tab = tab + self.delegate = delegate + self.url = tab.url + super.init() + + translationController = UIHostingController( + rootView: BraveTranslateContainerView( + onTranslationSessionUpdated: { [weak self] session in + guard let self = self else { return } + + self.translationSession = session + if let translationTask = self.translationTask, session != nil { + try? await translationTask() + self.translationTask = nil + } + }, + languageInfo: currentLanguageInfo + ) + ) + + urlObserver = tab.webView?.observe( + \.url, + options: [.new], + changeHandler: { [weak self] _, change in + guard let self = self, let url = change.newValue else { return } + if self.url != url { + self.url = url + self.isTranslationReady = false + self.canShowToast = false + self.delegate?.updateTranslateURLBar(tab: self.tab, state: .unavailable) + } + } + ) + } + + deinit { + translationController?.willMove(toParent: nil) + translationController?.removeFromParent() + translationController = nil + translationTask = nil + } + + func startTranslation(canShowToast: Bool) { + guard let tab = tab else { + return + } + + // Translation already in progress + if tab.translationState == .pending { + return + } + + // This is necessary because if the session hasn't be initialized yet (since it's asynchronous in Apple's API), + // then translation would not be possible. So we need to store this request for now, and when the session is ready + // then we execute the translation. If the session is already available, we execute immediately. + self.translationTask = { @MainActor [weak self] in + guard let self, + let currentLanguage = currentLanguageInfo.currentLanguage.languageCode?.identifier, + let pageLanguage = currentLanguageInfo.pageLanguage?.languageCode?.identifier, + currentLanguage != pageLanguage + else { + throw BraveTranslateError.invalidLanguage + } + + try Task.checkCancellation() + + // Normalize Chinese if necessary to Chinese (Simplified). zh-TW = Traditional + // We don't current use the components/language/ios/browser/language_detection_java_script_feature.mm + // Supported List of Languages is from: components/translate/core/browser/translate_language_list.cc + // So we have to do this for now. + try await executeChromiumFunction( + tab: tab, + name: "translate.startTranslation", + args: [ + pageLanguage == "zh" ? "zh-CN" : pageLanguage, + currentLanguage == "zh" ? "zh-CN" : currentLanguage, + ] + ) + + try Task.checkCancellation() + + self.canShowToast = canShowToast + self.delegate?.updateTranslateURLBar(tab: tab, state: .pending) + } + + if translationSession != nil { + Task { @MainActor in + try? await translationTask?() + translationTask = nil + } + } + } + + func revertTranslation() { + guard let tab = tab else { + return + } + + Task { @MainActor [weak self] in + guard let self = self, + self.isTranslationReady, + tab.translationState == .active + else { + return + } + + try? await executeChromiumFunction(tab: tab, name: "translate.revertTranslation") + self.delegate?.updateTranslateURLBar(tab: tab, state: .available) + } + } + + func presentUI(on controller: UIViewController) { + guard isTranslationReady else { + return + } + + if translationController?.parent != controller { + controller.addChild(translationController) + tab?.webView?.addSubview(translationController.view) + translationController.didMove(toParent: controller) + tab?.webView?.sendSubviewToBack(translationController.view) + } + } + + @MainActor + func setupTranslate() async throws { + guard let tab = tab else { + return + } + + isTranslationReady = true + let languageInfo = await getLanguageInfo(for: tab) + self.currentLanguageInfo.pageLanguage = languageInfo.pageLanguage + self.currentLanguageInfo.currentLanguage = languageInfo.currentLanguage + try Task.checkCancellation() + try await updateTranslationStatus(for: tab) + } + + @MainActor + func processTranslationRequest( + _ request: BraveTranslateSession.RequestMessage + ) async throws -> (Data, HTTPURLResponse) { + let translationSession = self.translationSession ?? BraveTranslateSession() + let isTranslationRequest = BraveTranslateSession.isPhraseTranslationRequest(request) + + // The message is for HTML or CSS request + if self.translationSession == nil + && isTranslationRequest + { + throw BraveTranslateError.otherError + } + + let (data, response) = try await translationSession.translate(request) + + guard let response = response as? HTTPURLResponse else { + throw BraveTranslateError.invalidTranslationResponse + } + + if isTranslationRequest && canShowToast { + canShowToast = false + + Task { @MainActor in + self.delegate?.updateTranslateURLBar(tab: tab, state: .active) + self.delegate?.presentToast(tab: tab, languageInfo: currentLanguageInfo) + } + } + + return (data, response) + } + + // MARK: - Private + + @MainActor + private func executePageFunction(tab: Tab, name functionName: String) async -> String? { + guard let webView = tab.webView else { + return nil + } + + let (result, error) = await webView.evaluateSafeJavaScript( + functionName: "window.__firefox__.\(BraveTranslateScriptHandler.namespace).\(functionName)", + contentWorld: BraveTranslateScriptHandler.scriptSandbox, + asFunction: true + ) + + if let error = error { + Logger.module.error("Unable to execute page function \(functionName) error: \(error)") + return nil + } + + guard let result = result as? String else { + Logger.module.error("Invalid Page Result") + return nil + } + + return result + } + + @MainActor + @discardableResult + private func executeChromiumFunction( + tab: Tab, + name functionName: String, + args: [Any] = [] + ) async throws -> Any { + guard let webView = tab.webView else { + throw BraveTranslateError.otherError + } + + let (result, error) = await webView.evaluateSafeJavaScript( + functionName: "window.__gCrWeb.\(functionName)", + args: args, + contentWorld: BraveTranslateScriptHandler.scriptSandbox, + asFunction: true + ) + + if let error = error { + Logger.module.error("Unable to execute page function \(functionName) error: \(error)") + throw error + } + + return result + } + + private func getPageSource(for tab: Tab) async -> String? { + return await executePageFunction(tab: tab, name: "getPageSource") + } + + @MainActor + private func guessLanguage(for tab: Tab) async -> Locale.Language? { + try? await executeChromiumFunction(tab: tab, name: "languageDetection.detectLanguage") + + // Language was identified by the Translate Script + if let currentLanguage = currentLanguageInfo.currentLanguage.languageCode?.identifier, + let pageLanguage = currentLanguageInfo.pageLanguage?.languageCode?.identifier + { + if currentLanguage == pageLanguage { + currentLanguageInfo.pageLanguage = nil + } + return currentLanguageInfo.pageLanguage + } + + // Language identified via our own Javascript + if let languageCode = await executePageFunction(tab: tab, name: "getPageLanguage"), + !languageCode.isEmpty + { + return Locale.Language(identifier: languageCode) + } + + // Language identified by running the NL detection on the page source + if let rawPageSource = await executePageFunction(tab: tab, name: "getRawPageSource") { + recognizer.reset() + recognizer.processString(rawPageSource) + + if let dominantLanguage = recognizer.dominantLanguage, dominantLanguage != .undetermined { + return Locale.Language(identifier: dominantLanguage.rawValue) + } + } + + return nil + } + + private func getLanguageInfo(for tab: Tab) async -> BraveTranslateLanguageInfo { + let pageLanguage = await guessLanguage(for: tab) + return .init(currentLanguage: Locale.current.language, pageLanguage: pageLanguage) + } + + @MainActor + private func updateTranslationStatus(for tab: Tab) async throws { + guard let pageLanguage = currentLanguageInfo.pageLanguage else { + delegate?.updateTranslateURLBar(tab: tab, state: .unavailable) + return + } + + let isTranslationSupported = await BraveTranslateSession.isTranslationSupported( + from: pageLanguage, + to: currentLanguageInfo.currentLanguage + ) + + try Task.checkCancellation() + delegate?.updateTranslateURLBar( + tab: tab, + state: isTranslationSupported ? .available : .unavailable + ) + } +} + +class BraveTranslateLanguageInfo: ObservableObject { + @Published + var currentLanguage: Locale.Language + + @Published + var pageLanguage: Locale.Language? + + init() { + currentLanguage = .init(identifier: Locale.current.identifier) + pageLanguage = nil + } + + init(currentLanguage: Locale.Language, pageLanguage: Locale.Language?) { + self.currentLanguage = currentLanguage + self.pageLanguage = pageLanguage + } +} diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+ReaderMode.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+ReaderMode.swift index 117b7a7c0c12..93842de7b0a7 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+ReaderMode.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+ReaderMode.swift @@ -159,12 +159,16 @@ extension BrowserViewController { if backList.count > 1 && backList.last?.url == readerModeURL { let playlistItem = tab.playlistItem + let translationState = tab.translationState webView.go(to: backList.last!) PlaylistScriptHandler.updatePlaylistTab(tab: tab, item: playlistItem) + self.updateTranslateURLBar(tab: tab, state: translationState) } else if !forwardList.isEmpty && forwardList.first?.url == readerModeURL { let playlistItem = tab.playlistItem + let translationState = tab.translationState webView.go(to: forwardList.first!) PlaylistScriptHandler.updatePlaylistTab(tab: tab, item: playlistItem) + self.updateTranslateURLBar(tab: tab, state: translationState) } else { // Store the readability result in the cache and load it. This will later move to the ReadabilityHelper. webView.evaluateSafeJavaScript( @@ -173,10 +177,12 @@ extension BrowserViewController { ) { (object, error) -> Void in if let readabilityResult = ReadabilityResult(object: object as AnyObject?) { let playlistItem = tab.playlistItem - Task { + let translationState = tab.translationState + Task { @MainActor in try? await self.readerModeCache.put(currentURL, readabilityResult) if webView.load(PrivilegedRequest(url: readerModeURL) as URLRequest) != nil { PlaylistScriptHandler.updatePlaylistTab(tab: tab, item: playlistItem) + self.updateTranslateURLBar(tab: tab, state: translationState) } } } @@ -200,16 +206,22 @@ extension BrowserViewController { if let originalURL = currentURL.decodeEmbeddedInternalURL(for: .readermode) { if backList.count > 1 && backList.last?.url == originalURL { let playlistItem = tab.playlistItem + let translationState = tab.translationState webView.go(to: backList.last!) PlaylistScriptHandler.updatePlaylistTab(tab: tab, item: playlistItem) + self.updateTranslateURLBar(tab: tab, state: translationState) } else if !forwardList.isEmpty && forwardList.first?.url == originalURL { let playlistItem = tab.playlistItem + let translationState = tab.translationState webView.go(to: forwardList.first!) PlaylistScriptHandler.updatePlaylistTab(tab: tab, item: playlistItem) + self.updateTranslateURLBar(tab: tab, state: translationState) } else { let playlistItem = tab.playlistItem + let translationState = tab.translationState if webView.load(URLRequest(url: originalURL)) != nil { PlaylistScriptHandler.updatePlaylistTab(tab: tab, item: playlistItem) + self.updateTranslateURLBar(tab: tab, state: translationState) } } } diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+ShareActivity.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+ShareActivity.swift index c82f044c2421..45100016740b 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+ShareActivity.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+ShareActivity.swift @@ -96,6 +96,30 @@ extension BrowserViewController { // Any other buttons on the leading side of the location view should be added here as well } + // Translate Activity + if let translationState = tab?.translationState, translationState != .unavailable, + Preferences.Translate.translateEnabled.value + { + activities.append( + BasicMenuActivity( + activityType: .translatePage, + callback: { [weak self] in + guard let self = self, let tab = tab else { return } + + if let translateHelper = tab.translateHelper { + translateHelper.presentUI(on: self) + + if tab.translationState == .active { + translateHelper.revertTranslation() + } else if tab.translationState != .active { + translateHelper.startTranslation(canShowToast: true) + } + } + } + ) + ) + } + // Find In Page Activity activities.append( BasicMenuActivity( diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+TabManagerDelegate.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+TabManagerDelegate.swift index 578e28a40c9c..0b17cc8d56d3 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+TabManagerDelegate.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+TabManagerDelegate.swift @@ -153,7 +153,20 @@ extension BrowserViewController: TabManagerDelegate { item: selected?.playlistItem ) } else { - topToolbar.updateReaderModeState(ReaderModeState.unavailable) + topToolbar.updateReaderModeState(.unavailable) + } + + if (selected?.getContentScript( + name: BraveTranslateScriptHandler.scriptName + ) as? BraveTranslateScriptHandler) != nil { + updateTranslateURLBar(tab: selected, state: selected?.translationState ?? .unavailable) + updatePlaylistURLBar( + tab: selected, + state: selected?.playlistItemState ?? .none, + item: selected?.playlistItem + ) + } else { + topToolbar.updateTranslateButtonState(.unavailable) } updateScreenTimeUrl(tabManager.selectedTab?.url) diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+ToolbarDelegate.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+ToolbarDelegate.swift index ae814cbb799e..90e1152a0a6f 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+ToolbarDelegate.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+ToolbarDelegate.swift @@ -261,6 +261,20 @@ extension BrowserViewController: TopToolbarDelegate { } } + func topToolbarDidPressTranslateButton(_ urlBar: TopToolbarView) { + guard let tab = tabManager.selectedTab else { return } + + if let translateHelper = tab.translateHelper { + translateHelper.presentUI(on: self) + + if tab.translationState == .active { + translateHelper.revertTranslation() + } else if tab.translationState != .active { + translateHelper.startTranslation(canShowToast: true) + } + } + } + @MainActor private func submitValidURL( _ text: String, isUserDefinedURLNavigation: Bool @@ -1025,8 +1039,7 @@ extension BrowserViewController: ToolbarDelegate { } func topToolbarDidTapSecureContentState(_ urlBar: TopToolbarView) { - guard let tab = tabManager.selectedTab, let url = tab.url, - let secureContentStateButton = urlBar.locationView.secureContentStateButton + guard let tab = tabManager.selectedTab, let url = tab.url else { return } let hasCertificate = (tab.webView?.serverTrust ?? (try? ErrorPageHelper.serverTrust(from: url))) != nil @@ -1040,7 +1053,7 @@ extension BrowserViewController: ToolbarDelegate { } ) let popoverController = PopoverController(content: pageSecurityView) - popoverController.present(from: secureContentStateButton, on: self) + popoverController.present(from: urlBar.locationView.secureContentStateButton, on: self) } func showBackForwardList() { diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+Translate.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+Translate.swift new file mode 100644 index 000000000000..026f26bab0eb --- /dev/null +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+Translate.swift @@ -0,0 +1,120 @@ +// Copyright 2024 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import BraveUI +import DesignSystem +import Foundation +import Onboarding +import Preferences +import SwiftUI +import UIKit + +extension BrowserViewController: BraveTranslateScriptHandlerDelegate { + func updateTranslateURLBar(tab: Tab?, state: TranslateURLBarButton.TranslateState) { + guard let tab = tab else { return } + + tab.translationState = state + + if tab === tabManager.selectedTab { + topToolbar.updateTranslateButtonState(state) + + showTranslateOnboarding(tab: tab) { [weak tab] translateEnabled in + if let translateHelper = tab?.translateHelper { + translateHelper.startTranslation(canShowToast: true) + } + } + } + } + + func showTranslateOnboarding(tab: Tab?, completion: @escaping (_ translateEnabled: Bool) -> Void) + { + // Do NOT show the translate onboarding popup if the tab isn't visible + guard Preferences.Translate.translateEnabled.value, + let selectedTab = tabManager.selectedTab, + selectedTab === tab, + selectedTab.translationState == .available + else { + return + } + + if Preferences.Translate.translateURLBarOnboardingCount.value < 2, + shouldShowTranslationOnboardingThisSession, + presentedViewController == nil + { + Preferences.Translate.translateURLBarOnboardingCount.value += 1 + + topToolbar.layoutIfNeeded() + view.layoutIfNeeded() + + // Ensure url bar is expanded before presenting a popover on it + toolbarVisibilityViewModel.toolbarState = .expanded + + DispatchQueue.main.async { + let popover = PopoverController( + content: OnboardingTranslateView( + onContinueButtonPressed: { [weak self, weak tab] in + guard let tab = tab, let self = self else { return } + + self.topToolbar.locationView.translateButton.setOnboardingState(enabled: false) + Preferences.Translate.translateEnabled.value = true + + tab.translateHelper?.presentUI(on: self) + }, + onDisableFeature: { [weak self, weak tab] in + guard let tab = tab, let self = self else { return } + + self.topToolbar.locationView.translateButton.setOnboardingState(enabled: false) + + Preferences.Translate.translateEnabled.value = false + tab.translationState = .unavailable + self.topToolbar.updateTranslateButtonState(.unavailable) + } + ), + autoLayoutConfiguration: .init(preferredWidth: self.view.bounds.width - (32.0 * 2.0)) + ) + + popover.arrowDistance = 10.0 + + popover.previewForOrigin = .init( + view: self.topToolbar.locationView.translateButton.then { + $0.setOnboardingState(enabled: true) + }, + action: { popover in + popover.previewForOrigin = nil + popover.dismissPopover() + completion(Preferences.Translate.translateEnabled.value) + } + ) + + popover.popoverDidDismiss = { [weak self, weak tab] _ in + guard let tab = tab, let self = self else { return } + + self.topToolbar.locationView.translateButton.setOnboardingState(enabled: false) + + if Preferences.Translate.translateEnabled.value { + tab.translateHelper?.presentUI(on: self) + completion(true) + return + } + + completion(false) + } + popover.present(from: self.topToolbar.locationView.translateButton, on: self) + } + + shouldShowTranslationOnboardingThisSession = false + } + } + + func presentToast(tab: Tab?, languageInfo: BraveTranslateLanguageInfo) { + let popover = PopoverController( + content: TranslateToast(languageInfo: languageInfo) { [weak tab] _ in + tab?.translateHelper?.startTranslation(canShowToast: false) + }, + autoLayoutConfiguration: nil + ) + popover.present(from: self.topToolbar.locationView.translateButton, on: self) + } +} diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+WKNavigationDelegate.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+WKNavigationDelegate.swift index 09f450bca98a..b1f04f104549 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+WKNavigationDelegate.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+WKNavigationDelegate.swift @@ -92,13 +92,15 @@ extension BrowserViewController: WKNavigationDelegate { // (orange color) as soon as the page has loaded. if let url = webView.url { if !url.isInternalURL(for: .readermode) { - topToolbar.updateReaderModeState(ReaderModeState.unavailable) + topToolbar.updateReaderModeState(.unavailable) hideReaderModeBar(animated: false) } } hideToastsOnNavigationStartIfNeeded(tabManager) + // If we are going to navigate to a new page, hide the translate button. + topToolbar.updateTranslateButtonState(.unavailable) resetRedirectChain(webView) // Append source URL to redirect chain diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController.swift index 48676548f800..18bd7232f631 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController.swift @@ -25,6 +25,7 @@ import SpeechRecognition import Storage import StoreKit import SwiftUI +import Translation import UIKit import WebKit import os.log @@ -151,6 +152,11 @@ public class BrowserViewController: UIViewController { private var adFeatureLinkageCancelable: AnyCancellable? var onPendingRequestUpdatedCancellable: AnyCancellable? + // Translation + let translationHostingController: UIHostingController = .init( + rootView: AnyView(EmptyView()) + ) + /// Voice Search var voiceSearchViewController: PopupViewController? var voiceSearchCancelable: AnyCancellable? @@ -484,6 +490,7 @@ public class BrowserViewController: UIViewController { Preferences.NewTabPage.backgroundMediaTypeRaw.observe(from: self) ShieldPreferences.blockAdsAndTrackingLevelRaw.observe(from: self) Preferences.Privacy.screenTimeEnabled.observe(from: self) + Preferences.Translate.translateEnabled.observe(from: self) pageZoomListener = NotificationCenter.default.addObserver( forName: PageZoomView.notificationName, @@ -869,6 +876,10 @@ public class BrowserViewController: UIViewController { addChild(tabsBar) tabsBar.didMove(toParent: self) + addChild(translationHostingController) + view.addSubview(translationHostingController.view) + translationHostingController.didMove(toParent: self) + view.addSubview(alertStackView) view.addSubview(bottomTouchArea) view.addSubview(topTouchArea) @@ -1327,6 +1338,9 @@ public class BrowserViewController: UIViewController { /// Whether or not to show the playlist onboarding callout this session var shouldShowPlaylistOnboardingThisSession = true + /// Wheter or not to show the translate onboarding callout this session + var shouldShowTranslationOnboardingThisSession = true + public func showQueuedAlertIfAvailable() { if let queuedAlertInfo = tabManager.selectedTab?.dequeueJavascriptAlertPrompt() { let alertController = queuedAlertInfo.alertController() @@ -2641,11 +2655,13 @@ extension BrowserViewController: TabDelegate { Web3NameServiceScriptHandler(tab: tab), YoutubeQualityScriptHandler(tab: tab), BraveLeoScriptHandler(tab: tab), - tab.contentBlocker, tab.requestBlockingContentHelper, ] + injectedScripts.append(BraveTranslateScriptLanguageDetectionHandler(tab: tab)) + injectedScripts.append(BraveTranslateScriptHandler(tab: tab)) + #if canImport(BraveTalk) injectedScripts.append( BraveTalkScriptHandler( @@ -2694,6 +2710,9 @@ extension BrowserViewController: TabDelegate { as? PlaylistFolderSharingScriptHandler)?.delegate = self (tab.getContentScript(name: Web3NameServiceScriptHandler.scriptName) as? Web3NameServiceScriptHandler)?.delegate = self + + // Translate Helper + tab.translateHelper = BraveTranslateTabHelper(tab: tab, delegate: self) } func tab(_ tab: Tab, willDeleteWebView webView: WKWebView) { @@ -3357,6 +3376,11 @@ extension BrowserViewController: PreferencesObserver { screenTimeViewController?.suppressUsageRecording = true screenTimeViewController = nil } + case Preferences.Translate.translateEnabled.key: + tabManager.selectedTab?.setScripts(scripts: [ + .braveTranslate: Preferences.Translate.translateEnabled.value + ]) + tabManager.reloadSelectedTab() default: Logger.module.debug( "Received a preference change for an unknown key: \(key, privacy: .public) on \(type(of: self), privacy: .public)" diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/Playlist/Onboarding & Toast/PlaylistToast.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/Playlist/Onboarding & Toast/PlaylistToast.swift index a665acc11d11..8640fd23f3f0 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Browser/Playlist/Onboarding & Toast/PlaylistToast.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/Playlist/Onboarding & Toast/PlaylistToast.swift @@ -11,37 +11,6 @@ import Shared import SnapKit import UIKit -private class ToastShadowView: UIView { - private var shadowLayer: CAShapeLayer? - - override func layoutSubviews() { - super.layoutSubviews() - - let path = UIBezierPath( - roundedRect: bounds, - cornerRadius: ButtonToastUX.toastButtonBorderRadius - ).cgPath - if let shadowLayer = shadowLayer { - shadowLayer.path = path - shadowLayer.shadowPath = path - } else { - shadowLayer = CAShapeLayer().then { - $0.path = path - $0.fillColor = UIColor.clear.cgColor - $0.shadowColor = UIColor.black.cgColor - $0.shadowPath = path - $0.shadowOffset = .zero - $0.shadowOpacity = 0.5 - $0.shadowRadius = ButtonToastUX.toastButtonBorderRadius - } - - shadowLayer?.do { - layer.insertSublayer($0, at: 0) - } - } - } -} - private class HighlightableButton: UIButton { private var shadowLayer: CAShapeLayer? diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/Tab.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/Tab.swift index 5ce4cd8284e5..d98f18a6eb09 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Browser/Tab.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/Tab.swift @@ -320,6 +320,7 @@ class Tab: NSObject { var isEditing = false var playlistItem: PlaylistInfo? var playlistItemState: PlaylistItemAddedState = .none + var translationState: TranslateURLBarButton.TranslateState = .unavailable /// The rewards reporting state which is filled during a page navigation. // It is reset to initial values when the page navigation is finished. @@ -428,6 +429,8 @@ class Tab: NSObject { } } + var translateHelper: BraveTranslateTabHelper? + /// Boolean tracking custom url-scheme alert presented var isExternalAppAlertPresented = false var externalAppPopup: AlertPopupView? @@ -539,6 +542,7 @@ class Tab: NSObject { .cookieBlocking: Preferences.Privacy.blockAllCookies.value, .mediaBackgroundPlay: Preferences.General.mediaAutoBackgrounding.value, .nightMode: Preferences.General.nightModeEnabled.value, + .braveTranslate: Preferences.Translate.translateEnabled.value, ] userScripts = Set(scriptPreferences.filter({ $0.value }).map({ $0.key })) @@ -626,6 +630,7 @@ class Tab: NSObject { func deleteWebView() { contentScriptManager.uninstall(from: self) + translateHelper = nil if let webView = webView { webView.removeObserver(self, forKeyPath: KVOConstants.url.keyPath) diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/Toast.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/Toast.swift index b275c71fd83a..46531d95f1a9 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Browser/Toast.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/Toast.swift @@ -6,6 +6,37 @@ import Foundation import SnapKit import UIKit +class ToastShadowView: UIView { + private var shadowLayer: CAShapeLayer? + + override func layoutSubviews() { + super.layoutSubviews() + + let path = UIBezierPath( + roundedRect: bounds, + cornerRadius: ButtonToastUX.toastButtonBorderRadius + ).cgPath + if let shadowLayer = shadowLayer { + shadowLayer.path = path + shadowLayer.shadowPath = path + } else { + shadowLayer = CAShapeLayer().then { + $0.path = path + $0.fillColor = UIColor.clear.cgColor + $0.shadowColor = UIColor.black.cgColor + $0.shadowPath = path + $0.shadowOffset = .zero + $0.shadowOpacity = 0.5 + $0.shadowRadius = ButtonToastUX.toastButtonBorderRadius + } + + shadowLayer?.do { + layer.insertSublayer($0, at: 0) + } + } + } +} + class Toast: UIView { var animationConstraint: Constraint? var completionHandler: ((Bool) -> Void)? diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/Toolbars/UrlBar/ReaderModeButton.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/Toolbars/UrlBar/ReaderModeButton.swift index 79d73d757346..afc5c2ca36e3 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Browser/Toolbars/UrlBar/ReaderModeButton.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/Toolbars/UrlBar/ReaderModeButton.swift @@ -70,18 +70,6 @@ class ReaderModeButton: UIButton { ) } - override var intrinsicContentSize: CGSize { - var size = super.intrinsicContentSize - let toolbarTraitCollection = UITraitCollection( - preferredContentSizeCategory: traitCollection.toolbarButtonContentSizeCategory - ) - size.width = UIFontMetrics(forTextStyle: .body).scaledValue( - for: 44, - compatibleWith: toolbarTraitCollection - ) - return size - } - var readerModeState: ReaderModeState { get { return _readerModeState diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/Toolbars/UrlBar/TabLocationView.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/Toolbars/UrlBar/TabLocationView.swift index d178eeda3106..3d5b3f98f1be 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Browser/Toolbars/UrlBar/TabLocationView.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/Toolbars/UrlBar/TabLocationView.swift @@ -23,6 +23,7 @@ protocol TabLocationViewDelegate { _ tabLocationView: TabLocationView, action: PlaylistURLBarButton.MenuAction ) + func tabLocationViewDidTapTranslateButton(_ tabLocationView: TabLocationView) func tabLocationViewDidTapReload(_ tabLocationView: TabLocationView) func tabLocationViewDidTapStop(_ tabLocationView: TabLocationView) func tabLocationViewDidTapVoiceSearch(_ tabLocationView: TabLocationView) @@ -79,7 +80,7 @@ class TabLocationView: UIView { configuration.buttonSize = .small configuration.imagePadding = 4 // A bit extra on the leading edge for visual spacing - configuration.contentInsets = .init(top: 0, leading: 12, bottom: 0, trailing: 8) + configuration.contentInsets = .init(top: 0, leading: 0, bottom: 0, trailing: 8) var title = AttributedString(Strings.tabToolbarNotSecureTitle) title.font = .preferredFont(forTextStyle: .subheadline, compatibleWith: clampedTraitCollection) @@ -113,27 +114,27 @@ class TabLocationView: UIView { } private func updateLeadingItem() { - var leadingView: UIView? - defer { leadingItemView = leadingView } + // Hide all items + leadingItemStackView.arrangedSubviews.forEach { + $0.isHidden = true + } + if !secureContentState.shouldDisplayWarning { // Consider reader mode - leadingView = readerModeState != .unavailable ? readerModeButton : nil + secureContentStateButton.configuration = secureContentStateButtonConfiguration + readerModeButton.isHidden = readerModeState == .unavailable + + // Consider brave translate + translateButton.isHidden = translationState == .unavailable return } - let button = UIButton( - configuration: secureContentStateButtonConfiguration, - primaryAction: .init(handler: { [weak self] _ in - guard let self = self else { return } - self.delegate?.tabLocationViewDidTapSecureContentState(self) - }) - ) - button.configurationUpdateHandler = { [unowned self] btn in - btn.configuration = secureContentStateButtonConfiguration - } - button.tintAdjustmentMode = .normal - secureContentStateButton = button - leadingView = button + // Display security status + secureContentStateButton.configuration = secureContentStateButtonConfiguration + secureContentStateButton.isHidden = false + + // Consider brave translate + translateButton.isHidden = translationState == .unavailable } deinit { @@ -172,6 +173,37 @@ class TabLocationView: UIView { } } + var translationState: TranslateURLBarButton.TranslateState { + get { + return translateButton.translateState + } + set(state) { + defer { updateLeadingItem() } + if state != self.translateButton.translateState { + let wasHidden = leadingItemView == nil + self.translateButton.translateState = state + if wasHidden != (state == TranslateURLBarButton.TranslateState.unavailable) { + UIAccessibility.post(notification: .layoutChanged, argument: nil) + if !translateButton.isHidden { + // Delay the Translation Button accessibility announcement briefly to prevent interruptions. + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) { + UIAccessibility.post( + notification: .announcement, + argument: Strings.BraveTranslate.availableVoiceOverAnnouncement + ) + } + } + } + UIView.animate( + withDuration: 0.1, + animations: { () -> Void in + self.translateButton.alpha = state == .unavailable ? 0 : 1 + } + ) + } + } + } + lazy var urlDisplayLabel: UILabel = { let urlDisplayLabel = DisplayURLLabel() @@ -190,15 +222,13 @@ class TabLocationView: UIView { return urlDisplayLabel }() - private(set) lazy var readerModeButton: ReaderModeButton = { - let readerModeButton = ReaderModeButton(frame: .zero) - readerModeButton.addTarget(self, action: #selector(didTapReaderModeButton), for: .touchUpInside) - readerModeButton.isAccessibilityElement = true - readerModeButton.imageView?.contentMode = .scaleAspectFit - readerModeButton.accessibilityLabel = Strings.tabToolbarReaderViewButtonAccessibilityLabel - readerModeButton.accessibilityIdentifier = "TabLocationView.readerModeButton" - return readerModeButton - }() + private(set) lazy var readerModeButton = ReaderModeButton(frame: .zero).then { + $0.addTarget(self, action: #selector(didTapReaderModeButton), for: .touchUpInside) + $0.isAccessibilityElement = true + $0.imageView?.contentMode = .scaleAspectFit + $0.accessibilityLabel = Strings.tabToolbarReaderViewButtonAccessibilityLabel + $0.accessibilityIdentifier = "TabLocationView.readerModeButton" + } private(set) lazy var playlistButton = PlaylistURLBarButton(frame: .zero).then { $0.accessibilityIdentifier = "TabToolbar.playlistButton" @@ -208,6 +238,14 @@ class TabLocationView: UIView { $0.addTarget(self, action: #selector(didTapPlaylistButton), for: .touchUpInside) } + private(set) lazy var translateButton = TranslateURLBarButton(frame: .zero).then { + $0.accessibilityIdentifier = "TabToolbar.translateButton" + $0.isAccessibilityElement = true + $0.translateState = .unavailable + $0.imageView?.contentMode = .scaleAspectFit + $0.addTarget(self, action: #selector(didTapTranslateButton), for: .touchUpInside) + } + private(set) lazy var walletButton = WalletURLBarButton(frame: .zero).then { $0.accessibilityIdentifier = "TabToolbar.walletButton" $0.isAccessibilityElement = true @@ -255,22 +293,35 @@ class TabLocationView: UIView { $0.identifier = "url-layout-guide" } - private let leadingItemContainerView = UIView() + private let leadingItemStackView = UIStackView().then { + $0.alignment = .center + $0.insetsLayoutMarginsFromSafeArea = false + $0.spacing = 8.0 + } + private var leadingItemView: UIView? { willSet { leadingItemView?.removeFromSuperview() } didSet { if let leadingItemView { - leadingItemContainerView.addSubview(leadingItemView) - leadingItemView.snp.makeConstraints { - $0.edges.equalToSuperview() - } + leadingItemStackView.addArrangedSubview(leadingItemView) } } } - private(set) var secureContentStateButton: UIButton? + private(set) lazy var secureContentStateButton = UIButton( + configuration: secureContentStateButtonConfiguration, + primaryAction: .init(handler: { [weak self] _ in + guard let self = self else { return } + self.delegate?.tabLocationViewDidTapSecureContentState(self) + }) + ).then { + $0.configurationUpdateHandler = { [unowned self] in + $0.configuration = secureContentStateButtonConfiguration + } + $0.tintAdjustmentMode = .normal + } private(set) lazy var progressBar = GradientProgressBar().then { $0.clipsToBounds = false @@ -287,9 +338,13 @@ class TabLocationView: UIView { addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTapLocationBar))) - readerModeButton.do { + let leadingItemSubviews: [UIView] = [ + readerModeButton, translateButton, secureContentStateButton, + ] + leadingItemSubviews.forEach { $0.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) $0.setContentHuggingPriority(.defaultHigh, for: .horizontal) + leadingItemStackView.addArrangedSubview($0) } var trailingOptionSubviews: [UIView] = [walletButton, playlistButton] @@ -309,7 +364,7 @@ class TabLocationView: UIView { addLayoutGuide(urlLayoutGuide) addSubview(contentView) - contentView.addSubview(leadingItemContainerView) + contentView.addSubview(leadingItemStackView) contentView.addSubview(urlDisplayLabel) contentView.addSubview(trailingTabOptionsStackView) contentView.addSubview(placeholderLabel) @@ -325,8 +380,8 @@ class TabLocationView: UIView { $0.trailing.lessThanOrEqualTo(urlLayoutGuide) } - leadingItemContainerView.snp.makeConstraints { - $0.leading.equalToSuperview() + leadingItemStackView.snp.makeConstraints { + $0.leading.equalToSuperview().offset(TabLocationViewUX.spacing * 2) $0.top.bottom.equalToSuperview() } @@ -337,7 +392,7 @@ class TabLocationView: UIView { urlLayoutGuide.snp.makeConstraints { $0.leading.greaterThanOrEqualTo(TabLocationViewUX.spacing * 2) - $0.leading.equalTo(leadingItemContainerView.snp.trailing).priority(.medium) + $0.leading.equalTo(leadingItemStackView.snp.trailing).priority(.medium) $0.trailing.equalTo(trailingTabOptionsStackView.snp.leading) $0.top.bottom.equalTo(self) } @@ -382,8 +437,11 @@ class TabLocationView: UIView { override var accessibilityElements: [Any]? { get { - return [urlDisplayLabel, placeholderLabel, readerModeButton, playlistButton, reloadButton] - .filter { !$0.isHidden } + return [ + urlDisplayLabel, placeholderLabel, readerModeButton, playlistButton, translateButton, + reloadButton, + ] + .filter { !$0.isHidden } } set { super.accessibilityElements = newValue @@ -393,7 +451,7 @@ class TabLocationView: UIView { override func layoutSubviews() { super.layoutSubviews() - secureContentStateButton?.setNeedsUpdateConfiguration() + secureContentStateButton.setNeedsUpdateConfiguration() } private func updateForTraitCollection() { @@ -437,6 +495,8 @@ class TabLocationView: UIView { placeholderLabel.textColor = browserColors.textTertiary readerModeButton.unselectedTintColor = browserColors.iconDefault readerModeButton.selectedTintColor = browserColors.iconActive + translateButton.unselectedTintColor = browserColors.iconDefault + translateButton.selectedTintColor = browserColors.iconActive (urlDisplayLabel as! DisplayURLLabel).clippingFade.gradientLayer.colors = [ browserColors.containerBackground, @@ -510,7 +570,7 @@ class TabLocationView: UIView { voiceSearchButton.isHidden = (url != nil) || !isVoiceSearchAvailable placeholderLabel.isHidden = url != nil urlDisplayLabel.isHidden = url == nil - leadingItemContainerView.isHidden = url == nil + leadingItemStackView.isHidden = url == nil } // MARK: Tap Actions @@ -523,6 +583,10 @@ class TabLocationView: UIView { delegate?.tabLocationViewDidTapPlaylist(self) } + @objc func didTapTranslateButton() { + delegate?.tabLocationViewDidTapTranslateButton(self) + } + @objc func didTapStopReloadButton() { if loading { delegate?.tabLocationViewDidTapStop(self) diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/Toolbars/UrlBar/TopToolbarView.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/Toolbars/UrlBar/TopToolbarView.swift index 0c5e02ef714e..27cef7af3b29 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Browser/Toolbars/UrlBar/TopToolbarView.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/Toolbars/UrlBar/TopToolbarView.swift @@ -21,6 +21,7 @@ protocol TopToolbarDelegate: AnyObject { _ urlBar: TopToolbarView, action: PlaylistURLBarButton.MenuAction ) + func topToolbarDidPressTranslateButton(_ urlBar: TopToolbarView) func topToolbarDidEnterOverlayMode(_ topToolbar: TopToolbarView) func topToolbarDidLeaveOverlayMode(_ topToolbar: TopToolbarView) func topToolbarDidPressScrollToTop(_ topToolbar: TopToolbarView) @@ -56,6 +57,7 @@ class TopToolbarView: UIView, ToolbarProtocol { enum URLBarButton { case wallet case playlist + case translate } // MARK: Internal @@ -126,7 +128,8 @@ class TopToolbarView: UIView, ToolbarProtocol { privateBrowsingManager: privateBrowsingManager ).then { $0.translatesAutoresizingMaskIntoConstraints = false - $0.readerModeState = ReaderModeState.unavailable + $0.readerModeState = .unavailable + $0.translationState = .unavailable $0.delegate = self $0.layer.cornerRadius = UX.textFieldCornerRadius $0.layer.cornerCurve = .continuous @@ -650,6 +653,11 @@ class TopToolbarView: UIView, ToolbarProtocol { updateURLBarButtonsVisibility() } + func updateTranslateButtonState(_ state: TranslateURLBarButton.TranslateState) { + locationView.translationState = state + updateURLBarButtonsVisibility() + } + func updateWalletButtonState(_ state: WalletURLBarButton.ButtonState) { locationView.walletButton.buttonState = state updateURLBarButtonsVisibility() @@ -929,6 +937,10 @@ extension TopToolbarView: TabLocationViewDelegate { delegate?.topToolbarDidPressPlaylistMenuAction(self, action: action) } + func tabLocationViewDidTapTranslateButton(_ tabLocationView: TabLocationView) { + delegate?.topToolbarDidPressTranslateButton(self) + } + func tabLocationViewDidBeginDragInteraction(_ tabLocationView: TabLocationView) { delegate?.topToolbarDidBeginDragInteraction(self) } diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/Toolbars/UrlBar/TranslateURLBarButton.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/Toolbars/UrlBar/TranslateURLBarButton.swift new file mode 100644 index 000000000000..4f58a7836806 --- /dev/null +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/Toolbars/UrlBar/TranslateURLBarButton.swift @@ -0,0 +1,135 @@ +// Copyright 2024 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import DesignSystem +import UIKit + +class TranslateURLBarButton: UIButton { + var selectedTintColor: UIColor? { + didSet { + updateAppearance() + } + } + + var unselectedTintColor: UIColor? { + didSet { + updateAppearance() + } + } + + var imageIcon: UIImage? { + UIImage(braveSystemNamed: "leo.product.translate") + } + + override init(frame: CGRect) { + super.init(frame: frame) + adjustsImageWhenHighlighted = false + setImage(imageIcon, for: .normal) + updateIconSize() + } + + @available(*, unavailable) + required init?(coder aDecoder: NSCoder) { + fatalError() + } + + override func layoutSubviews() { + super.layoutSubviews() + gradientView.frame = bounds + } + + override var isSelected: Bool { + didSet { + updateAppearance() + } + } + + override open var isHighlighted: Bool { + didSet { + updateAppearance() + } + } + + override var tintColor: UIColor! { + didSet { + self.imageView?.tintColor = self.tintColor + } + } + + private func updateAppearance() { + self.tintColor = (isHighlighted || isSelected) ? selectedTintColor : unselectedTintColor + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + updateIconSize() + } + + private func updateIconSize() { + let sizeCategory = traitCollection.toolbarButtonContentSizeCategory + let pointSize = UIFont.preferredFont( + forTextStyle: .body, + compatibleWith: .init(preferredContentSizeCategory: sizeCategory) + ).pointSize + setPreferredSymbolConfiguration( + .init(pointSize: pointSize, weight: .regular, scale: .medium), + forImageIn: .normal + ) + } + + var translateState: TranslateState = .unavailable { + didSet { + switch translateState { + case .unavailable: + self.isEnabled = false + self.isSelected = false + case .available: + self.isEnabled = true + self.isSelected = false + case .pending: + self.isEnabled = true + self.isSelected = false + case .active: + self.isEnabled = true + self.isSelected = true + } + } + } + + private let gradientView = GradientView(braveSystemName: .iconsActive) + + func setOnboardingState(enabled: Bool) { + if enabled { + addSubview(gradientView) + gradientView.frame = bounds + gradientView.mask = imageView + } else { + if let imageView = imageView { + // gradientView.mask = imageView automatically removes the imageView from the button :o! + // So we have to add it back lol + addSubview(imageView) + } + + gradientView.mask = nil + gradientView.removeFromSuperview() + setImage(imageIcon, for: .normal) + updateIconSize() + } + } + + enum TranslateState { + // Page cannot be translated + case unavailable + + // Translation is available, the page hasn't been translated yet + case available + + // Translation is in progress + case pending + + // Translation complete or partially complete + case active + } +} diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/Translate/BraveTranslateSettingsView.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/Translate/BraveTranslateSettingsView.swift new file mode 100644 index 000000000000..f55428c3827e --- /dev/null +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/Translate/BraveTranslateSettingsView.swift @@ -0,0 +1,40 @@ +// Copyright 2024 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import BraveStrings +import BraveUI +import Preferences +import Shared +import SwiftUI + +struct BraveTranslateSettingsView: View { + @ObservedObject + private var translateEnabled = Preferences.Translate.translateEnabled + + var body: some View { + Form { + Section { + Toggle(isOn: $translateEnabled.value) { + Text(Strings.BraveTranslate.settingsTranslateEnabledOptionTitle) + } + .tint(Color.accentColor) + .listRowBackground(Color(.secondaryBraveGroupedBackground)) + } footer: { + Text(Strings.BraveTranslate.settingsTranslateEnabledOptionDescription) + } + } + .listStyle(.insetGrouped) + .listBackgroundColor(Color(UIColor.braveGroupedBackground)) + .navigationTitle(Strings.BraveTranslate.settingsScreenTitle) + } +} + +#if DEBUG +struct BraveTranslateSettingsView_Previews: PreviewProvider { + static var previews: some View { + BraveTranslateSettingsView() + } +} +#endif diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/Translate/TranslateToast.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/Translate/TranslateToast.swift new file mode 100644 index 000000000000..b6ab37642dfe --- /dev/null +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/Translate/TranslateToast.swift @@ -0,0 +1,347 @@ +// Copyright 2024 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import BraveStrings +import BraveUI +import DesignSystem +import SwiftUI + +private struct TranslationOptionsView: View { + @Environment(\.dismiss) + private var dismiss + + @State + private var searchText = "" + + @Binding + var language: Locale.Language? + + var body: some View { + NavigationStack { + List { + ForEach(filteredLanguages, id: \.self) { language in + Button( + action: { + self.language = language + dismiss() + }, + label: { + Text(languageName(for: language)) + } + ) + } + } + .listStyle(.plain) + .navigationTitle(Strings.BraveTranslate.languageSelectionButtonTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(Strings.CancelString) { + dismiss() + } + } + } + } + .searchable( + text: $searchText, + placement: .navigationBarDrawer(displayMode: .always), + prompt: Strings.BraveTranslate.searchInputTitle + ) + } + + private var filteredLanguages: [Locale.Language] { + return searchText.isEmpty + ? languages + : languages.filter({ + languageName(for: $0).localizedCaseInsensitiveContains(searchText) + }) + } + + // TODO: Take from Brave-Core's list + // TODO: Take from Apple's list + // https://github.com/brave/brave-browser/issues/42280 + private var languages: [Locale.Language] { + return [ + "af", // Afrikaans + "ak", // Twi + "am", // Amharic + "ar", // Arabic + "as", // Assamese + "ay", // Aymara + "az", // Azerbaijani + "be", // Belarusian + "bg", // Bulgarian + "bho", // Bhojpuri + "bm", // Bambara + "bn", // Bengali + "bs", // Bosnian + "ca", // Catalan + "ceb", // Cebuano + "ckb", // Kurdish (Sorani) + "co", // Corsican + "cs", // Czech + "cy", // Welsh + "da", // Danish + "de", // German + "doi", // Dogri + "dv", // Dhivehi + "ee", // Ewe + "el", // Greek + "en", // English + "eo", // Esperanto + "es", // Spanish + "et", // Estonian + "eu", // Basque + "fa", // Persian + "fi", // Finnish + "fr", // French + "fy", // Frisian + "ga", // Irish + "gd", // Scots Gaelic + "gl", // Galician + "gom", // Konkani + "gu", // Gujarati + "ha", // Hausa + "haw", // Hawaiian + "hi", // Hindi + "hmn", // Hmong + "hr", // Croatian + "ht", // Haitian Creole + "hu", // Hungarian + "hy", // Armenian + "id", // Indonesian + "ig", // Igbo + "ilo", // Ilocano + "is", // Icelandic + "it", // Italian + "iw", // Hebrew - Chrome uses "he" + "ja", // Japanese + "jw", // Javanese - Chrome uses "jv" + "ka", // Georgian + "kk", // Kazakh + "km", // Khmer + "kn", // Kannada + "ko", // Korean + "kri", // Krio + "ku", // Kurdish + "ky", // Kyrgyz + "la", // Latin + "lb", // Luxembourgish + "lg", // Luganda + "ln", // Lingala + "lo", // Lao + "lt", // Lithuanian + "lus", // Mizo + "lv", // Latvian + "mai", // Maithili + "mg", // Malagasy + "mi", // Maori + "mk", // Macedonian + "ml", // Malayalam + "mn", // Mongolian + "mni-Mtei", // Manipuri (Meitei Mayek) + "mr", // Marathi + "ms", // Malay + "mt", // Maltese + "my", // Burmese + "ne", // Nepali + "nl", // Dutch + "no", // Norwegian - Chrome uses "nb" + "nso", // Sepedi + "ny", // Nyanja + "om", // Oromo + "or", // Odia (Oriya) + "pa", // Punjabi + "pl", // Polish + "ps", // Pashto + "pt", // Portuguese + "qu", // Quechua + "ro", // Romanian + "ru", // Russian + "rw", // Kinyarwanda + "sa", // Sanskrit + "sd", // Sindhi + "si", // Sinhala + "sk", // Slovak + "sl", // Slovenian + "sm", // Samoan + "sn", // Shona + "so", // Somali + "sq", // Albanian + "sr", // Serbian + "st", // Southern Sotho + "su", // Sundanese + "sv", // Swedish + "sw", // Swahili + "ta", // Tamil + "te", // Telugu + "tg", // Tajik + "th", // Thai + "ti", // Tigrinya + "tk", // Turkmen + "tl", // Tagalog - Chrome uses "fil" + "tr", // Turkish + "ts", // Tsonga + "tt", // Tatar + "ug", // Uyghur + "uk", // Ukrainian + "ur", // Urdu + "uz", // Uzbek + "vi", // Vietnamese + "xh", // Xhosa + "yi", // Yiddish + "yo", // Yoruba + "zh-CN", // Chinese (Simplified) + "zh-TW", // Chinese (Traditional) + "zu", // Zulu + ].map({ + Locale.Language.init(identifier: $0) + }) + } + + private func languageName(for language: Locale.Language) -> String { + if let languageCode = language.languageCode?.identifier, + let languageName = Locale.current.localizedString(forLanguageCode: languageCode) + { + return languageName + } + return "Unknown Language" + } +} + +struct TranslateToast: View { + @Environment(\.dismiss) + private var dismiss + + @State + private var showSourceLanguageSelection: Bool = false + + @State + private var showTargetLanguageSelection: Bool = false + + @ObservedObject + var languageInfo: BraveTranslateLanguageInfo + + var onLanguageSelectionChanged: ((BraveTranslateLanguageInfo) -> Void)? + + var body: some View { + HStack { + Image(braveSystemName: "leo.product.translate") + .symbolRenderingMode(.monochrome) + .foregroundStyle( + LinearGradient(braveSystemName: .iconsActive) + ) + .padding(.trailing) + VStack(alignment: .leading) { + Text(Strings.BraveTranslate.pageTranslatedTitle) + .font(.callout.weight(.semibold)) + .foregroundColor(Color(braveSystemName: .textPrimary)) + .padding(.bottom, 8.0) + + HStack { + Button { + showSourceLanguageSelection = true + } label: { + Text( + "\(pageLanguageName)" + ) + .font(.callout) + .foregroundColor(Color(braveSystemName: .textSecondary)) + .padding([.top, .bottom], 4.0) + .padding([.leading, .trailing], 8.0) + .background( + ContainerRelativeShape() + .fill(Color(braveSystemName: .pageBackground)) + ) + .containerShape(RoundedRectangle(cornerRadius: 4.0, style: .continuous)) + } + + Text(Strings.BraveTranslate.translateFromToTitle) + .font(.callout) + .foregroundColor(Color(braveSystemName: .textSecondary)) + + Button { + showTargetLanguageSelection = true + } label: { + Text( + "\(currentLanguageName)" + ) + .font(.callout) + .foregroundColor(Color(braveSystemName: .textSecondary)) + .padding([.top, .bottom], 4.0) + .padding([.leading, .trailing], 8.0) + .background( + ContainerRelativeShape() + .fill(Color(braveSystemName: .pageBackground)) + ) + .containerShape(RoundedRectangle(cornerRadius: 4.0, style: .continuous)) + } + } + } + } + .padding() + .frame(alignment: .leading) + .bravePopup(isPresented: $showSourceLanguageSelection) { + TranslationOptionsView( + language: Binding( + get: { + languageInfo.pageLanguage + }, + set: { + languageInfo.pageLanguage = $0 + onLanguageSelectionChanged?(languageInfo) + } + ) + ) + .onDisappear { + showTargetLanguageSelection = false + } + } + .bravePopup(isPresented: $showTargetLanguageSelection) { + TranslationOptionsView( + language: Binding( + get: { + languageInfo.currentLanguage + }, + set: { + languageInfo.currentLanguage = $0 ?? Locale.current.language + onLanguageSelectionChanged?(languageInfo) + } + ) + ) + .onDisappear { + showSourceLanguageSelection = false + } + } + } + + private var currentLanguageName: String { + if let languageCode = languageInfo.currentLanguage.languageCode?.identifier, + let languageName = Locale.current.localizedString(forLanguageCode: languageCode) + { + return languageName + } + return Strings.BraveTranslate.unknownLanguageTitle + } + + private var pageLanguageName: String { + if let languageCode = languageInfo.pageLanguage?.languageCode?.identifier, + let languageName = Locale.current.localizedString(forLanguageCode: languageCode) + { + return languageName + } + return Strings.BraveTranslate.unknownLanguageTitle + } +} + +extension TranslateToast: PopoverContentComponent { + public var popoverBackgroundColor: UIColor { + .braveBackground + } +} + +#Preview { + TranslateToast(languageInfo: .init(), onLanguageSelectionChanged: nil) +} diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/User Scripts/Debug/Preferences.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/User Scripts/Debug/Preferences.swift index 10d7bd2ce241..746a2dd08dea 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Browser/User Scripts/Debug/Preferences.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/User Scripts/Debug/Preferences.swift @@ -91,5 +91,11 @@ extension Preferences { key: "userscript.preferences.\(UserScriptManager.ScriptType.braveLeoAIChat.rawValue)", default: true ) + + public static let translate = + Option( + key: "userscript.preferences.\(UserScriptManager.ScriptType.braveTranslate.rawValue)", + default: true + ) } } diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/User Scripts/Debug/UserScriptsDebugView.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/User Scripts/Debug/UserScriptsDebugView.swift index 90ad044deda0..54da8c84069a 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Browser/User Scripts/Debug/UserScriptsDebugView.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/User Scripts/Debug/UserScriptsDebugView.swift @@ -52,6 +52,9 @@ struct UserScriptsDebugView: View { @ObservedObject private var leo = Preferences.UserScript.leo + @ObservedObject + private var translate = Preferences.UserScript.translate + var body: some View { List { Section { @@ -89,6 +92,8 @@ struct UserScriptsDebugView: View { Toggle("Youtube Quality", isOn: $youtubeQuality.value) Toggle("Leo/AI-Chat", isOn: $leo.value) + + Toggle("Brave Translate", isOn: $translate.value) } .font(.callout) .tint(Color(braveSystemName: .primary60)) diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/UserScriptManager.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/UserScriptManager.swift index 0c6e9d2145ad..94c22674e1c5 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Browser/UserScriptManager.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/UserScriptManager.swift @@ -128,6 +128,7 @@ class UserScriptManager { case searchResultAd case youtubeQuality case braveLeoAIChat + case braveTranslate fileprivate var script: WKUserScript? { switch self { @@ -172,6 +173,8 @@ class UserScriptManager { ? YoutubeQualityScriptHandler.userScript : nil case .braveLeoAIChat: return Preferences.UserScript.leo.value ? BraveLeoScriptHandler.userScript : nil + case .braveTranslate: + return Preferences.UserScript.translate.value ? BraveTranslateScriptHandler.userScript : nil } } diff --git a/ios/brave-ios/Sources/Brave/Frontend/ClientPreferences.swift b/ios/brave-ios/Sources/Brave/Frontend/ClientPreferences.swift index eda85b4ba783..037f47133bc7 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/ClientPreferences.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/ClientPreferences.swift @@ -356,3 +356,15 @@ extension Preferences { static let npr = Option(key: "website-redirect.npr", default: false) } } + +extension Preferences { + final public class Translate { + /// Determines whether Brave Translate is enabled + public static let translateEnabled = + Option(key: "brave-translate.enabled", default: true) + + /// Determines whether to show Brave Translate onboarding. + public static let translateURLBarOnboardingCount = + Option(key: "brave-translate.url-bar-onboarding-count", default: 0) + } +} diff --git a/ios/brave-ios/Sources/Brave/Frontend/Reader/Reader.html b/ios/brave-ios/Sources/Brave/Frontend/Reader/Reader.html index d8bf8bcff624..8c2cca1a7c8b 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Reader/Reader.html +++ b/ios/brave-ios/Sources/Brave/Frontend/Reader/Reader.html @@ -2,7 +2,7 @@ - + diff --git a/ios/brave-ios/Sources/Brave/Frontend/Settings/SettingsViewController.swift b/ios/brave-ios/Sources/Brave/Frontend/Settings/SettingsViewController.swift index f1606f3be834..42ed5ec4a3a3 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Settings/SettingsViewController.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/Settings/SettingsViewController.swift @@ -391,6 +391,18 @@ class SettingsViewController: TableViewController { ) ) + section.rows.append( + Row( + text: Strings.BraveTranslate.settingsMenuTitle, + selection: { [unowned self] in + let translateSettings = UIHostingController(rootView: BraveTranslateSettingsView()) + self.navigationController?.pushViewController(translateSettings, animated: true) + }, + image: UIImage(braveSystemNamed: "leo.product.translate"), + accessory: .disclosureIndicator + ) + ) + return section } diff --git a/ios/brave-ios/Sources/Brave/Frontend/Share/ShareExtensionHelper.swift b/ios/brave-ios/Sources/Brave/Frontend/Share/ShareExtensionHelper.swift index b96f375038d7..c77a2803b789 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Share/ShareExtensionHelper.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/Share/ShareExtensionHelper.swift @@ -79,6 +79,11 @@ extension BasicMenuActivity.ActivityType { title: Strings.toggleReaderMode, braveSystemImage: "leo.product.speedreader" ) + static let translatePage: Self = .init( + id: "TranslatePage", + title: Strings.translatePage, + braveSystemImage: "leo.product.translate" + ) static let findInPage: Self = .init( id: "FindInPage", title: Strings.findInPage, diff --git a/ios/brave-ios/Sources/Brave/Frontend/UserContent/UserScripts/Scripts_Dynamic/ScriptHandlers/Sandboxed/BraveTranslateScriptHandler.swift b/ios/brave-ios/Sources/Brave/Frontend/UserContent/UserScripts/Scripts_Dynamic/ScriptHandlers/Sandboxed/BraveTranslateScriptHandler.swift new file mode 100644 index 000000000000..3d39571b1c39 --- /dev/null +++ b/ios/brave-ios/Sources/Brave/Frontend/UserContent/UserScripts/Scripts_Dynamic/ScriptHandlers/Sandboxed/BraveTranslateScriptHandler.swift @@ -0,0 +1,234 @@ +// Copyright 2023 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import BraveCore +import Foundation +import NaturalLanguage +import Preferences +import Shared +import WebKit +import os.log + +protocol BraveTranslateScriptHandlerDelegate: NSObject { + func updateTranslateURLBar(tab: Tab?, state: TranslateURLBarButton.TranslateState) + func showTranslateOnboarding(tab: Tab?, completion: @escaping (_ translateEnabled: Bool) -> Void) + func presentToast(tab: Tab?, languageInfo: BraveTranslateLanguageInfo) +} + +class BraveTranslateScriptHandler: NSObject, TabContentScript { + private weak var tab: Tab? + private static var elementScriptTask: Task = downloadElementScript() + + init(tab: Tab) { + self.tab = tab + super.init() + } + + static let namespace = "translate_\(uniqueID)" + static let scriptName = "BraveTranslateScript" + static let scriptId = UUID().uuidString + static let messageHandlerName = "TranslateMessage" + static let scriptSandbox = WKContentWorld.world(name: "BraveTranslateContentWorld") + static let userScript: WKUserScript? = { + guard var script = loadUserScript(named: scriptName) else { + return nil + } + + return WKUserScript( + source: + script + .replacingOccurrences(of: "$", with: namespace) + .replacingOccurrences( + of: "$", + with: kBraveServicesKey + ) + .replacingOccurrences(of: "$", with: messageHandlerName) + .replacingOccurrences( + of: "$", + with: TranslateScript.script ?? "" + ), + injectionTime: .atDocumentEnd, + forMainFrameOnly: true, + in: scriptSandbox + ) + }() + + func userContentController( + _ userContentController: WKUserContentController, + didReceiveScriptMessage message: WKScriptMessage, + replyHandler: @escaping (Any?, String?) -> Void + ) { + + // Setup + + guard let webView = message.webView else { + Logger.module.error("Invalid WebView for Translation") + replyHandler(nil, BraveTranslateError.otherError.rawValue) + return + } + + if !Preferences.Translate.translateEnabled.value { + Logger.module.debug("Translation Disabled") + replyHandler(nil, BraveTranslateError.translateDisabled.rawValue) + return + } + + guard let body = message.body as? [String: Any], + let command = body["command"] as? String + else { + Logger.module.error("Invalid Brave Translate Message") + return + } + + // Processing + + if command == "load_brave_translate_script" { + Task { + let script = try await BraveTranslateScriptHandler.elementScriptTask.value + replyHandler(script, nil) + } + return + } + + if command == "ready" { + Task { @MainActor [weak tab] in + try await + (tab?.translateHelper + as? BraveTranslateTabHelper)?.setupTranslate() + replyHandler(nil, nil) + } + + return + } + + if command == "request" { + Task { @MainActor [weak tab] in + do { + let message = try JSONDecoder().decode( + BraveTranslateSession.RequestMessage.self, + from: JSONSerialization.data(withJSONObject: body, options: .fragmentsAllowed) + ) + + guard let tab = tab, let translateHelper = tab.translateHelper + else { + replyHandler(nil, BraveTranslateError.otherError.rawValue) + return + } + + let (data, response) = try await translateHelper.processTranslationRequest(message) + + replyHandler( + [ + "value": [ + "statusCode": response.statusCode, + "responseType": "", + "response": String(data: data, encoding: .utf8) ?? "", + "headers": response.allHeaderFields.map({ "\($0): \($1)" }).joined( + separator: "\r\n" + ), + ] + ], + nil + ) + } catch { + Logger.module.error("Brave Translate Error: \(error)") + replyHandler(nil, "Translation Error") + } + } + + return + } + + replyHandler(nil, nil) + } + + // MARK: - Private + + private static func downloadElementScript() -> Task { + return Task { + var urlRequest = URLRequest( + url: URL(string: "https://translate.brave.com/static/v1/element.js")! + ) + urlRequest.httpMethod = "GET" + + let session = URLSession(configuration: .ephemeral) + defer { session.finishTasksAndInvalidate() } + let (data, response) = try await session.data(for: urlRequest) + + guard let response = response as? HTTPURLResponse, response.statusCode == 200 else { + throw BraveTranslateError.invalidTranslationResponse + } + + guard let script = String(data: data, encoding: .utf8) else { + throw BraveTranslateError.invalidTranslationResponse + } + + return script + } + } +} + +class BraveTranslateScriptLanguageDetectionHandler: NSObject, TabContentScript { + private weak var tab: Tab? + private static let namespace = "translate_\(uniqueID)" + + init(tab: Tab) { + self.tab = tab + super.init() + } + + static let scriptName = "BraveTranslateLanguageDetectionScript" + static let scriptId = UUID().uuidString + static let messageHandlerName = "LanguageDetectionTextCaptured" + static let scriptSandbox = WKContentWorld.world(name: "BraveTranslateContentWorld") + static let userScript: WKUserScript? = nil + + func userContentController( + _ userContentController: WKUserContentController, + didReceiveScriptMessage message: WKScriptMessage, + replyHandler: @escaping (Any?, String?) -> Void + ) { + + // In the future we'd get this from: components/language/ios/browser/language_detection_java_script_feature.mm + + guard let body = message.body as? [String: Any] else { + Logger.module.error("Invalid Brave Translate Language Detection Message") + return + } + + guard + let translateHelper = tab?.translateHelper + else { + return + } + + do { + let message = try JSONDecoder().decode( + Message.self, + from: JSONSerialization.data(withJSONObject: body, options: .fragmentsAllowed) + ) + + if message.hasNoTranslate { + translateHelper.currentLanguageInfo.pageLanguage = + translateHelper.currentLanguageInfo.currentLanguage + } else { + translateHelper.currentLanguageInfo.pageLanguage = + !message.htmlLang.isEmpty ? Locale.Language(identifier: message.htmlLang) : nil + } + + replyHandler(nil, nil) + } catch { + Logger.module.error("Brave Translate Language Detection Error: \(error)") + replyHandler(nil, "Translation Language Detection Error") + } + } + + private struct Message: Codable { + let frameId: String + let hasNoTranslate: Bool + let htmlLang: String + let httpContentLanguage: String + } +} diff --git a/ios/brave-ios/Sources/Brave/Frontend/UserContent/UserScripts/Scripts_Dynamic/Scripts/Sandboxed/BraveTranslateScript.js b/ios/brave-ios/Sources/Brave/Frontend/UserContent/UserScripts/Scripts_Dynamic/Scripts/Sandboxed/BraveTranslateScript.js new file mode 100644 index 000000000000..c422afaa386e --- /dev/null +++ b/ios/brave-ios/Sources/Brave/Frontend/UserContent/UserScripts/Scripts_Dynamic/Scripts/Sandboxed/BraveTranslateScript.js @@ -0,0 +1,292 @@ +/* Copyright (c) 2024 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +window.__firefox__ = {}; + +Object.defineProperty(window.__firefox__, "$", { + enumerable: false, + configurable: false, + writable: false, + value: { + "useNativeNetworking": true, + "getPageSource": (function() { + return encodeURIComponent(document.documentElement.outerHTML); + }), + "getPageLanguage": (function() { + return document.documentElement.lang; + }), + "getRawPageSource": (function() { + return document.documentElement.outerText; + }), + } +}); + + +// TRANSLATE + +var translateApiKey = '$'; +var gtTimeInfo = { + 'fetchStart': Date.now(), + 'fetchEnd': Date.now() + 1 +}; +var serverParams = ''; +var securityOrigin = 'https://translate.brave.com/'; + + +try { + // components/translate/core/browser/resources/translate.js + $ + + // components/translate/core/browser/translate_script.cc;l=149 + __gCrWeb.translate.installCallbacks(); +} catch (error) { + cr.googleTranslate.onTranslateElementError(error); +} + + +// brave-core/components/translate/core/browser/resources/brave_translate.js + +try { + const useGoogleTranslateEndpoint = false; + const braveTranslateStaticPath = '/static/v1/'; + // securityOrigin is predefined by translate_script.cc. + const securityOriginHost = new URL(securityOrigin).host; + + // A method to rewrite URL in the scripts: + // 1. change the domain to translate.brave.com; + // 2. adjust static paths to use braveTranslateStaticPath. + const rewriteUrl = (url)=>{ + try { + let newURL = new URL(url); + if (newURL.pathname === '/translate_a/t') { + // useGoogleTranslateEndpoint is predefined by translate_script.cc. + // It's used only for local testing to disable the redirection of + // translation requests. + if (useGoogleTranslateEndpoint) { + // Remove API key + newURL.searchParams.set('key', ''); + + // Leave the domain unchanged (translate.googleapis.com). + return newURL.toString(); + } + } else { + // braveTranslateStaticPath is predefined by translate_script.cc. + newURL.pathname = newURL.pathname.replace('/translate_static/', braveTranslateStaticPath); + } + newURL.host = securityOriginHost; + return newURL.toString(); + } catch { + return url; + } + }; + + const emptySvgDataUrl = 'data:image/svg+xml;base64,' + btoa(''); + + // Make replacements in loading .js files. + function processJavascript(text) { + // Replace gen204 telemetry requests with loading an empty svg. + text = text.replaceAll('"//"+po+"/gen204?"+Bo(b)', '"' + emptySvgDataUrl + '"'); + + // Used in the injected elements, that are currently not visible. Replace it + // to hide the loading error in devtools (because of CSP). + text = text.replaceAll('https://www.gstatic.com/images/branding/product/1x/translate_24dp.png', emptySvgDataUrl); + return text; + } + + // Make replacements in loading .css files. + function processCSS(text) { + // Used in the injected elements, that are currently not visible. Replace it + // to hide the loading error in devtools (because of CSP). + text = text.replaceAll('//www.gstatic.com/images/branding/product/2x/translate_24dp.png', emptySvgDataUrl); + return text; + } + + // Used to rewrite urls for XHRs in the translate isolated world + // (primarily for /translate_a/t). + if (window.__firefox__.$.useNativeNetworking) { + const methodProperty = Symbol('method') + const urlProperty = Symbol('url') + const userProperty = Symbol('user') + const passwordProperty = Symbol('password') + const requestHeadersProperty = Symbol('requestHeaders') + + XMLHttpRequest.prototype.getResponseHeader = function(headerName) { + return this[requestHeadersProperty][headerName]; + } + + XMLHttpRequest.prototype.getAllResponseHeaders = function() { + return this[requestHeadersProperty]; + } + + XMLHttpRequest.prototype.realSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader; + XMLHttpRequest.prototype.setRequestHeader = function(header, value) { + if (!this[requestHeadersProperty]) { + this[requestHeadersProperty] = {}; + } + + this[requestHeadersProperty][header] = value; + this.realSetRequestHeader(header, value); + } + + XMLHttpRequest.prototype.realOverrideMimeType = XMLHttpRequest.prototype.overrideMimeType; + XMLHttpRequest.prototype.overrideMimeType = function(mimeType) { + this.realOverrideMimeType(mimeType); + } + + XMLHttpRequest.prototype.realOpen = XMLHttpRequest.prototype.open; + XMLHttpRequest.prototype.open = function(method, url, isAsync=true, user='', password='') { + if (isAsync !== undefined && !isAsync) { + return this.realOpen(method, rewriteUrl(url), isAsync, user, password); + } + + this[methodProperty] = method; + this[urlProperty] = rewriteUrl(url); + this[userProperty] = user; + this[passwordProperty] = password; + return this.realOpen(method, rewriteUrl(url), isAsync, user, password); + } + + XMLHttpRequest.prototype.realSend = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.send = function(body) { + if (this[urlProperty] === undefined) { + return this.realSend(body); + } + + try { + var t = window.webkit; + delete window["webkit"]; + + window.webkit.messageHandlers["TranslateMessage"].postMessage({ + "command": "request", + "method": this[methodProperty] ?? "GET", + "url": this[urlProperty], + "user": this[userProperty] ?? "", + "password": this[passwordProperty] ?? "", + "headers": this[requestHeadersProperty] ?? {}, + "body": body ?? "" + }).then((result) => { + + Object.defineProperties(this, { + readyState: { value: XMLHttpRequest.DONE } // DONE (4) + }); + + if (result.value) { + Object.defineProperties(this, { + status: { value: result.value.statusCode } + }); + + Object.defineProperties(this, { + statusText: { value: "OK" } + }); + + Object.defineProperties(this, { + responseType: { value: result.value.responseType } + }); + + Object.defineProperty(this, 'response', { writable: true }); + Object.defineProperty(this, 'responseText', { writable: true }); + this.responseText = result.value.response; + + switch (result.value.responseType) { + case "arraybuffer": this.response = new ArrayBuffer(result.value.response); + case "json": this.response = JSON.parse(result.value.response); + case "text": this.response = result.value.response; + case "": this.response = result.value.response; + } + } + + this.dispatchEvent(new ProgressEvent('loadstart')); + this.dispatchEvent(new ProgressEvent(result.error ? 'error' : 'load')); + this.dispatchEvent(new ProgressEvent('readystatechange')); + this.dispatchEvent(new ProgressEvent('loadend')); + }); + + window.webkit = t + } catch (e) { + return this.realSend(body); + } + } + } else { + if (typeof XMLHttpRequest.prototype.realOpen === 'undefined') { + XMLHttpRequest.prototype.realOpen = XMLHttpRequest.prototype.open; + XMLHttpRequest.prototype.open = function(method, url, async=true, user='', password='') { + this.realOpen(method, rewriteUrl(url), async, user, password); + } + } + } + + // An overridden version of onLoadJavascript from translate.js, that fetches + // and evaluates secondary scripts (i.e. main.js). + // The differences: + // 1. change url via rewriteUrl(); + // 2. process the loaded code via processJavascript(). + cr.googleTranslate.onLoadJavascript = function(url) { + const xhr = new XMLHttpRequest(); + xhr.open('GET', rewriteUrl(url), true); + xhr.onreadystatechange = function() { + if (this.readyState !== this.DONE) { + return; + } + if (this.status !== 200) { + errorCode = ERROR['SCRIPT_LOAD_ERROR']; + return; + } + + // nosemgrep + new Function(processJavascript(this.responseText)).call(window); + } + xhr.send(); + } + + // The styles to hide root elements that are injected by the scripts in the DOM. + // Currently they are always invisible. The styles are added in case of changes + // in future versions. + const braveExtraStyles = `.goog-te-spinner-pos, #goog-gt-tt {display: none;}` + + // An overridden version of onLoadCSS from translate.js. + // The differences: + // 1. change url via rewriteUrl(); + // 2. process the loaded styles via processCSS(). + // 3. Add braveExtraStyles in the end. + cr.googleTranslate.onLoadCSS = function(url) { + const xhr = new XMLHttpRequest(); + xhr.open('GET', rewriteUrl(url), true); + xhr.onreadystatechange = function() { + if (this.readyState !== this.DONE || this.status !== 200) { + return; + } + + const element = document.createElement('style'); + element.type = 'text/css'; + element.charset = 'UTF-8'; + element.innerText = processCSS(this.responseText) + braveExtraStyles; + document.head.appendChild(element); + } + + xhr.send(); + } +} catch(error) { + cr.googleTranslate.onTranslateElementError(error); +} + + +// Brave Translate + +try { + var oldWebkit = window.webkit; + delete window['webkit']; + window.webkit.messageHandlers["$"].postMessage({ + "command": "load_brave_translate_script" + }).then((script) => { + try { + new Function(script).call(this); + } catch (error) { + cr.googleTranslate.onTranslateElementError(error); + } + }); + window.webkit = oldWebkit; +} catch (error) { + console.error(error); +} diff --git a/ios/brave-ios/Sources/BraveStrings/BraveStrings.swift b/ios/brave-ios/Sources/BraveStrings/BraveStrings.swift index db63a79aec39..7e57e1e621b6 100644 --- a/ios/brave-ios/Sources/BraveStrings/BraveStrings.swift +++ b/ios/brave-ios/Sources/BraveStrings/BraveStrings.swift @@ -2722,6 +2722,13 @@ extension Strings { value: "Cookies and Site Data", comment: "Settings item for clearing cookies and site data" ) + public static let translatePage = NSLocalizedString( + "TranslatePage", + tableName: "BraveShared", + bundle: .module, + value: "Translate", + comment: "Title of an action that allows the user to translate the current web-page to another language" + ) public static let findInPage = NSLocalizedString( "FindInPage", tableName: "BraveShared", @@ -9530,3 +9537,86 @@ extension Strings { ) } } + +extension Strings { + public struct BraveTranslate { + public static let languageSelectionButtonTitle = NSLocalizedString( + "BraveTranslate.languageSelectionButtonTitle", + tableName: "BraveShared", + bundle: .module, + value: "Select Language", + comment: "Title for the button that allows you go to the language selection screen, to select a language you want to translate to/from." + ) + + public static let searchInputTitle = NSLocalizedString( + "BraveTranslate.searchInputTitle", + tableName: "BraveShared", + bundle: .module, + value: "Search", + comment: "Title for the search field in the navigation bar for the translate language selection page. The user can search for a language to translate to or from." + ) + + public static let pageTranslatedTitle = NSLocalizedString( + "BraveTranslate.pageTranslatedTitle", + tableName: "BraveShared", + bundle: .module, + value: "Page Translated", + comment: "Title for a text element that lets the user know the page was translated successfully." + ) + + public static let translateFromToTitle = NSLocalizedString( + "BraveTranslate.translateFromToTitle", + tableName: "BraveShared", + bundle: .module, + value: "To", + comment: "The word 'to', is used in the sentenced: Translate [language-a] to [language-b]" + ) + + public static let unknownLanguageTitle = NSLocalizedString( + "BraveTranslate.unknownLanguageTitle", + tableName: "BraveShared", + bundle: .module, + value: "Unknown Language", + comment: "This text is used as a placeholder for languages when the language detected on the page is unsupported or unknown." + ) + + public static let settingsMenuTitle = NSLocalizedString( + "BraveTranslate.settingsMenuTitle", + tableName: "BraveShared", + bundle: .module, + value: "Brave Translate", + comment: "This text is for the settings menu for all Brave-Translate options." + ) + + public static let settingsScreenTitle = NSLocalizedString( + "BraveTranslate.settingsScreenTitle", + tableName: "BraveShared", + bundle: .module, + value: "Brave Translate", + comment: "This text is for the translate settings screen. It's the title of the settings menu." + ) + + public static let settingsTranslateEnabledOptionTitle = NSLocalizedString( + "BraveTranslate.settingsTranslateEnabledOptionTitle", + tableName: "BraveShared", + bundle: .module, + value: "Translate Enabled", + comment: "This text is for a Toggle that allows the user to enable or disable page translation." + ) + + public static let settingsTranslateEnabledOptionDescription = NSLocalizedString( + "BraveTranslate.settingsTranslateEnabledOptionDescription", + tableName: "BraveShared", + bundle: .module, + value: "When enabled, Brave Translate will automatically detect your page's current language, and display a translate button in the URL bar.", + comment: "This text is for a Toggle that allows the user to enable or disable page translation." + ) + + public static let availableVoiceOverAnnouncement = NSLocalizedString( + "BraveTranslate.availableVoiceOverAnnouncement", + bundle: .module, + value: "Page Translation available", + comment: "Accessibility message e.g. spoken by VoiceOver when page translation becomes available." + ) + } +} diff --git a/ios/brave-ios/Sources/BraveUI/SwiftUI/BravePopup.swift b/ios/brave-ios/Sources/BraveUI/SwiftUI/BravePopup.swift new file mode 100644 index 000000000000..b618aaf85ae6 --- /dev/null +++ b/ios/brave-ios/Sources/BraveUI/SwiftUI/BravePopup.swift @@ -0,0 +1,121 @@ +// Copyright 2024 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Foundation +import Shared +import SwiftUI +import UIKit + +public struct BravePopupViewModifier: ViewModifier +where PopupContent: View { + @Binding var isPresented: Bool + let content: () -> PopupContent + + public func body(content: Content) -> some View { + content + .background( + BravePopupView( + isPresented: self.$isPresented, + content: self.content + ) + ) + } +} + +extension View { + public func bravePopup( + isPresented: Binding, + @ViewBuilder content: @escaping () -> Content + ) -> some View where Content: View { + self.modifier( + BravePopupViewModifier( + isPresented: isPresented, + content: content + ) + ) + } +} + +public struct BravePopupView: UIViewControllerRepresentable { + @Binding var isPresented: Bool + private var content: Content + + public init( + isPresented: Binding, + @ViewBuilder content: () -> Content + ) { + self._isPresented = isPresented + self.content = content() + } + + public func makeUIViewController(context: Context) -> UIViewController { + .init() + } + + public func updateUIViewController(_ uiViewController: UIViewController, context: Context) { + if isPresented { + guard uiViewController.presentedViewController == nil + else { + // The system dismissed our Popup automatically, but never updated our presentation state + // It usually does this if you present another Popup or sheet + // Manually update it + if let controller = context.coordinator.presentedViewController + as? PopupViewController + { + DispatchQueue.main.async { + controller.dismiss(animated: true) { + context.coordinator.presentedViewController = nil + self.isPresented = false + } + } + } else if context.coordinator.presentedViewController != nil { + DispatchQueue.main.async { + isPresented = false + } + } + return + } + + if let parent = uiViewController.parent, !parent.isBeingDismissed { + let controller = PopupViewController(rootView: content, isDismissable: true) + context.coordinator.presentedViewController = controller + + DispatchQueue.main.async { + if KeyboardHelper.defaultHelper.currentState != nil { + UIApplication.shared.sendAction( + #selector(UIResponder.resignFirstResponder), + to: nil, + from: nil, + for: nil + ) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + uiViewController.present(controller, animated: true) + } + } else { + uiViewController.present(controller, animated: true) + } + } + } + } else { + if let presentedViewController = context.coordinator.presentedViewController, + presentedViewController == uiViewController.presentedViewController + { + uiViewController.presentedViewController?.dismiss(animated: true) { + context.coordinator.presentedViewController = nil + self.isPresented = false + } + } + } + } + + public class Coordinator { + weak var presentedViewController: UIViewController? + } + + public func makeCoordinator() -> Coordinator { + Coordinator() + } +} diff --git a/ios/brave-ios/Sources/DesignSystem/Icons/Symbols.xcassets/leo.product.translate.symbolset/Contents.json b/ios/brave-ios/Sources/DesignSystem/Icons/Symbols.xcassets/leo.product.translate.symbolset/Contents.json new file mode 100644 index 000000000000..2f415ce6e4c0 --- /dev/null +++ b/ios/brave-ios/Sources/DesignSystem/Icons/Symbols.xcassets/leo.product.translate.symbolset/Contents.json @@ -0,0 +1,11 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "idiom" : "universal" + } + ] +} diff --git a/ios/brave-ios/Sources/Onboarding/Assets/Images.xcassets/Translate/Contents.json b/ios/brave-ios/Sources/Onboarding/Assets/Images.xcassets/Translate/Contents.json new file mode 100644 index 000000000000..73c00596a7fc --- /dev/null +++ b/ios/brave-ios/Sources/Onboarding/Assets/Images.xcassets/Translate/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/brave-ios/Sources/Onboarding/Assets/Images.xcassets/Translate/translate-onboarding-icon.imageset/Contents.json b/ios/brave-ios/Sources/Onboarding/Assets/Images.xcassets/Translate/translate-onboarding-icon.imageset/Contents.json new file mode 100644 index 000000000000..3c9144e597d9 --- /dev/null +++ b/ios/brave-ios/Sources/Onboarding/Assets/Images.xcassets/Translate/translate-onboarding-icon.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Image@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Image@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/brave-ios/Sources/Onboarding/Assets/Images.xcassets/Translate/translate-onboarding-icon.imageset/Image@2x.png b/ios/brave-ios/Sources/Onboarding/Assets/Images.xcassets/Translate/translate-onboarding-icon.imageset/Image@2x.png new file mode 100644 index 000000000000..9f02c04b7369 Binary files /dev/null and b/ios/brave-ios/Sources/Onboarding/Assets/Images.xcassets/Translate/translate-onboarding-icon.imageset/Image@2x.png differ diff --git a/ios/brave-ios/Sources/Onboarding/Assets/Images.xcassets/Translate/translate-onboarding-icon.imageset/Image@3x.png b/ios/brave-ios/Sources/Onboarding/Assets/Images.xcassets/Translate/translate-onboarding-icon.imageset/Image@3x.png new file mode 100644 index 000000000000..5c893ae7bc5a Binary files /dev/null and b/ios/brave-ios/Sources/Onboarding/Assets/Images.xcassets/Translate/translate-onboarding-icon.imageset/Image@3x.png differ diff --git a/ios/brave-ios/Sources/Onboarding/Callouts/OnboardingTranslateView.swift b/ios/brave-ios/Sources/Onboarding/Callouts/OnboardingTranslateView.swift new file mode 100644 index 000000000000..019bef406c69 --- /dev/null +++ b/ios/brave-ios/Sources/Onboarding/Callouts/OnboardingTranslateView.swift @@ -0,0 +1,107 @@ +// Copyright 2024 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import BraveShared +import BraveUI +import DesignSystem +import Foundation +import Strings +import SwiftUI + +public struct OnboardingTranslateView: View { + @Environment(\.dismiss) private var dismiss + + private var onContinueButtonPressed: (() -> Void)? + private var onDisableFeature: (() -> Void)? + + public init(onContinueButtonPressed: (() -> Void)? = nil, onDisableFeature: (() -> Void)? = nil) { + self.onContinueButtonPressed = onContinueButtonPressed + self.onDisableFeature = onDisableFeature + } + + public var body: some View { + ScrollView(.vertical) { + VStack(spacing: 0.0) { + Image("translate-onboarding-icon", bundle: .module) + .padding(.vertical, 24.0) + + Text(Strings.BraveTranslateOnboarding.translateTitle) + .font(.callout.weight(.semibold)) + .foregroundColor(Color(braveSystemName: .textPrimary)) + .padding(.bottom) + + Text(Strings.BraveTranslateOnboarding.translateDescription) + .font(.callout) + .foregroundColor(Color(braveSystemName: .textSecondary)) + .padding(.bottom, 24.0) + + Button( + action: { + dismiss() + onContinueButtonPressed?() + }, + label: { + Text(Strings.OBContinueButton) + .font(.body.weight(.semibold)) + .padding() + .frame(maxWidth: .infinity) + .foregroundStyle(Color(braveSystemName: .schemesOnPrimary)) + .background( + ContainerRelativeShape() + .fill(Color(braveSystemName: .buttonBackground)) + ) + .containerShape(RoundedRectangle(cornerRadius: 8.0, style: .continuous)) + } + ) + .buttonStyle(.plain) + .padding(.bottom) + + Button( + action: { + dismiss() + onDisableFeature?() + }, + label: { + Text(Strings.BraveTranslateOnboarding.disableTranslateButtonTitle) + .font(.body.weight(.semibold)) + .padding() + .frame(maxWidth: .infinity) + .foregroundStyle(Color(braveSystemName: .textSecondary)) + } + ) + .buttonStyle(.plain) + } + .padding(24.0) + } + .multilineTextAlignment(.center) + .osAvailabilityModifiers { content in + if #available(iOS 16.4, *) { + content + .scrollBounceBehavior(.basedOnSize) + } else { + content + .introspectScrollView { scrollView in + scrollView.alwaysBounceVertical = false + } + } + } + } +} + +extension OnboardingTranslateView: PopoverContentComponent { + public var popoverBackgroundColor: UIColor { + .braveBackground + } +} + +#if DEBUG +struct OnboardingTranslateView_PreviewProvider: PreviewProvider { + static var previews: some View { + OnboardingTranslateView() + .fixedSize(horizontal: false, vertical: true) + .previewLayout(.sizeThatFits) + } +} +#endif diff --git a/ios/brave-ios/Sources/Onboarding/OnboardingStrings.swift b/ios/brave-ios/Sources/Onboarding/OnboardingStrings.swift index a7573da98673..783b98b122df 100644 --- a/ios/brave-ios/Sources/Onboarding/OnboardingStrings.swift +++ b/ios/brave-ios/Sources/Onboarding/OnboardingStrings.swift @@ -157,3 +157,32 @@ extension Strings { ) } } + +extension Strings { + struct BraveTranslateOnboarding { + public static let translateTitle = NSLocalizedString( + "BraveTranslateOnboarding.translateTitle", + bundle: .module, + value: "Page Translations", + comment: + "A text element that explains we're translating pages." + ) + + public static let translateDescription = NSLocalizedString( + "BraveTranslateOnboarding.translateDescription", + bundle: .module, + value: + "Pages can be translated to languages supported by your iOS device. You may be required to configure languages if this is your first time using this feature.", + comment: + "A text element that describes the translation feature and explains that hte user might have to download or configure languages on their device." + ) + + public static let disableTranslateButtonTitle = NSLocalizedString( + "BraveTranslateOnboarding.disableTranslateButtonTitle", + bundle: .module, + value: "Disable This Feature", + comment: + "A button that allows the user to disable the brave translate feature. When pressed, the page will not be automatically translated to the user's language." + ) + } +} diff --git a/ios/brave-ios/Tests/ClientTests/TabManagerTests.swift b/ios/brave-ios/Tests/ClientTests/TabManagerTests.swift index 7a1e633893f7..4543421fc41d 100644 --- a/ios/brave-ios/Tests/ClientTests/TabManagerTests.swift +++ b/ios/brave-ios/Tests/ClientTests/TabManagerTests.swift @@ -119,6 +119,10 @@ open class MockTabManagerDelegate: TabManagerDelegate { private let privateBrowsingManager = PrivateBrowsingManager() private let testWindowId = UUID() + override class func setUp() { + Preferences.UserScript.translate.value = false + } + override func setUp() { super.setUp() diff --git a/ios/brave-ios/Tests/ClientTests/User Scripts/ScriptExecutionTests.swift b/ios/brave-ios/Tests/ClientTests/User Scripts/ScriptExecutionTests.swift index 016e7ab559ca..59ab063cd526 100644 --- a/ios/brave-ios/Tests/ClientTests/User Scripts/ScriptExecutionTests.swift +++ b/ios/brave-ios/Tests/ClientTests/User Scripts/ScriptExecutionTests.swift @@ -5,6 +5,7 @@ import BraveCore import CryptoKit +import Preferences import SnapKit import WebKit import XCTest @@ -35,6 +36,10 @@ final class ScriptExecutionTests: XCTestCase { let localFrameElement: Bool } + override class func setUp() { + Preferences.UserScript.translate.value = false + } + @MainActor func testSiteStateListenerScript() async throws { // Given let viewController = MockScriptsViewController() diff --git a/ios/browser/BUILD.gn b/ios/browser/BUILD.gn index 09a14bbe525d..bb4ebbad8468 100644 --- a/ios/browser/BUILD.gn +++ b/ios/browser/BUILD.gn @@ -40,6 +40,7 @@ source_set("browser") { "api/session_restore", "api/storekit_receipt", "api/sync", + "api/translate", "api/unicode", "api/url", "api/version_info", diff --git a/ios/browser/api/translate/BUILD.gn b/ios/browser/api/translate/BUILD.gn new file mode 100644 index 000000000000..817251cee98a --- /dev/null +++ b/ios/browser/api/translate/BUILD.gn @@ -0,0 +1,29 @@ +# Copyright (c) 2024 The Brave Authors. All rights reserved. +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at https://mozilla.org/MPL/2.0/. + +import("//build/config/ios/rules.gni") +import("//ios/build/config.gni") + +source_set("translate") { + sources = [ + "translate_script.h", + "translate_script.mm", + ] + + deps = [ + "//base", + "//components/language/ios/browser:browser", + "//components/resources:components_resources_grit", + "//components/translate/ios/browser", + "//ios/web/annotations:annotations", + "//ios/web/js_messaging", + "//ios/web/navigation:navigation_feature", + "//ios/web/public/js_messaging:js_messaging", + "//ios/web/text_fragments:text_fragments", + "//ui/base:base", + ] + + frameworks = [ "Foundation.framework" ] +} diff --git a/ios/browser/api/translate/DEPS b/ios/browser/api/translate/DEPS new file mode 100644 index 000000000000..28d8da6e9bac --- /dev/null +++ b/ios/browser/api/translate/DEPS @@ -0,0 +1,5 @@ +include_rules = [ + "+ios/web/annotations/annotations_java_script_feature.h", + "+ios/web/navigation/navigation_java_script_feature.h", + "+ios/web/text_fragments/text_fragments_java_script_feature.h", +] diff --git a/ios/browser/api/translate/headers.gni b/ios/browser/api/translate/headers.gni new file mode 100644 index 000000000000..cb5ed45d25ed --- /dev/null +++ b/ios/browser/api/translate/headers.gni @@ -0,0 +1,7 @@ +# Copyright (c) 2024 The Brave Authors. All rights reserved. +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at https://mozilla.org/MPL/2.0/. + +browser_api_translate_public_headers = + [ "//brave/ios/browser/api/translate/translate_script.h" ] diff --git a/ios/browser/api/translate/translate_script.h b/ios/browser/api/translate/translate_script.h new file mode 100644 index 000000000000..daa929daf465 --- /dev/null +++ b/ios/browser/api/translate/translate_script.h @@ -0,0 +1,16 @@ +/* Copyright (c) 2024 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +#ifndef BRAVE_IOS_BROWSER_API_TRANSLATE_TRANSLATE_SCRIPT_H_ +#define BRAVE_IOS_BROWSER_API_TRANSLATE_TRANSLATE_SCRIPT_H_ + +#include + +OBJC_EXPORT +@interface TranslateScript : NSObject +@property(nonatomic, class, readonly) NSString* script; +@end + +#endif // BRAVE_IOS_BROWSER_API_TRANSLATE_TRANSLATE_SCRIPT_H_ diff --git a/ios/browser/api/translate/translate_script.mm b/ios/browser/api/translate/translate_script.mm new file mode 100644 index 000000000000..054219f8f6c9 --- /dev/null +++ b/ios/browser/api/translate/translate_script.mm @@ -0,0 +1,75 @@ +/* Copyright (c) 2024 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +#include "brave/ios/browser/api/translate/translate_script.h" + +#include "base/strings/sys_string_conversions.h" +#include "components/grit/components_resources.h" +#include "components/language/ios/browser/language_detection_java_script_feature.h" +#include "components/translate/ios/browser/translate_java_script_feature.h" +#include "ios/web/annotations/annotations_java_script_feature.h" +#include "ios/web/js_messaging/page_script_util.h" +#include "ios/web/js_messaging/web_frames_manager_java_script_feature.h" +#include "ios/web/navigation/navigation_java_script_feature.h" +#include "ios/web/public/js_messaging/java_script_feature.h" +#include "ios/web/public/js_messaging/java_script_feature_util.h" +#include "ios/web/text_fragments/text_fragments_java_script_feature.h" +#include "ui/base/resource/resource_bundle.h" + +@implementation TranslateScript + ++ (NSString*)script { + // Dependencies + const web::JavaScriptFeature* features[] = { + web::java_script_features::GetBaseJavaScriptFeature(), + web::java_script_features::GetCommonJavaScriptFeature(), + web::java_script_features::GetMessageJavaScriptFeature(), + web::NavigationJavaScriptFeature::GetInstance(), + web::TextFragmentsJavaScriptFeature::GetInstance(), + web::AnnotationsJavaScriptFeature::GetInstance(), + language::LanguageDetectionJavaScriptFeature::GetInstance()}; + + // components/translate/core/browser/resources/translate.js + // components/translate/core/browser/translate_script.cc;l=145 + std::string translate_js = + ui::ResourceBundle::GetSharedInstance().LoadDataResourceString( + IDR_TRANSLATE_JS); + + // Translate feature + const auto* translate_feature = + translate::TranslateJavaScriptFeature::GetInstance(); + + NSMutableString* result = [[NSMutableString alloc] init]; + + for (const auto* feature : features) { + if (feature) { + std::vector scripts = + feature->GetScripts(); + for (const auto& script : scripts) { + NSString* content = script.GetScriptString(); + if (content && [content length]) { + [result appendString:content]; + } + } + }; + } + + // Append translate.js + [result appendString:base::SysUTF8ToNSString(translate_js)]; + + // Append translate_ios.js + if (translate_feature) { + for (const auto& script : translate_feature->GetScripts()) { + NSString* content = script.GetScriptString(); + if (content && [content length]) { + [result appendString:content]; + } + } + } + + return [result copy]; +} + +@end diff --git a/ios/browser/api/web/ui/BUILD.gn b/ios/browser/api/web/ui/BUILD.gn index 38fb5f742cfa..04849e8fefce 100644 --- a/ios/browser/api/web/ui/BUILD.gn +++ b/ios/browser/api/web/ui/BUILD.gn @@ -20,6 +20,10 @@ source_set("ui") { "//ios/chrome/browser/shared/model/profile", "//ios/components/webui:url_constants", "//ios/web/js_messaging:js_messaging", + "//ios/web/public/js_messaging:frame_id", + "//ios/web/public/js_messaging:gcrweb", + "//ios/web/public/js_messaging:gcrweb_js", + "//ios/web/public/js_messaging:util_scripts", "//ios/web/public/thread", "//ios/web/public/webui", "//ios/web/web_state", diff --git a/ios/nala/BUILD.gn b/ios/nala/BUILD.gn index 5d41fc8d44a7..246ea7d1af1c 100644 --- a/ios/nala/BUILD.gn +++ b/ios/nala/BUILD.gn @@ -171,6 +171,7 @@ nala_icons = [ "leo.product.private-window.svg", "leo.product.speedreader.svg", "leo.product.sync.svg", + "leo.product.translate.svg", "leo.product.vpn.svg", "leo.qr.code.svg", "leo.radio.unchecked.svg",