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:
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.
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
AppleLanguages
dictionary and tries to create a locale from one of the values, but that's separateSo 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.