ioslocalization

What happened to the `Localizable.strings` files on devices in recent versions of iOS?


In previous iOS versions, it was possible to access the localization of system resources on device by using the Bundle class.

For example, translating Done into German was possible using the following code:

let bundle = Bundle(url: Bundle(for: UINavigationController.self).url(forResource: "de", withExtension: "lproj")!)!
print("📁 \(bundle.bundleURL)")
for file in try! FileManager.default.contentsOfDirectory(at: bundle.bundleURL, includingPropertiesForKeys: []) {
    print("  📄 \(file.lastPathComponent)")
}
let done = bundle.localizedString(forKey: "Done", value: "_fallback_", table: "Localizable")
print("Done in German: \(done)")

It was printing the following, just like expected:

📁 file:///Applications/Xcode-14.3.1.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/System/Library/PrivateFrameworks/UIKitCore.framework/de.lproj/
  📄 Localizable.strings
  📄 Localizable.stringsdict
  📄 UITableViewLocalizedSectionIndex.plist
Done in German: Fertig

Note that this technique is still working on the simulators (for example iPhone 14 Pro running iOS 16.4) but is not working on actual devices.

When running this same code on an iPhone 11 running iOS 16.5.1 I get the following output:

📁 file:///System/Library/PrivateFrameworks/UIKitCore.framework/de.lproj/
  📄 UITableViewLocalizedSectionIndex.plist
Done in German: _fallback_

We can see that the translation fails because the Localizable.strings and Localizable.stringsdict have disappeared.

What happened to those files in recent iOS releases? Can we still access them somehow?


Solution

  • Alexandre Colucci informed me on Mastodon about the existence of a new Localizable.loctable file where all the localizations can be found.

    With this knowledge I was able to write an extension method on the Bundle class to access localized strings from any bundle.

    There are two implementations to get the dictionary of all localized strings. One that does not use a private API but uses the undocumented .loctable file format and one that uses the private localizedStringsForTable:localization: method.

    import Foundation
    
    extension Bundle {
        public func localizedString(forKey key: String, localization: String, value: String? = nil, table tableName: String? = nil) -> String {
            if let localizedStrings = localizedStrings(forTable: tableName, localization: localization), let localizedString = localizedStrings[key] as? String {
                return localizedString
            } else if let url = url(forResource: localization, withExtension: "lproj"), let localizationBundle = Bundle(url: url) {
                return localizationBundle.localizedString(forKey: key, value: value, table: tableName)
            }
    
            if let value, !value.isEmpty {
                return value
            }
    
            return key
        }
    
        private func localizedStrings(forTable tableName: String?, localization: String) -> [String: Any]? {
    #if DISABLE_PRIVATE_API
            // This is not technically using a private API but it is using an undocument file and format
            let loctableURL = url(forResource: tableName ?? "Localizable", withExtension: "loctable")
            if let loctableURL, let data = try? Data(contentsOf: loctableURL) {
                let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: [String: Any]]
                if let localizedStrings = plist?[localization] as? [String: Any] {
                    return localizedStrings
                }
            }
    #else
            // This is using a private API but one which is designed for this purpose
            let localizedStringsForTable = Selector(("localizedStringsForTable:localization:"))
            if responds(to: localizedStringsForTable), let localizedStrings = perform(localizedStringsForTable, with: tableName, with: localization)?.takeUnretainedValue() as? [String: Any] {
                return localizedStrings
            }
    #endif
            return nil
        }
    }
    

    Usage is straightforward and works on both simulators and devices for all versions of iOS.

    let done = Bundle(for: UIApplication.self).localizedString(forKey: "Done", localization: "de")
    print("Done in German: \(done)")
    

    This prints Done in German: Fertig as expected.