Search code examples
iosobjective-clocale

How to give a real iPhone null locale


I have an iOS app that gets the country code like this

NSLocale *currentLocale = [NSLocale autoupdatingCurrentLocale];
NSString *countryCode = [currentLocale objectForKey:NSLocaleCountryCode];

I understand that technically this can be null, but I assumed (incorrectly) that this could only occur in corner cases where it wouldn't matter.

But now I have a user whose phone is reporting null locale and it's breaking a part of my app so that they can't use it. While working on a bug fix, I just want to understand how to get into and out of this state, so that they can work around the bug. But even when they set their language and region to English/USA, they still get this weird result on the Language & Region screen:

unknown currency sign

and the bug in my app still occurs due to null locale/region. That's 0xa4 "currency sign" at the bottom, according to Wikipedia:

The currency sign ¤ is a character used to denote an unspecified currency.

So it seems that even iOS does not understand their locale. How does this happen with a real phone and how do you fix it?

They're using an iPhone 12 mini on iOS 14.7.1.


Solution

  • This is an unfortunate and somewhat rare bug that appears to be related to user preferences getting smashed on-device, and without a known cause, there isn't much you can do about it short of attempting to detect it and falling back to some default locale (like en_US).

    Digging in (details from Hopper):

    • <Foundation> +[NSLocale autoupdatingCurrentLocale] creates an instance of NSAutoLocale, an instance of a private class whose initializer fetches +[NSLocale currentLocale] and listens to a privately-defined notification for when the current locale changes:

                           +[NSLocale autoupdatingCurrentLocale]:
      00000000000bdfd8         stp        x29, x30, [sp, #-0x10]!                     ; Objective C Implementation defined at 0x24fb80 (class method)
      00000000000bdfdc         mov        x29, sp
      00000000000bdfe0         adrp       x8, #0x353000                               ; &@selector(variantFormatter)
      00000000000bdfe4         ldr        x0, [x8, #0xf58]                            ; objc_cls_ref_NSAutoLocale,_OBJC_CLASS_$_NSAutoLocale
      00000000000bdfe8         bl         imp___stubs__objc_opt_new                   ; objc_opt_new
      00000000000bdfec         ldp        x29, x30, [sp], #0x10
      00000000000bdff0         b          imp___stubs__objc_autorelease               ; objc_autorelease
      
      int -[NSAutoLocale _init](int arg0) {
          r19 = arg0;
          r0 = [NSLocale currentLocale];
          r0 = [r0 retain];
          r8 = 0x35603c;
          asm { ldpsw      x8, x9, [x8] };
          *(r19 + r9) = r0;
          pthread_mutex_init(r19 + r8, 0x0);
          [[NSNotificationCenter defaultCenter] addObserver:r19 selector:@selector(_update:) name:@"kCFLocaleCurrentLocaleDidChangeNotification-4" object:0x0];
          r0 = r19;
          return r0;
      }
      

      NSAutoLocale responds to all locale methods and forwards them to the underlying NSLocale instance, so we can look to see what +currentLocale returns

    • <CoreFoundation> +[NSLocale currentLocale] simply returns the results of _CFLocaleCopyCurrent() (NSLocale and CFLocaleRef are toll-free bridged):

      void +[NSLocale currentLocale]() {
          [_CFLocaleCopyCurrent() autorelease];
          return;
      }
      

      _CFLocaleCopyCurrent() returns the contents of a shared implementation of a "Guts" function which is called from multiple places with different parameters:

      int _CFLocaleCopyCurrent() {
          rax = __CFLocaleCopyCurrentGuts(0x0, 0x1, 0x0, 0x0);
          return rax;
      }
      
    • <CoreFoundation> __CFLocaleCopyCurrentGuts is too large to include here inline, but when called with the given parameters (and when it has no cached "current locale" instance), it creates a new locale by looking up the value for the AppleLocale key from current user preferences

      • Failing that, it also looks up the AppleLanguages dictionary and tries to create a locale from one of the values, but that's separate

    So effectively, the "current locale" is simply a locale object constructed with the locale identifier defined by "AppleLocale" in prefs. The specific location looked at is the kCFPreferencesAnyApplication application (i.e., global prefs) for the kCFPreferencesCurrentUser at the kCFPreferencesCurrentHost — i.e., what is returned by defaults read/write NSGlobalDomain ... (equivalent to defaults read/write -g ...).

    You can inspect this value yourself, and because my machine is set to English in the United States, I see

    $ defaults read -g AppleLocale
    en_US
    

    You can write a small tool that inspects "AppleLocale", write a value to "AppleLocale", and rerun it to inspect the change if you'd like, but this is also possible to do in-process (in a sort of convoluted way). If you forward-declare _CFLocaleResetCurrent() from Objective-C somewhere (a bridging header will work), you can directly call the function which clears the current locale cache, and by manipulating CFPreferences directly, see the changes to the current locale:

    import Foundation
    
    func overwriteLocaleIdentifier(_ identifier: String) {
        // We use kCFPreferencesCurrentApplication to avoid changing system-wide settings.
        CFPreferencesSetAppValue("AppleLocale" as CFString, identifier as CFString, kCFPreferencesCurrentApplication)
        _CFLocaleResetCurrent()
    }
    
    func withOverwrittenLocaleIdentifier(_ identifier: String, _ action: () -> Void) {
        let currentIdentifier = Locale.current.identifier
        defer { overwriteLocaleIdentifier(currentIdentifier) }
    
        overwriteLocaleIdentifier(identifier)
        action()
    }
    
    print(Locale.current.identifier)
    withOverwrittenLocaleIdentifier("he_IL") {
        print(Locale.current.identifier)
    }
    
    print(Locale.current.identifier)
    

    For me, this produces

    en_US
    he_IL
    en_US
    

    For better or worse, this behavior means that whatever value is found for the "AppleLocale" key is what will get used as the current locale identifier.

    If the "AppleLocale" value is missing, you'll start seeing some logs:

    2021-09-15 16:47:52.013846-0400 LocaleExample[40427:9913027] [User Defaults] CFPrefsPlistSource<0x107b08420> (Domain: kCFPreferencesAnyApplication, User: kCFPreferencesCurrentUser, ByHost: No, Container: (null), Contents Need Refresh: No): Value for key AppleLocale was (null). Expected en_US (defaults(40182): 2021-09-15 16:44:29 (EDT))
    

    When either the locale identifier is missing (or set to nonsense), the system can absolutely start misbehaving, since the locale identifier is used directly.


    So, when can this happen? On a typical iOS device, the answer should be "never", but this is not the case. Through typical UI, there is no way to accidentally set the locale identifier to something invalid, but I've seen in the wild user's locales set to "", "en" (no country code), or simply missing, on devices in hand, confirmed to be stock, not jailbroken, etc. All it really takes is one misbehaving process to clobber the "AppleLocale" value and you can end up in this situation.

    There's typically no real recourse beside falling back to a known good local (or a default like en_US), and if a user asks, request that they reset their locale through Settings (changing the settings, then changing back). Ideally, this will get them back to a known good state.