diff --git a/ios/BUILD.gn b/ios/BUILD.gn index 849c28bbfa8f..d409b4c6863c 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/string/headers.gni") import("//brave/ios/browser/api/url/headers.gni") import("//brave/ios/browser/api/url_sanitizer/headers.gni") import("//brave/ios/browser/api/web/ui/headers.gni") @@ -127,6 +128,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_string_public_headers action("brave_core_umbrella_header") { script = "//build/config/ios/generate_umbrella_header.py" diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/Toolbars/BottomToolbar/Menu/History/HistoryView.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/Toolbars/BottomToolbar/Menu/History/HistoryView.swift index 9b002b718173..2c3d35ba9b35 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Browser/Toolbars/BottomToolbar/Menu/History/HistoryView.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/Toolbars/BottomToolbar/Menu/History/HistoryView.swift @@ -44,13 +44,10 @@ struct HistoryItemView: View { forDisplayOmitSchemePathAndTrivialSubdomains: url.absoluteString ) ) - .truncationMode(.tail) .font(.footnote) .frame(maxWidth: .infinity, alignment: .leading) .fixedSize(horizontal: false, vertical: true) .foregroundStyle(Color(braveSystemName: .textSecondary)) - .environment(\.layoutDirection, .leftToRight) - .flipsForRightToLeftLayoutDirection(false) } } } diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/Toolbars/PageSecurityView.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/Toolbars/PageSecurityView.swift index 5200425f9410..a2597530763d 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Browser/Toolbars/PageSecurityView.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/Toolbars/PageSecurityView.swift @@ -4,6 +4,7 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. import BraveCore +import BraveShared import BraveUI import Foundation import Shared @@ -63,9 +64,12 @@ struct PageSecurityView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 16) { - Text(displayURL) + URLElidedText(text: displayURL) .font(.headline) .foregroundStyle(Color(braveSystemName: .textPrimary)) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + HStack(alignment: .firstTextBaseline) { warningIcon VStack(alignment: .leading, spacing: 4) { 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 0a478e268e13..89000885dfc1 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 @@ -3,6 +3,7 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. import BraveCore +import BraveShared import BraveStrings import Combine import DesignSystem @@ -456,7 +457,7 @@ class TabLocationView: UIView { if let url = url { if let internalURL = InternalURL(url), internalURL.isBasicAuthURL { - urlDisplayLabel.isWebScheme = false + urlDisplayLabel.isLeftToRight = true urlDisplayLabel.text = Strings.PageSecurityView.signIntoWebsiteURLBarTitle } else { // Matches LocationBarModelImpl::GetFormattedURL in Chromium (except for omitHTTP) @@ -466,21 +467,42 @@ class TabLocationView: UIView { // If we can't parse the origin and the URL can't be classified via AutoCompleteClassifier // the URL is likely a broken deceptive URL. Example: `about:blank#https://apple.com` if URLOrigin(url: url).url == nil && URIFixup.getURL(url.absoluteString) == nil { - urlDisplayLabel.isWebScheme = false + urlDisplayLabel.isLeftToRight = true urlDisplayLabel.text = "" } else { - urlDisplayLabel.isWebScheme = ["http", "https"].contains(url.scheme ?? "") urlDisplayLabel.text = URLFormatter.formatURL( URLOrigin(url: url).url?.absoluteString ?? url.absoluteString, formatTypes: [ - .trimAfterHost, .omitHTTPS, .omitTrivialSubdomains, + .omitDefaults, .trimAfterHost, .omitHTTPS, .omitTrivialSubdomains, ], unescapeOptions: .normal ) + + // The direction the string will be rendered (this happens regardless of locale!!!) + // In a LTR environment, Arabic will always render RTL + // The dominant charset based on unicode's bidirection algorithm + // with strong L and strong R determines how a string will be rendered + let isLTRRendered = url.isLTRRendered + + // Determine if the URL has BOTH LTR and RTL characters. + // Very likely a malicious URL, but not always! + // URLs like: m5155.xn--mgbaiqly6b2eg.xn--ngbc5azd/ are innocent + let isMixedCharset = url.hasMixedBidiDirection + + var isLTR = isLTRRendered && !isMixedCharset + if isMixedCharset { + // FORCE LTR - ETLD are always the right most portion after the dot. + // We will force render the URL in LTR mode, so we should force clip on the left (sub-domain). + // RTL rendered domains will clip from the right side (sub-domain) and the ETLD will be rendered on the left. + isLTR = true + } + + urlDisplayLabel.isLeftToRight = + !["http", "https"].contains(url.scheme ?? "") || !isLTR } } } else { - urlDisplayLabel.isWebScheme = false + urlDisplayLabel.isLeftToRight = true urlDisplayLabel.text = "" } @@ -548,9 +570,10 @@ private class DisplayURLLabel: UILabel { } private var textSize: CGSize = .zero - private var isRightToLeft: Bool = false - fileprivate var isWebScheme: Bool = false { + fileprivate var isLeftToRight: Bool = true { didSet { + updateText() + updateTextSize() updateClippingDirection() setNeedsLayout() setNeedsDisplay() @@ -570,13 +593,24 @@ private class DisplayURLLabel: UILabel { if oldValue != text { updateText() updateTextSize() - detectLanguageForNaturalDirectionClipping() updateClippingDirection() } setNeedsDisplay() } } + private func updateTextSize() { + textSize = attributedText?.size() ?? .zero + setNeedsLayout() + setNeedsDisplay() + } + + private func updateClippingDirection() { + // Update clipping fade direction + clippingFade.gradientLayer.startPoint = .init(x: isLeftToRight ? 1 : 0, y: 0.5) + clippingFade.gradientLayer.endPoint = .init(x: isLeftToRight ? 0 : 1, y: 0.5) + } + private func updateText() { if let text = text { // Without attributed string, the label will always render RTL characters even if you force LTR layout. @@ -597,28 +631,6 @@ private class DisplayURLLabel: UILabel { } } - private func updateTextSize() { - textSize = attributedText?.size() ?? .zero - setNeedsLayout() - setNeedsDisplay() - } - - private func detectLanguageForNaturalDirectionClipping() { - guard let text, let language = NLLanguageRecognizer.dominantLanguage(for: text) else { return } - switch language { - case .arabic, .hebrew, .persian, .urdu: - isRightToLeft = true - default: - isRightToLeft = false - } - } - - private func updateClippingDirection() { - // Update clipping fade direction - clippingFade.gradientLayer.startPoint = .init(x: isRightToLeft || !isWebScheme ? 1 : 0, y: 0.5) - clippingFade.gradientLayer.endPoint = .init(x: isRightToLeft || !isWebScheme ? 0 : 1, y: 0.5) - } - @available(*, unavailable) required init(coder: NSCoder) { fatalError() @@ -637,7 +649,7 @@ private class DisplayURLLabel: UILabel { super.layoutSubviews() clippingFade.frame = .init( - x: isRightToLeft || !isWebScheme ? bounds.width - 20 : 0, + x: isLeftToRight ? bounds.width - 20 : 0, y: 0, width: 20, height: bounds.height @@ -651,7 +663,7 @@ private class DisplayURLLabel: UILabel { var rect = rect if textSize.width > bounds.width { let delta = (textSize.width - bounds.width) - if !isRightToLeft && isWebScheme { + if !isLeftToRight { rect.origin.x -= delta rect.size.width += delta } diff --git a/ios/brave-ios/Sources/BraveShared/Extensions/URLExtensions.swift b/ios/brave-ios/Sources/BraveShared/Extensions/URLExtensions.swift index e7b7173bda71..e0996a5c265b 100644 --- a/ios/brave-ios/Sources/BraveShared/Extensions/URLExtensions.swift +++ b/ios/brave-ios/Sources/BraveShared/Extensions/URLExtensions.swift @@ -136,6 +136,56 @@ extension URL { return URL(string: "\(baseURL)?\(InternalURL.Param.url.rawValue)=\(encodedURL)") } + + public var hasMixedBidiDirection: Bool { + // First format the URL which will decode the puny-coding + let scheme = scheme ?? "http" + var renderedString = URLFormatter.formatURL( + absoluteString, + formatTypes: [.omitDefaults, .omitTrivialSubdomains, .omitTrailingSlashOnBareHostname], + unescapeOptions: .normal + ) + + // Strip prefixes + if let range = renderedString.range(of: "^(www|mobile|m)\\.", options: .regularExpression) { + renderedString.replaceSubrange(range, with: "") + } + + // Strip scheme + if let range = renderedString.range( + of: "^(\(scheme)://|\(scheme):)", + options: .regularExpression + ) { + renderedString.replaceSubrange(range, with: "") + } + + return renderedString.bidiDirection == .MIXED + } + + public var isLTRRendered: Bool { + // First format the URL which will decode the puny-coding + let scheme = scheme ?? "http" + var renderedString = URLFormatter.formatURL( + absoluteString, + formatTypes: [.omitDefaults, .omitTrivialSubdomains, .omitTrailingSlashOnBareHostname], + unescapeOptions: .normal + ) + + // Strip prefixes + if let range = renderedString.range(of: "^(www|mobile|m)\\.", options: .regularExpression) { + renderedString.replaceSubrange(range, with: "") + } + + // Strip scheme + if let range = renderedString.range( + of: "^(\(scheme)://|\(scheme):)", + options: .regularExpression + ) { + renderedString.replaceSubrange(range, with: "") + } + + return renderedString.bidiBaseDirection == .LTR + } } extension InternalURL { diff --git a/ios/brave-ios/Sources/BraveShared/URLElidedTextView.swift b/ios/brave-ios/Sources/BraveShared/URLElidedTextView.swift index e84f543012cb..c83e5cb63a00 100644 --- a/ios/brave-ios/Sources/BraveShared/URLElidedTextView.swift +++ b/ios/brave-ios/Sources/BraveShared/URLElidedTextView.swift @@ -37,5 +37,8 @@ public struct URLElidedText: View { attributes: .init([.font: font ?? .body, .paragraphStyle: paragraphStyle]) ) ) + .truncationMode(.tail) + .environment(\.layoutDirection, .leftToRight) + .flipsForRightToLeftLayoutDirection(false) } } diff --git a/ios/brave-ios/Tests/ClientTests/URLFormatTests.swift b/ios/brave-ios/Tests/ClientTests/URLFormatTests.swift index 239372b04691..a655975ea7b4 100644 --- a/ios/brave-ios/Tests/ClientTests/URLFormatTests.swift +++ b/ios/brave-ios/Tests/ClientTests/URLFormatTests.swift @@ -4,6 +4,7 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. import BraveCore +import BraveShared import Shared import UIKit import XCTest @@ -12,6 +13,10 @@ import XCTest @MainActor class URLFormatTests: XCTestCase { + override class func setUp() { + BraveCoreMain.initializeICUForTesting() + } + func testURIFixup() { // Check valid URLs. We can load these after some fixup. checkValidURL("about:", afterFixup: "chrome://version/") @@ -195,6 +200,136 @@ import XCTest ) } + func testSecurityLTRAndRTLRendering() { + XCTAssertTrue(URL(string: "https://brave.com")!.isLTRRendered) + XCTAssertTrue( + URL( + string: + "https://long-extended-subdomain-name-containing-many-letters-and-dashes.badssl.com/" + )!.isLTRRendered + ) + XCTAssertTrue( + URL( + string: + "https://longextendedsubdomainnamewithoutdashesinordertotestwordwrapping.badssl.com/" + )!.isLTRRendered + ) + + XCTAssertTrue( + URL( + string: + "blob:https://pwr.wtf/58f713aa-fa8f-4651-ac2e-e68d2a4c5ef4#?x#https://www.account.apple.com" + )!.isLTRRendered + ) + XCTAssertTrue(URL(string: "about:blank%23https://accounts.google.com")!.isLTRRendered) + XCTAssertFalse( + URL(string: "https://xn--llb.login.wwww.accounts.google.com.xn--llb.pwr.wtf/")!.isLTRRendered + ) + XCTAssertTrue(URL(string: "about:blank%23https://accounts.google.com")!.isLTRRendered) + + XCTAssertTrue( + URL(string: "https://com.xn--mgbh0fb.xn--mgberp4a5d4ar/%D9%A0/1100068049663")!.isLTRRendered + ) + XCTAssertTrue( + URL( + string: + "https://com.facebook.verylongsubdomainpadding.xn--mgbh0fb.xn--mgberp4a5d4ar/%D9%A0/1100068049663" + )!.isLTRRendered + ) + XCTAssertFalse(URL(string: "https://xn--mgbh0fb.xn--mgberp4a5d4ar/")!.isLTRRendered) + XCTAssertFalse( + URL( + string: + "https://xn--mgbh0fb.xn--mgbh0fb.xn--mgbh0fb.xn--mgbh0fb.xn--mgbh0fb.xn--mgbh0fb.xn--mgbh0fb.xn--mgbh0fb.xn--mgbh0fb.xn--mgbh0fb.xn--mgberp4a5d4ar/" + )!.isLTRRendered + ) + + XCTAssertFalse( + URL( + string: "http://xn--mgbaaaaaaaaaaaaaaaaaaaaa.login.google.com.xn--ngbof4hb.xn--ngbc5azd/" + )!.isLTRRendered + ) + } + + func testSecurityDisplay() { + let formatURL = { (url: URL) -> String in + return URLFormatter.formatURL( + URLOrigin(url: url).url?.absoluteString ?? url.absoluteString, + formatTypes: [ + .omitDefaults, .trimAfterHost, .omitHTTPS, .omitTrivialSubdomains, + ], + unescapeOptions: .normal + ) + } + + XCTAssertEqual(formatURL(URL(string: "https://brave.com")!), "brave.com") + XCTAssertEqual( + formatURL( + URL( + string: + "https://long-extended-subdomain-name-containing-many-letters-and-dashes.badssl.com/" + )! + ), + "long-extended-subdomain-name-containing-many-letters-and-dashes.badssl.com" + ) + XCTAssertEqual( + formatURL( + URL( + string: + "https://longextendedsubdomainnamewithoutdashesinordertotestwordwrapping.badssl.com/" + )! + ), + "longextendedsubdomainnamewithoutdashesinordertotestwordwrapping.badssl.com" + ) + XCTAssertEqual( + formatURL( + URL( + string: + "https://pwr.wtf/58f713aa-fa8f-4651-ac2e-e68d2a4c5ef4#?x#https://www.account.apple.com" + )! + ), + "pwr.wtf" + ) + XCTAssertEqual( + formatURL(URL(string: "https://xn--llb.login.wwww.accounts.google.com.xn--llb.pwr.wtf/")!), + "ە.login.wwww.accounts.google.com.ە.pwr.wtf" + ) + XCTAssertEqual( + formatURL(URL(string: "https://com.xn--mgbh0fb.xn--mgberp4a5d4ar/%D9%A0/1100068049663")!), + "com.مثال.السعودية" + ) + XCTAssertEqual( + formatURL( + URL( + string: + "https://com.facebook.verylongsubdomainpadding.xn--mgbh0fb.xn--mgberp4a5d4ar/%D9%A0/1100068049663" + )! + ), + "com.facebook.verylongsubdomainpadding.مثال.السعودية" + ) + XCTAssertEqual( + formatURL(URL(string: "https://xn--mgbh0fb.xn--mgberp4a5d4ar/")!), + "مثال.السعودية" + ) + XCTAssertEqual( + formatURL( + URL( + string: + "https://xn--mgbh0fb.xn--mgbh0fb.xn--mgbh0fb.xn--mgbh0fb.xn--mgbh0fb.xn--mgbh0fb.xn--mgbh0fb.xn--mgbh0fb.xn--mgbh0fb.xn--mgbh0fb.xn--mgberp4a5d4ar/" + )! + ), + "مثال.مثال.مثال.مثال.مثال.مثال.مثال.مثال.مثال.مثال.السعودية" + ) + XCTAssertEqual( + formatURL( + URL( + string: "http://xn--mgbaaaaaaaaaaaaaaaaaaaaa.login.google.com.xn--ngbof4hb.xn--ngbc5azd/" + )! + ), + "اااااااااااااااااااااا.login.google.com.بريدي.شبكة" + ) + } + fileprivate func checkValidURL(_ beforeFixup: String, afterFixup: String) { XCTAssertEqual(URIFixup.getURL(beforeFixup)!.absoluteString, afterFixup) } diff --git a/ios/browser/BUILD.gn b/ios/browser/BUILD.gn index fa037761dfc3..5dd2b3b78cc0 100644 --- a/ios/browser/BUILD.gn +++ b/ios/browser/BUILD.gn @@ -39,6 +39,7 @@ source_set("browser") { "api/password", "api/session_restore", "api/storekit_receipt", + "api/string", "api/sync", "api/url", "api/version_info", diff --git a/ios/browser/api/string/BUILD.gn b/ios/browser/api/string/BUILD.gn new file mode 100644 index 000000000000..094d23975ad6 --- /dev/null +++ b/ios/browser/api/string/BUILD.gn @@ -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/. + +source_set("string") { + sources = [ + "string+utils.h", + "string+utils.mm", + ] + deps = [ + "//base", + "//third_party/icu", + ] + frameworks = [ "Foundation.framework" ] +} diff --git a/ios/browser/api/string/headers.gni b/ios/browser/api/string/headers.gni new file mode 100644 index 000000000000..a42ca91e937b --- /dev/null +++ b/ios/browser/api/string/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_string_public_headers = + [ "//brave/ios/browser/api/string/string+utils.h" ] diff --git a/ios/browser/api/string/string+utils.h b/ios/browser/api/string/string+utils.h new file mode 100644 index 000000000000..8e8d020f2f76 --- /dev/null +++ b/ios/browser/api/string/string+utils.h @@ -0,0 +1,37 @@ +// 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_STRING_STRING_UTILS_H_ +#define BRAVE_IOS_BROWSER_API_STRING_STRING_UTILS_H_ + +#import + +NS_ASSUME_NONNULL_BEGIN + +NS_SWIFT_NAME(NSString.UBiDiDirection) +typedef NS_ENUM(NSUInteger, BraveUBiDiDirection) { + BraveUBiDiDirectionLTR, + BraveUBiDiDirectionRTL, + BraveUBiDiDirectionMIXED, + BraveUBiDiDirectionNEUTRAL, + BraveUBiDiDirectionUNKNOWN_ERROR +}; + +OBJC_EXPORT +@interface NSString (BraveCoreUtils) +/// The bidi direction of the text. +/// Can return LTR, RTL, MIXED and UNKNOWN_ERROR +/// Can never return NEUTRAL +@property(nonatomic, readonly) BraveUBiDiDirection bidiDirection; + +/// The bidi base direction of the text +/// Can return LTR, RTL, NEUTRAL, UNKNOWN_ERROR +/// Can never return MIXED +@property(nonatomic, readonly) BraveUBiDiDirection bidiBaseDirection; +@end + +NS_ASSUME_NONNULL_END + +#endif // BRAVE_IOS_BROWSER_API_STRING_STRING_UTILS_H_ diff --git a/ios/browser/api/string/string+utils.mm b/ios/browser/api/string/string+utils.mm new file mode 100644 index 000000000000..3c5c795addc1 --- /dev/null +++ b/ios/browser/api/string/string+utils.mm @@ -0,0 +1,48 @@ +// 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 + +#include "base/strings/sys_string_conversions.h" +#include "brave/ios/browser/api/string/string+utils.h" +#include "third_party/icu/source/common/unicode/ubidi.h" + +@implementation NSString (BraveCoreUtils) + +- (BraveUBiDiDirection)bidiDirection { + auto utf16_string = base::SysNSStringToUTF16(self); + if (!utf16_string.empty()) { + UBiDi* bidi = ubidi_open(); + if (bidi) { + UErrorCode status = U_ZERO_ERROR; + ubidi_setPara(bidi, &utf16_string[0], + static_cast(utf16_string.size()), + UBIDI_DEFAULT_LTR, nullptr, &status); + if (U_FAILURE(status)) { + ubidi_close(bidi); + return BraveUBiDiDirectionUNKNOWN_ERROR; + } + + UBiDiDirection direction = ubidi_getDirection(bidi); + ubidi_close(bidi); + return static_cast(direction); + } + return BraveUBiDiDirectionUNKNOWN_ERROR; + } + + return BraveUBiDiDirectionLTR; +} + +- (BraveUBiDiDirection)bidiBaseDirection { + auto utf16_string = base::SysNSStringToUTF16(self); + if (!utf16_string.empty()) { + return static_cast(ubidi_getBaseDirection( + &utf16_string[0], static_cast(utf16_string.size()))); + } + + return BraveUBiDiDirectionNEUTRAL; +} + +@end