Skip to content

Commit

Permalink
SwiftUI support
Browse files Browse the repository at this point in the history
* Adds attributed string swizzling that allows Transifex SDK to allow
for SwiftUI support.
* De-swizzles methods when SDK is deactivated (when `dispose` is
called).
* Adds public static method to deactivate swizzling on passed `Bundle`
instance.
* Ensures that the special Transifex `.stringsdict` key used to
calculate the plural rules, cannot be swizzled
(`TRANSIFEX_STRINGSDICT_KEY`).
* Ensures that SDK is properly deactivated when a unit test finishes, so
that swizzling is re-applied correctly on each test.
* Adds unit test for the `localizedAttributedString:...` method in
Objective-C.
* Adds unit test for the `NSLocalizedString()` method in Swift.

NOTE: SwiftUI elements (`View`, `Button` etc) cannot be added to unit
tests.
  • Loading branch information
stelabouras committed Jun 10, 2024
1 parent b85d7c8 commit 1e2accc
Show file tree
Hide file tree
Showing 9 changed files with 295 additions and 48 deletions.
1 change: 1 addition & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ let package = Package(
.testTarget(
name: "TransifexObjCTests",
dependencies: [
"Transifex",
"TransifexObjCRuntime",
]
),
Expand Down
4 changes: 0 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -378,10 +378,6 @@ Description strings) that are included in the `InfoPList.strings` file.
* Localized entried found in the `Root.plist` of the `Settings.bundle` of an app that
exposes its Settings to the iOS Settings app that are included in the `Root.strings` file.

### SwiftUI

The SDK does not currently support SwiftUI views.

### ICU support

Also, currently SDK supports only supports the platform rendering strategy, so if the ICU
Expand Down
13 changes: 11 additions & 2 deletions Sources/Transifex/Core.swift
Original file line number Diff line number Diff line change
Expand Up @@ -501,7 +501,14 @@ token: \(token)

Swizzler.activate(bundles: [bundle])
}


/// Deactivates swizzling for the Bundle previously passed in the `activate(bundle:)` method.
/// - Parameter bundle: the bundle to be deactivated.
@objc
public static func deactivate(bundle: Bundle) {
Swizzler.deactivate(bundles: [bundle])
}

/// Return the translation of the given source string on a certain locale.
///
/// - Parameters:
Expand Down Expand Up @@ -599,9 +606,11 @@ token: \(token)
tx?.forceCacheInvalidation(completionHandler: completionHandler)
}

/// Destructs the TXNative singleton instance so that another one can be used.
/// Destructs the TXNative singleton instance so that another one can be used. Reverts swizzled
/// classes and methods.
@objc
public static func dispose() {
Swizzler.deactivate()
tx = nil
}
}
5 changes: 3 additions & 2 deletions Sources/Transifex/RenderingStrategy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ class ICUMessageFormat : RenderingStrategyFormatter {

/// The platform rendering strategy
class PlatformFormat : RenderingStrategyFormatter {

internal static let TRANSIFEX_STRINGSDICT_KEY = "Transifex.StringsDict.TestKey.%d"

/// Returns the proper plural rule to use based on the given locale and arguments.
///
/// In order to find the correct rule, it takes advantage of Apple's localization framework
Expand All @@ -50,7 +51,7 @@ class PlatformFormat : RenderingStrategyFormatter {
/// business logic from scratch.
static func extractPluralizationRule(locale: Locale,
argument: CVarArg) -> PluralizationRule {
let key = NSLocalizedString("Transifex.StringsDict.TestKey.%d",
let key = NSLocalizedString(TRANSIFEX_STRINGSDICT_KEY,
bundle: Bundle.module,
comment: "")
let pluralizationRule = String(format: key,
Expand Down
132 changes: 124 additions & 8 deletions Sources/Transifex/Swizzler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,34 @@ import TransifexObjCRuntime
/// Swizzles all localizedString() calls made either by Storyboard files or by the use of NSLocalizedString()
/// function in code.
class SwizzledBundle : Bundle {
// NOTE:
// We can't override the `localizedAttributedString(forKey:value:table:)`
// method here, as it's not exposed in Swift.
// We can neither use swizzling in Swift for the same reason.
// In order to intercept this method (that SwiftUI uses for all its text
// components), we rely on the `TXNativeObjecSwizzler` to swizzle that
// method that is also going to work on runtime for Swift/SwiftUI.
override func localizedString(forKey key: String,
value: String?,
table tableName: String?) -> String {
if Swizzler.activated && value != Swizzler.SKIP_SWIZZLING_VALUE {
// Apply the swizzled method only if:
// * Swizzler has been activated.
// * Swizzling was not disabled temporarily using the
// `SKIP_SWIZZLING_VALUE` flag.
// * The key does not match the reserved Transifex StringsDict key that
// is used to extract the proper pluralization rule.
// NOTE: While running the Unit Test suite of the Transifex SDK, we
// noticed that certain unit tests (e.g. `testPlatformFormat`,
// `testPlatformFormatMultiple`) were triggering the Transifex module
// bundle to load to due to the call of the `extractPluralizationRule`
// method. Even though the loading of the module bundling was
// happening after the Swizzler was activated, subsequent unit tests
// had the bundle already loaded in the `Bundle.allBundles` array,
// causing the bundle to also be swizzled, thus preventing the
// `Localizable.stringsdict` to report the correct pluralization rule.
if Swizzler.activated
&& value != Swizzler.SKIP_SWIZZLING_VALUE
&& key != PlatformFormat.TRANSIFEX_STRINGSDICT_KEY {
return Swizzler.localizedString(forKey: key,
value: value,
table: tableName)
Expand Down Expand Up @@ -55,20 +79,100 @@ class Swizzler {
}

self.translationProvider = translationProvider


// Swizzle `NSBundle.localizedString(String,String?,String?)` method
// for Swift.
activate(bundles: Bundle.allBundles)

TXNativeObjcSwizzler.activate {

// Swizzle `-[NSString localizedStringWithFormat:]` method for
// Objective-C.
TXNativeObjcSwizzler.swizzleLocalizedString {
return self.localizedString(format: $0,
arguments: $1)
}

// Swizzle `-[NSBundle localizedAttributedStringForKey:value:table:]`
// method for Objective-C, Swift and SwiftUI.
TXNativeObjcSwizzler.swizzleLocalizedAttributedString(self,
selector: swizzledLocalizedAttributedStringSelector())

activated = true
}


/// Deactivates Swizzler
internal static func deactivate() {
guard activated else {
return
}

// Deactivate swizzled bundles
deactivate(bundles: Bundle.allBundles)

// Deactivate swizzling in:
// * `-[NSString localizedStringWithFormat:]`
// * `-[NSBundle localizedAttributedStringForKey:value:table:]`
TXNativeObjcSwizzler.revertLocalizedString()
TXNativeObjcSwizzler.revertLocalizedAttributedString(self,
selector: swizzledLocalizedAttributedStringSelector())

translationProvider = nil

activated = false
}

private static func swizzledLocalizedAttributedStringSelector() -> Selector {
return #selector(swizzledLocalizedAttributedString(forKey:value:table:))
}

@objc
private func swizzledLocalizedAttributedString(forKey key: String,
value: String?,
table tableName: String?) -> NSAttributedString {
let swizzledString = Swizzler.localizedString(forKey: key,
value: value,
table: tableName)
// On supported platforms, attempt to decode the attributed string as
// markdown in case it contains style decorators (e.g. *italic*,
// **bold** etc).
#if os(iOS)
if #available(iOS 15, *) {
return Self.attributedString(with: swizzledString)
}
#elseif os(watchOS)
if #available(watchOS 8, *) {
return Self.attributedString(with: swizzledString)
}
#elseif os(tvOS)
if #available(tvOS 15, *) {
return Self.attributedString(with: swizzledString)
}
#elseif os(macOS)
if #available(macOS 12, *) {
return Self.attributedString(with: swizzledString)
}
#endif
// Otherwise, return a simple attributed string
return NSAttributedString(string: swizzledString)
}

@available(macOS 12, iOS 15, tvOS 15, watchOS 8, *)
private static func attributedString(with swizzledString: String) -> NSAttributedString {
var string: AttributedString
do {
string = try AttributedString(markdown: swizzledString)
}
catch {
// Fallback to the non-Markdown version in case of an error
// during Markdown parsing.
return NSAttributedString(string: swizzledString)
}
// If successful, return the Markdown-styled string
return NSAttributedString(string)
}

/// Swizzles the passed bundles so that their localization methods are intercepted.
///
/// - Parameter bundles: The Bundle that will be swizzled
/// - Parameter bundles: The bundles to be swizzled
internal static func activate(bundles: [Bundle]) {
bundles.forEach({ (bundle) in
guard !bundle.isKind(of: SwizzledBundle.self) else {
Expand All @@ -78,15 +182,27 @@ class Swizzler {
})
}

/// Reverts the class of the passed swizzled bundles to original `Bundle` class.
///
/// - Parameter bundles: The bundles to be reverted.
internal static func deactivate(bundles: [Bundle]) {
bundles.forEach({ (bundle) in
guard bundle.isKind(of: SwizzledBundle.self) else {
return
}
object_setClass(bundle, Bundle.self)
})
}

/// Centralized method that all swizzled or overriden localizedStringWithFormat: methods will call once
/// Swizzler is activated.
///
/// - Parameters:
/// - format: The format string
/// - arguments: An array of arguments of arbitrary type
/// - Returns: The final string
static func localizedString(format: String,
arguments: [Any]) -> String {
internal static func localizedString(format: String,
arguments: [Any]) -> String {
guard let translationProvider = translationProvider else {
return MISSING_PROVIDER
}
Expand Down
38 changes: 27 additions & 11 deletions Sources/TransifexObjCRuntime/TXNativeObjcSwizzler.m
Original file line number Diff line number Diff line change
Expand Up @@ -140,17 +140,33 @@ @implementation TXNativeObjcArgument

@implementation TXNativeObjcSwizzler

+ (void)activateWithClosure:(NSString* (^)(NSString *format,
NSArray <TXNativeObjcArgument *> *arguments))closure {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
TXNativeObjcSwizzlerClosure = closure;

Class class = NSString.class;
Method m1 = class_getClassMethod(class, @selector(localizedStringWithFormat:));
Method m2 = class_getClassMethod(class, @selector(swizzledLocalizedStringWithFormat:));
method_exchangeImplementations(m1, m2);
});
+ (void)swizzleLocalizedStringWithClosure:(NSString* (^)(NSString *format,
NSArray <TXNativeObjcArgument *> *arguments))closure {
TXNativeObjcSwizzlerClosure = closure;

Method m1 = class_getClassMethod(NSString.class, @selector(localizedStringWithFormat:));
Method m2 = class_getClassMethod(NSString.class, @selector(swizzledLocalizedStringWithFormat:));
method_exchangeImplementations(m1, m2);
}

+ (void)revertLocalizedString {
TXNativeObjcSwizzlerClosure = nil;

Method m1 = class_getClassMethod(NSString.class, @selector(localizedStringWithFormat:));
Method m2 = class_getClassMethod(NSString.class, @selector(swizzledLocalizedStringWithFormat:));
method_exchangeImplementations(m2, m1);
}

+ (void)swizzleLocalizedAttributedString:(Class)class selector:(SEL)selector {
Method m1 = class_getInstanceMethod(NSBundle.class, @selector(localizedAttributedStringForKey:value:table:));
Method m2 = class_getInstanceMethod(class, selector);
method_exchangeImplementations(m1, m2);
}

+ (void)revertLocalizedAttributedString:(Class)class selector:(SEL)selector {
Method m1 = class_getInstanceMethod(NSBundle.class, @selector(localizedAttributedStringForKey:value:table:));
Method m2 = class_getInstanceMethod(class, selector);
method_exchangeImplementations(m2, m1);
}

@end
21 changes: 19 additions & 2 deletions Sources/TransifexObjCRuntime/include/TXNativeObjcSwizzler.h
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,25 @@ typedef NS_ENUM(NSInteger, TXNativeObjcArgumentType) {
///
/// @param closure A provided block that will be called when the localizedStringWithFormat: method
/// is called.
+ (void)activateWithClosure:(NSString* (^)(NSString *format,
NSArray <TXNativeObjcArgument *> *arguments))closure;
+ (void)swizzleLocalizedStringWithClosure:(NSString* (^)(NSString *format,
NSArray <TXNativeObjcArgument *> *arguments))closure;

/// Deactivate swizzling for Objective C NSString.localizedStringWithFormat: method.
+ (void)revertLocalizedString;

/// Swizzle the `localizedAttributedStringForKey:value:table:` NSBundle method using
/// the provided class and method from the caller.
///
/// @param class The caller class that contains the swizzled selector.
/// @param selector The swizzled selector.
+ (void)swizzleLocalizedAttributedString:(Class)class selector:(SEL)selector;

/// Deactivate swizzling for `localizedAttributedStringForKey:value:table:` NSBundle
/// method.
///
/// @param class The caller class that contains the swizzled selector.
/// @param selector The swizzled selector.
+ (void)revertLocalizedAttributedString:(Class)class selector:(SEL)selector;

@end

Expand Down
Loading

0 comments on commit 1e2accc

Please sign in to comment.