Repository: digidem/comapeo-mobile
Pull request #1063: feat: add option to use system preferences for language
Author: login: achou11
Created: 2025-03-25T20:42:29Z | Merged: 2025-03-28T13:41:29Z

Closes #978 
Closes #1028 
Closes #1019

A combination of changes from #1011 and #1027 with two goals:

- update the state setup to follow other zustand-based states
- fix a bug with language resolution by introducing a new UI option for language selection, which makes language resolution much more predictable

---

<img width="300" alt="image" src="https://github.com/user-attachments/assets/e3259f74-a59f-436c-86bd-9d46a1eb1923" />


Linked issue #978: App does not use system language preference when expected
**Describe the bug**

The flow in which we should be calculating the language to use in the app is generally as follows:

1. If you explicitly selected a language in the app using the language settings, use that
2. Use the the language preference based on the system preferences
3. Use English

Assuming (1) is has not been done, switching the system language to a choice that the app does support does not update the the language in the app. This is the case the app is already opened and even after restarting the app.

**To Reproduce**
Steps to reproduce the behavior:
1. Do a fresh install
2. Open your Settings app (do not close CoMapeo)
3. Go to `System -> Languages`
4. Change the primary (or preferred, depending on Android version) language to something other than the current choice that is supported by the app
5. Go back to the app. You'll notice that the app does not update to make use of the newly selected system language
6. Restart the app. Upon re-opening, you'll notice that the app does not make use of the newly selected system language.

**Expected behavior**

In the case that the user has NEVER explicitly selected a language _within_ the app's settings UI, the chosen language based on the user's system preferences should be reflected in the app.

**Screenshots**

https://github.com/user-attachments/assets/83ace32c-7344-4d0b-9d63-a70cf7b7c68e

**Desktop (please complete the following information):**
 - OS: [e.g. iOS]
 - Browser [e.g. chrome, safari]
 - Version [e.g. 22]

**Smartphone (please complete the following information):**
 - Device: Pixel 6
 - OS: Android 15
 - Version: 1.2.0

**Additional context**

I think I already identified the issue in the code. It's related to our usage of persisted state for initializing and determining the active language that the app uses

https://github.com/digidem/comapeo-mobile/blob/309c374022d1bc46bfade1376761fd80113bb351/src/frontend/hooks/persistedState/usePersistedLocale.ts

Linked issue comments: user: login: achou11
created_at: 2025-02-27T18:45:13Z
body: Part of the issue here is that we don't have a way of understanding _why_ the active language is being used. For example, I see that `English` is my selected language, but I have no idea if that's because my system preferences are being used, or if it's because at some point, I pressed the English option and our app persisted that in storage.

Most apps I use have an option in their language selection UIs along the lines of `Follow system preferences` and usually that's the default. Would be great to have something similar for ours or update the UI to communicate that somehow.
author_association: MEMBER

Diff:
diff --git a/messages/en.json b/messages/en.json
index 71031a9410..b0c74e16b9 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -650,6 +650,10 @@
   "screens.IntroToCoMapeo.ownData": {
     "message": "Own and control your data"
   },
+  "screens.LanguageSettings.followSystemPreferences": {
+    "description": "Label for language option to follow system preferences",
+    "message": "Follow system preferences"
+  },
   "screens.LanguageSettings.title": {
     "description": "Title language settings screen",
     "message": "Language"
diff --git a/src/frontend/App.tsx b/src/frontend/App.tsx
index 48880f67fb..5f00705d3d 100644
--- a/src/frontend/App.tsx
+++ b/src/frontend/App.tsx
@@ -1,4 +1,5 @@
 import * as React from 'react';
+import {getLocales} from 'expo-localization';
 import {createMapeoClient} from '@comapeo/ipc';
 import {AppNavigator} from './AppNavigator';
 import {MessagePortLike} from './lib/MessagePortLike';
@@ -24,6 +25,8 @@ import {createCoordinateFormatStore} from './contexts/CoordinateFormatContext';
 import {createManualEntryCoordinateFormatStore} from './contexts/ManualEntryCoordinateFormatContext';
 import {createActiveProjectIdStore} from './contexts/ActiveProjectIdStoreContext';
 import {createMetricsDiagnosticsStore} from './contexts/MetricsDiagnosticsStoreContext';
+import {createLocaleStore} from './contexts/LocaleStoreContext';
+import {getAppLanguageTag} from './lib/intl';
 
 type SentryEnvironment = 'development' | 'qa' | 'production';
 
@@ -46,7 +49,27 @@ Sentry.init({
 
 Mapbox.setTelemetryEnabled(false);
 
-const appDiagnosticMetrics = new AppDiagnosticMetrics();
+const persistedLocaleStore = createLocaleStore({
+  persist: true,
+});
+
+const appDiagnosticMetrics = new AppDiagnosticMetrics({
+  getLocaleInfo: () => {
+    const systemLocales = getLocales();
+    const localeState = persistedLocaleStore.instance.getState();
+
+    const appLanguageTag = getAppLanguageTag({
+      localeState,
+      systemLanguageTags: systemLocales.map(l => l.languageTag),
+    }).value;
+
+    return {
+      appLanguageTag,
+      deviceLanguageTag: systemLocales[0]!.languageTag,
+    };
+  },
+});
+
 const deviceDiagnosticMetrics = new DeviceDiagnosticMetrics();
 const messagePort = new MessagePortLike();
 const mapeoApi = createMapeoClient(messagePort, {timeout: Infinity});
@@ -147,7 +170,8 @@ const App = () => {
         persistedManualEntryCoordinateFormatStore
       }
       activeProjectIdStore={persistedActiveProjectIdStore}
-      metricsDiagnosticsStore={persistedMetricsDiagnosticsStore}>
+      metricsDiagnosticsStore={persistedMetricsDiagnosticsStore}
+      localeStore={persistedLocaleStore}>
       <AppNavigator permissionAsked={permissionsAsked} />
     </AppProviders>
   );
diff --git a/src/frontend/__mocks__/expo-localization.ts b/src/frontend/__mocks__/expo-localization.ts
new file mode 100644
index 0000000000..0d5fcd4cbe
--- /dev/null
+++ b/src/frontend/__mocks__/expo-localization.ts
@@ -0,0 +1,34 @@
+import {
+  getLocales as _getLocales,
+  useLocales as _useLocales,
+  type Locale,
+} from 'expo-localization';
+
+import {extractLanguageCode} from '../lib/intl';
+
+export const getLocales: typeof _getLocales = () => {
+  return [createBaseLocale('en-US')];
+};
+
+// eslint-disable-next-line @eslint-react/hooks-extra/no-useless-custom-hooks
+export const useLocales: typeof _useLocales = () => {
+  return [createBaseLocale('en-US')];
+};
+
+function createBaseLocale(languageTag: string): Locale {
+  return {
+    languageTag,
+    languageCode: extractLanguageCode(languageTag),
+    langageCurrencyCode: null,
+    langageCurrencySymbol: null,
+    languageRegionCode: null,
+    regionCode: null,
+    currencyCode: null,
+    currencySymbol: null,
+    decimalSeparator: null,
+    digitGroupingSeparator: null,
+    textDirection: null,
+    measurementSystem: null,
+    temperatureUnit: null,
+  };
+}
diff --git a/src/frontend/contexts/AppProviders.tsx b/src/frontend/contexts/AppProviders.tsx
index fc2d8333e1..59a6e7545f 100644
--- a/src/frontend/contexts/AppProviders.tsx
+++ b/src/frontend/contexts/AppProviders.tsx
@@ -37,6 +37,7 @@ import {
   type MetricsDiagnosticsStore,
   MetricsDiagnosticsStoreProvider,
 } from './MetricsDiagnosticsStoreContext';
+import {LocaleStore, LocaleStoreProvider} from './LocaleStoreContext';
 
 type AppProvidersProps = {
   children: React.ReactNode;
@@ -50,6 +51,7 @@ type AppProvidersProps = {
   manualEntryCoordinateFormatStore: ManualEntryCoordinateFormatStore;
   activeProjectIdStore: ActiveProjectIdStore;
   metricsDiagnosticsStore: MetricsDiagnosticsStore;
+  localeStore: LocaleStore;
 };
 
 const queryClient = new QueryClient();
@@ -66,50 +68,55 @@ export const AppProviders = ({
   manualEntryCoordinateFormatStore,
   activeProjectIdStore,
   metricsDiagnosticsStore,
+  localeStore,
 }: AppProvidersProps) => {
   return (
-    <MetricsDiagnosticsStoreProvider value={metricsDiagnosticsStore}>
-      <ActiveProjectIdStoreProvider value={activeProjectIdStore}>
-        <SecurityStoreProvider value={securityStore}>
-          <CoordinateFormatProvider value={coordinateFormatStore}>
-            <ManualEntryCoordinateFormatProvider
-              value={manualEntryCoordinateFormatStore}>
-              <TrackStoreProvider value={trackStore}>
-                <IntlProvider>
-                  <QueryClientProvider client={queryClient}>
-                    <SafeAreaProvider>
-                      <GestureHandlerRootView style={styles.flex}>
-                        <TrackTimerContextProvider>
-                          <ServerLoading messagePort={messagePort}>
-                            <LocalDiscoveryProvider
-                              value={localDiscoveryController}>
-                              <ClientApiProvider clientApi={mapeoApi}>
-                                <ActiveProjectProvider>
-                                  <BottomSheetModalProvider>
-                                    <PhotoPromiseProvider>
-                                      <DraftObservationProvider
-                                        draftObservationStore={
-                                          persistedDrafObservationStore
-                                        }>
-                                        <AuthProvider>{children}</AuthProvider>
-                                      </DraftObservationProvider>
-                                    </PhotoPromiseProvider>
-                                  </BottomSheetModalProvider>
-                                </ActiveProjectProvider>
-                              </ClientApiProvider>
-                            </LocalDiscoveryProvider>
-                          </ServerLoading>
-                        </TrackTimerContextProvider>
-                      </GestureHandlerRootView>
-                    </SafeAreaProvider>
-                  </QueryClientProvider>
-                </IntlProvider>
-              </TrackStoreProvider>
-            </ManualEntryCoordinateFormatProvider>
-          </CoordinateFormatProvider>
-        </SecurityStoreProvider>
-      </ActiveProjectIdStoreProvider>
-    </MetricsDiagnosticsStoreProvider>
+    <LocaleStoreProvider value={localeStore}>
+      <MetricsDiagnosticsStoreProvider value={metricsDiagnosticsStore}>
+        <ActiveProjectIdStoreProvider value={activeProjectIdStore}>
+          <SecurityStoreProvider value={securityStore}>
+            <CoordinateFormatProvider value={coordinateFormatStore}>
+              <ManualEntryCoordinateFormatProvider
+                value={manualEntryCoordinateFormatStore}>
+                <TrackStoreProvider value={trackStore}>
+                  <IntlProvider>
+                    <QueryClientProvider client={queryClient}>
+                      <SafeAreaProvider>
+                        <GestureHandlerRootView style={styles.flex}>
+                          <TrackTimerContextProvider>
+                            <ServerLoading messagePort={messagePort}>
+                              <LocalDiscoveryProvider
+                                value={localDiscoveryController}>
+                                <ClientApiProvider clientApi={mapeoApi}>
+                                  <ActiveProjectProvider>
+                                    <BottomSheetModalProvider>
+                                      <PhotoPromiseProvider>
+                                        <DraftObservationProvider
+                                          draftObservationStore={
+                                            persistedDrafObservationStore
+                                          }>
+                                          <AuthProvider>
+                                            {children}
+                                          </AuthProvider>
+                                        </DraftObservationProvider>
+                                      </PhotoPromiseProvider>
+                                    </BottomSheetModalProvider>
+                                  </ActiveProjectProvider>
+                                </ClientApiProvider>
+                              </LocalDiscoveryProvider>
+                            </ServerLoading>
+                          </TrackTimerContextProvider>
+                        </GestureHandlerRootView>
+                      </SafeAreaProvider>
+                    </QueryClientProvider>
+                  </IntlProvider>
+                </TrackStoreProvider>
+              </ManualEntryCoordinateFormatProvider>
+            </CoordinateFormatProvider>
+          </SecurityStoreProvider>
+        </ActiveProjectIdStoreProvider>
+      </MetricsDiagnosticsStoreProvider>
+    </LocaleStoreProvider>
   );
 };
 
diff --git a/src/frontend/contexts/IntlContext.tsx b/src/frontend/contexts/IntlContext.tsx
index beb496ef4f..8b2dcc6314 100644
--- a/src/frontend/contexts/IntlContext.tsx
+++ b/src/frontend/contexts/IntlContext.tsx
@@ -3,8 +3,8 @@ import {IntlProvider as ReactIntlProvider, CustomFormats} from 'react-intl';
 import {StyleSheet, Text} from 'react-native';
 
 import messages from '../../../translations/messages.json';
-import {usePersistedLocale} from '../hooks/persistedState/usePersistedLocale';
-import {TranslatedLocale} from '../lib/intl';
+import {useAppLanguageTag} from '../hooks/useAppLanguageTag';
+import {extractLanguageCode, TranslatedLanguageTag} from '../lib/intl';
 
 export const formats: CustomFormats = {
   date: {
@@ -25,20 +25,22 @@ const DEFAULT_RICH_TEXT_MAPPINGS: NonNullable<
 };
 
 export const IntlProvider = ({children}: {children: React.ReactNode}) => {
-  const appLocale = usePersistedLocale(store => store.locale);
+  const languageTag = useAppLanguageTag();
 
-  const languageCode = appLocale.split('-')[0];
+  const messagesToUse = React.useMemo(() => {
+    const languageCode = extractLanguageCode(languageTag);
 
-  // Add fallbacks for non-regional locales (e.g. "en" for "en-GB")
-  const localeMessages = {
-    ...messages[languageCode as TranslatedLocale],
-    ...(messages[appLocale as TranslatedLocale] || {}),
-  };
+    return {
+      // Add fallbacks for non-regional tags (e.g. "en" for "en-GB")
+      ...(messages[languageCode as TranslatedLanguageTag] || {}),
+      ...(messages[languageTag as TranslatedLanguageTag] || {}),
+    };
+  }, [languageTag]);
 
   return (
     <ReactIntlProvider
-      locale={appLocale}
-      messages={localeMessages}
+      locale={languageTag}
+      messages={messagesToUse}
       formats={formats}
       onError={onError}
       wrapRichTextChunksInFragment
diff --git a/src/frontend/contexts/LocaleStoreContext.tsx b/src/frontend/contexts/LocaleStoreContext.tsx
new file mode 100644
index 0000000000..1f674d86e6
--- /dev/null
+++ b/src/frontend/contexts/LocaleStoreContext.tsx
@@ -0,0 +1,103 @@
+import {createContext, useContext} from 'react';
+import * as v from 'valibot';
+import {createStore, useStore, type StoreApi} from 'zustand';
+import {
+  createJSONStorage,
+  persist as createPersistedState,
+} from 'zustand/middleware';
+
+import {MMKVZustandStorage} from '../hooks/persistedState/createPersistedState';
+import {isSupportedLanguageTag, SupportedLanguageTag} from '../lib/intl';
+
+// Do not change!
+export const STORAGE_KEY = 'locale' as const;
+
+export const LocaleStateSchema = v.variant('languageTag', [
+  v.object({
+    languageTag: v.null(),
+    useSystemPreferences: v.literal(true),
+  }),
+  v.object({
+    languageTag: v.pipe(
+      v.string(),
+      v.transform((value): SupportedLanguageTag => {
+        if (!isSupportedLanguageTag(value)) {
+          throw new Error(`Value is not a supported language tag: ${value}`);
+        }
+        return value;
+      }),
+    ),
+    useSystemPreferences: v.literal(false),
+  }),
+]);
+
+export type LocaleState = v.InferOutput<typeof LocaleStateSchema>;
+
+function createInitialState(): LocaleState {
+  return {
+    languageTag: null,
+    useSystemPreferences: true,
+  };
+}
+
+export function createLocaleStore({persist} = {persist: false}) {
+  let store: StoreApi<LocaleState>;
+
+  if (persist) {
+    store = createStore(
+      createPersistedState(createInitialState, {
+        name: STORAGE_KEY,
+        storage: createJSONStorage(() => MMKVZustandStorage),
+        version: 0,
+      }),
+    );
+  } else {
+    store = createStore(createInitialState);
+  }
+
+  const actions = {
+    setLanguageTag: (languageTag: SupportedLanguageTag | null) => {
+      store.setState(
+        languageTag === null
+          ? {
+              languageTag,
+              useSystemPreferences: true,
+            }
+          : {
+              languageTag,
+              useSystemPreferences: false,
+            },
+      );
+    },
+  };
+
+  return {instance: store, actions};
+}
+
+export type LocaleStore = ReturnType<typeof createLocaleStore>;
+
+const LocaleContext = createContext<LocaleStore | null>(null);
+
+export const LocaleStoreProvider = LocaleContext.Provider;
+
+function useLocaleContext() {
+  const value = useContext(LocaleContext);
+
+  if (!value) {
+    throw new Error('Must set up SelectedLocaleStoreProvider first');
+  }
+
+  return value;
+}
+
+export function useLocaleState(): LocaleState;
+export function useLocaleState<T>(selector: (state: LocaleState) => T): T;
+export function useLocaleState<T>(selector?: (state: LocaleState) => T) {
+  const {instance} = useLocaleContext();
+  return useStore(instance, selector!);
+}
+
+export function useLocaleActions() {
+  const {actions} = useLocaleContext();
+  return actions;
+}
diff --git a/src/frontend/hooks/persistedState/usePersistedLocale.ts b/src/frontend/hooks/persistedState/usePersistedLocale.ts
deleted file mode 100644
index 5119521f16..0000000000
--- a/src/frontend/hooks/persistedState/usePersistedLocale.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import {StateCreator} from 'zustand';
-import {getLocales} from 'expo-localization';
-
-import {getSupportedLocale} from '../../lib/intl';
-import {createPersistedState} from './createPersistedState';
-
-type LocaleSlice = {
-  locale: string;
-  setLocale: (locale: string) => void;
-};
-
-export const getDeviceLanguageTag = (): string =>
-  // We can use this non-null assertion with `getLocales()` because, according
-  // to [the docs][1], the result is "guaranteed to contain at least 1 element."
-  // [1]: https://github.com/expo/expo/blob/5585320eec9271038cd7c672b4cf9f0e945ca658/packages/expo-localization/src/Localization.ts#L123
-  getLocales()[0]!.languageTag;
-
-const localeSlice: StateCreator<LocaleSlice> = set => ({
-  locale: getSupportedLocale(getDeviceLanguageTag()) || 'en',
-  setLocale: newlocale => set({locale: newlocale}),
-});
-
-export const usePersistedLocale = createPersistedState(
-  localeSlice,
-  'MapeoLocale',
-);
diff --git a/src/frontend/hooks/server/fields.ts b/src/frontend/hooks/server/fields.ts
index 37f72693cf..2271ddae52 100644
--- a/src/frontend/hooks/server/fields.ts
+++ b/src/frontend/hooks/server/fields.ts
@@ -1,17 +1,18 @@
 import {useQuery} from '@tanstack/react-query';
+
 import {useActiveProject} from '../../contexts/ActiveProjectContext';
-import {usePersistedLocale} from '../persistedState/usePersistedLocale';
+import {useAppLanguageTag} from '../useAppLanguageTag';
 
 export const FIELDS_KEY = 'fields';
 
 export const useFieldsQuery = () => {
   const {projectId, projectApi} = useActiveProject();
-  const lang = usePersistedLocale(store => store.locale);
+  const languageTag = useAppLanguageTag();
 
   return useQuery({
-    queryKey: [FIELDS_KEY, projectId, lang],
+    queryKey: [FIELDS_KEY, projectId, languageTag],
     queryFn: async () => {
-      return projectApi.field.getMany({lang});
+      return projectApi.field.getMany({lang: languageTag});
     },
   });
 };
diff --git a/src/frontend/hooks/server/presets.ts b/src/frontend/hooks/server/presets.ts
index 76bab637df..52858adc43 100644
--- a/src/frontend/hooks/server/presets.ts
+++ b/src/frontend/hooks/server/presets.ts
@@ -6,18 +6,18 @@ import {
 import {PresetValue} from '@comapeo/schema';
 
 import {useActiveProject} from '../../contexts/ActiveProjectContext';
-import {usePersistedLocale} from '../persistedState/usePersistedLocale';
+import {useAppLanguageTag} from '../useAppLanguageTag';
 
 export const PRESETS_KEY = 'presets';
 
 export function usePresetsQuery() {
   const {projectId, projectApi} = useActiveProject();
-  const locale = usePersistedLocale(store => store.locale);
+  const languageTag = useAppLanguageTag();
 
   return useSuspenseQuery({
-    queryKey: [PRESETS_KEY, projectId, locale],
+    queryKey: [PRESETS_KEY, projectId, languageTag],
     queryFn: async () => {
-      return await projectApi.preset.getMany({lang: locale});
+      return await projectApi.preset.getMany({lang: languageTag});
     },
   });
 }
diff --git a/src/frontend/hooks/useAppLanguageTag.test.tsx b/src/frontend/hooks/useAppLanguageTag.test.tsx
new file mode 100644
index 0000000000..a5215f7e34
--- /dev/null
+++ b/src/frontend/hooks/useAppLanguageTag.test.tsx
@@ -0,0 +1,258 @@
+import {act, renderHook} from '@testing-library/react-native';
+import {type ReactNode} from 'react';
+import {Locale, useLocales} from 'expo-localization';
+
+import {
+  createLocaleStore,
+  type LocaleStore,
+  LocaleStoreProvider,
+  useLocaleActions,
+} from '../contexts/LocaleStoreContext';
+import {useAppLanguageTag} from './useAppLanguageTag';
+
+function createWrapper(store: LocaleStore) {
+  return ({children}: {children: ReactNode}) => {
+    return <LocaleStoreProvider value={store}>{children}</LocaleStoreProvider>;
+  };
+}
+
+// Override the global mock that's done in jest.setup.js
+jest.mock('expo-localization', () => {
+  return {
+    useLocales: jest.fn(),
+  };
+});
+
+// Not strictly necessary to create this but helpful to make the tests a bit more descriptive
+function setSystemPreferredLocales(locales: string[]) {
+  jest.mocked(useLocales).mockReturnValue(locales.map(mockLocale));
+}
+
+// Has to start with `mock` for jest to not complain
+function mockLocale(languageTag: string): Locale {
+  return {
+    languageTag,
+    languageCode: null,
+    langageCurrencyCode: null,
+    langageCurrencySymbol: null,
+    languageRegionCode: null,
+    regionCode: null,
+    currencyCode: null,
+    currencySymbol: null,
+    decimalSeparator: null,
+    digitGroupingSeparator: null,
+    textDirection: null,
+    measurementSystem: null,
+    temperatureUnit: null,
+  };
+}
+
+afterEach(() => {
+  jest.restoreAllMocks();
+});
+
+describe('no locale chosen and no system preferences', () => {
+  test('returns the fallback (en)', () => {
+    const store = createLocaleStore();
+    const wrapper = createWrapper(store);
+
+    setSystemPreferredLocales(
+      // This is very unlikely to ever happen given how the library works but doesn't hurt to test anyways
+      // https://github.com/expo/expo/blob/5585320eec9271038cd7c672b4cf9f0e945ca658/packages/expo-localization/src/Localization.ts#L123
+      [],
+    );
+
+    const languageTagHook = renderHook(() => useAppLanguageTag(), {
+      wrapper,
+    });
+
+    expect(languageTagHook.result.current).toBe('en');
+  });
+});
+
+describe('no specific locale selected and single system preference exists', () => {
+  test('uses system preference if supported', () => {
+    const store = createLocaleStore();
+    const wrapper = createWrapper(store);
+
+    setSystemPreferredLocales(['pt']);
+
+    const languageTagHook = renderHook(() => useAppLanguageTag(), {
+      wrapper,
+    });
+
+    expect(languageTagHook.result.current).toBe('pt');
+  });
+
+  test('uses system preference with regional code stripped (recognized regional code)', () => {
+    const store = createLocaleStore();
+    const wrapper = createWrapper(store);
+
+    setSystemPreferredLocales(['pt-BR']);
+
+    const languageTagHook = renderHook(() => useAppLanguageTag(), {
+      wrapper,
+    });
+
+    expect(languageTagHook.result.current).toBe('pt');
+  });
+
+  test('uses system preference with regional code stripped (unrecognized regional code)', () => {
+    const store = createLocaleStore();
+    const wrapper = createWrapper(store);
+
+    setSystemPreferredLocales([
+      // Intentionally somewhat nonsense
+      'pt-ZZ',
+    ]);
+
+    const languageTagHook = renderHook(() => useAppLanguageTag(), {
+      wrapper,
+    });
+
+    expect(languageTagHook.result.current).toBe('pt');
+  });
+
+  test('uses fallback if not supported', () => {
+    const store = createLocaleStore();
+    const wrapper = createWrapper(store);
+
+    setSystemPreferredLocales([
+      // Intentionally nonsense
+      '__',
+    ]);
+
+    const languageTagHook = renderHook(() => useAppLanguageTag(), {
+      wrapper,
+    });
+
+    expect(languageTagHook.result.current).toBe('en');
+  });
+});
+
+describe('using system and multiple system preferences exist', () => {
+  test('respects ordering of system preferences', () => {
+    const store = createLocaleStore();
+    const wrapper = createWrapper(store);
+
+    setSystemPreferredLocales(['es', 'pt']);
+
+    const languageTagHook = renderHook(() => useAppLanguageTag(), {
+      wrapper,
+    });
+
+    expect(languageTagHook.result.current).toBe('es');
+
+    act(() => {
+      setSystemPreferredLocales(['pt', 'es']);
+    });
+
+    languageTagHook.rerender({});
+
+    expect(languageTagHook.result.current).toBe('pt');
+  });
+
+  test('uses first supported system preference', () => {
+    const store = createLocaleStore();
+    const wrapper = createWrapper(store);
+
+    setSystemPreferredLocales([
+      // Intentionally nonsense
+      '__',
+      'pt',
+    ]);
+
+    const languageTagHook = renderHook(() => useAppLanguageTag(), {
+      wrapper,
+    });
+
+    expect(languageTagHook.result.current).toBe('pt');
+  });
+
+  test('uses fallback if none of the system preferences are supported', () => {
+    const store = createLocaleStore();
+    const wrapper = createWrapper(store);
+
+    setSystemPreferredLocales([
+      // Intentionally nonsense
+      '__',
+      // Intentionally nonsense
+      '^^',
+    ]);
+
+    const languageTagHook = renderHook(() => useAppLanguageTag(), {
+      wrapper,
+    });
+
+    expect(languageTagHook.result.current).toBe('en');
+  });
+});
+
+describe('specific locale chosen', () => {
+  test('reacts to changes in selected locale', () => {
+    const store = createLocaleStore();
+    const wrapper = createWrapper(store);
+
+    setSystemPreferredLocales(['pt-BR']);
+
+    const languageTagHook = renderHook(() => useAppLanguageTag(), {
+      wrapper,
+    });
+
+    expect(languageTagHook.result.current).toBe('pt');
+
+    const localeActionsHook = renderHook(() => useLocaleActions(), {
+      wrapper,
+    });
+
+    act(() => {
+      localeActionsHook.result.current.setLanguageTag('es');
+    });
+
+    expect(languageTagHook.result.current).toBe('es');
+
+    act(() => {
+      localeActionsHook.result.current.setLanguageTag(null);
+    });
+
+    expect(languageTagHook.result.current).toBe('pt');
+
+    act(() => {
+      localeActionsHook.result.current.setLanguageTag('en');
+    });
+
+    expect(languageTagHook.result.current).toBe('en');
+  });
+
+  test('is not affected by changes in system preferences', () => {
+    const store = createLocaleStore();
+    const wrapper = createWrapper(store);
+
+    setSystemPreferredLocales(['pt-BR']);
+
+    const localeActionsHook = renderHook(() => useLocaleActions(), {
+      wrapper,
+    });
+
+    act(() => {
+      localeActionsHook.result.current.setLanguageTag('es');
+    });
+
+    const languageTagHook = renderHook(() => useAppLanguageTag(), {
+      wrapper,
+    });
+
+    expect(languageTagHook.result.current).toBe('es');
+
+    setSystemPreferredLocales(['en-US']);
+    languageTagHook.rerender({});
+
+    expect(languageTagHook.result.current).toBe('es');
+
+    // Intentionally nonsense
+    setSystemPreferredLocales(['__']);
+    languageTagHook.rerender({});
+
+    expect(languageTagHook.result.current).toBe('es');
+  });
+});
diff --git a/src/frontend/hooks/useAppLanguageTag.ts b/src/frontend/hooks/useAppLanguageTag.ts
new file mode 100644
index 0000000000..bc959cc7a7
--- /dev/null
+++ b/src/frontend/hooks/useAppLanguageTag.ts
@@ -0,0 +1,19 @@
+import {useLocales} from 'expo-localization';
+import {useMemo} from 'react';
+
+import {useLocaleState} from '../contexts/LocaleStoreContext';
+import {getAppLanguageTag} from '../lib/intl';
+
+export function useAppLanguageTag() {
+  const systemLocales = useLocales();
+  const localeState = useLocaleState();
+
+  return useMemo(() => {
+    const {value} = getAppLanguageTag({
+      localeState,
+      systemLanguageTags: systemLocales.map(l => l.languageTag),
+    });
+
+    return value;
+  }, [localeState, systemLocales]);
+}
diff --git a/src/frontend/lib/intl.test.ts b/src/frontend/lib/intl.test.ts
new file mode 100644
index 0000000000..65168fa1a8
--- /dev/null
+++ b/src/frontend/lib/intl.test.ts
@@ -0,0 +1,127 @@
+import {extractLanguageCode, getAppLanguageTag} from './intl';
+
+describe('extractLanguageCode()', () => {
+  test('works with single component language tag', () => {
+    const result = extractLanguageCode('pt');
+
+    expect(result).toBe('pt');
+  });
+
+  test('works with two component language tag', () => {
+    const result = extractLanguageCode('pt-BR');
+
+    expect(result).toBe('pt');
+  });
+});
+
+describe('getAppLanguageTag()', () => {
+  test('use system - unsupported system preference', () => {
+    const result = getAppLanguageTag({
+      localeState: {
+        useSystemPreferences: true,
+        languageTag: null,
+      },
+      systemLanguageTags: ['__'],
+    });
+
+    expect(result).toStrictEqual({
+      source: 'fallback',
+      value: 'en',
+    });
+  });
+
+  test('use system - supported system preference (base language code only)', () => {
+    const result = getAppLanguageTag({
+      localeState: {
+        useSystemPreferences: true,
+        languageTag: null,
+      },
+      systemLanguageTags: ['es'],
+    });
+
+    expect(result).toStrictEqual({
+      source: 'system',
+      value: 'es',
+    });
+  });
+
+  // TODO: Not sure if the truncation is a desired outcome, but it matches pre-existing behavior
+  test('use system - supported system preference (base + regional code)', () => {
+    const result = getAppLanguageTag({
+      localeState: {
+        useSystemPreferences: true,
+        languageTag: null,
+      },
+      systemLanguageTags: ['es-MX'],
+    });
+
+    expect(result).toStrictEqual({
+      source: 'system',
+      value: 'es',
+    });
+  });
+
+  test('use system - multiple supported system preferences', () => {
+    {
+      const result = getAppLanguageTag({
+        localeState: {
+          useSystemPreferences: true,
+          languageTag: null,
+        },
+        systemLanguageTags: ['pt', 'es'],
+      });
+
+      expect(result).toStrictEqual({
+        source: 'system',
+        value: 'pt',
+      });
+    }
+
+    {
+      const result = getAppLanguageTag({
+        localeState: {
+          useSystemPreferences: true,
+          languageTag: null,
+        },
+        systemLanguageTags: ['__', 'pt'],
+      });
+
+      expect(result).toStrictEqual({
+        source: 'system',
+        value: 'pt',
+      });
+    }
+  });
+
+  test('use selected - supported selected locale', () => {
+    {
+      const result = getAppLanguageTag({
+        localeState: {
+          useSystemPreferences: false,
+          languageTag: 'pt',
+        },
+        systemLanguageTags: [],
+      });
+
+      expect(result).toStrictEqual({
+        source: 'selected',
+        value: 'pt',
+      });
+    }
+
+    {
+      const result = getAppLanguageTag({
+        localeState: {
+          useSystemPreferences: false,
+          languageTag: 'pt-BR',
+        },
+        systemLanguageTags: [],
+      });
+
+      expect(result).toStrictEqual({
+        source: 'selected',
+        value: 'pt-BR',
+      });
+    }
+  });
+});
diff --git a/src/frontend/lib/intl.ts b/src/frontend/lib/intl.ts
index ffcee5849e..8c171e40b4 100644
--- a/src/frontend/lib/intl.ts
+++ b/src/frontend/lib/intl.ts
@@ -1,43 +1,64 @@
+// Mapping of language tags to corresponding translated messages used in app
 import MESSAGES from '../../../translations/messages.json';
+import {LocaleState} from '../contexts/LocaleStoreContext';
+// Mapping of language tag to corresponding native and english names
 import LANGUAGES from '../languages.json';
 
-export type TranslatedLocale = keyof typeof MESSAGES;
-type SupportedLanguageLocale = keyof typeof LANGUAGES;
+// Language tag that has corresponding translations
+export type TranslatedLanguageTag = keyof typeof MESSAGES;
+// Language tag that the app claims to support
+export type SupportedLanguageTag = keyof typeof LANGUAGES;
 
-interface Language {
-  /** IETF BCP 47 langauge tag with region code. */
-  locale: SupportedLanguageLocale;
+// A subset of supported languages that have at least one translated message
+export const USABLE_LANGUAGES = getUsableLanguages(
+  Object.keys(MESSAGES) as Array<TranslatedLanguageTag>,
+);
+
+interface UsableLanguage {
+  /** IETF BCP 47 language tag (https://en.wikipedia.org/wiki/IETF_language_tag) */
+  languageTag: SupportedLanguageTag;
   /** Localized name for language */
   nativeName: string;
   /** English name for language */
   englishName: string;
 }
 
-export const SUPPORTED_LANGUAGES = deriveSupportedLanguages(
-  Object.keys(MESSAGES) as Array<TranslatedLocale>,
-);
+/**
+ * Gets the languages that are usable within the app, meaning:
+ *
+ * 1. They have at least one translated string
+ * 2. They match a language that is found in the [supported languages file](../languages.json)
+ *
+ * @param translatedLanguageTags List of language tags that may have translated messages
+ * @returns List of languages that are usable within the app (see {@link UsableLanguage})
+ */
+function getUsableLanguages(
+  translatedLanguageTags: Array<TranslatedLanguageTag>,
+): Array<UsableLanguage> {
+  const result: Array<UsableLanguage> = [];
 
-function deriveSupportedLanguages(
-  translatedLocales: Array<TranslatedLocale>,
-): Array<Language> {
-  const result: Array<Language> = [];
-
-  for (const locale of translatedLocales) {
+  for (const languageTag of translatedLanguageTags) {
     const hasAtLeastOneTranslatedString =
-      Object.keys(MESSAGES[locale]).length > 0;
+      Object.keys(MESSAGES[languageTag]).length > 0;
 
     if (!hasAtLeastOneTranslatedString) continue;
 
-    if (!isSupportedLanguageLocale(locale)) {
+    if (!isSupportedLanguageTag(languageTag)) {
       if (process.env.APP_VARIANT === 'development') {
         console.warn(
-          `Locale "${locale}" is not available in CoMapeo (see \`src/frontend/languages.json\`)`,
+          `Language "${languageTag}" is not available in CoMapeo (see \`src/frontend/languages.json\`)`,
         );
       }
       continue;
     }
 
-    result.push({locale, ...LANGUAGES[locale]});
+    const {englishName, nativeName} = LANGUAGES[languageTag];
+
+    result.push({
+      englishName,
+      languageTag,
+      nativeName,
+    });
   }
 
   result.sort((a, b) => {
@@ -47,24 +68,87 @@ function deriveSupportedLanguages(
   return result;
 }
 
-function isSupportedLanguageLocale(
-  locale: string,
-): locale is SupportedLanguageLocale {
-  return locale in LANGUAGES;
+export function isSupportedLanguageTag(
+  value: string,
+): value is SupportedLanguageTag {
+  return value in LANGUAGES;
 }
 
-// Device locale can be regional e.g. `en-US` but we might only have
-// translations for `en`. If we don't have translations for a given device
-// language, then we ignore it and fallback to `en` or the user selected
-// language for the app
-export function getSupportedLocale(
-  locale: string,
-): SupportedLanguageLocale | undefined {
-  if (SUPPORTED_LANGUAGES.find(lang => lang.locale === locale))
-    return locale as SupportedLanguageLocale;
+/**
+ * Returns the variant of a language tag that the app can use to show translated messages.
+ * If we have translations that match the primary language code component of a specified language tag,
+ * we return the language code as the language tag (since all language codes are valid language tags).
+ *
+ * Example: if the language tag is `'en-GB'` but we only have translations for `'en'`, then `'en'` is returned.
+ *
+ * @param languageTag Language tag
+ * @returns The usable language tag
+ */
+function getUsableLanguageTag(languageTag: string) {
+  for (const supported of USABLE_LANGUAGES) {
+    // Check if the language tag has a matching language tag that we support
+    if (languageTag === supported.languageTag) {
+      return supported.languageTag;
+    }
+
+    // Check if the language code component of the language tag has a matching language code that we support
+    const languageCode = extractLanguageCode(languageTag);
 
-  const nonRegionalLocale = locale.split('-')[0];
+    if (languageCode === supported.languageTag) {
+      return supported.languageTag;
+    }
+  }
+}
+
+type ResolvedLanguageTag = {
+  // Where the `value` comes from. Not really used in the app (yet),
+  // but it's useful for understanding the resolution implementation and for debugging purposes.
+  source: 'fallback' | 'system' | 'selected';
+  value: SupportedLanguageTag;
+};
+
+/**
+ * Determines the language tag that the app should use based on what is supported.
+ * If no supported value can be determined, falls back to using `'en'` as the resolved language tag value.
+ *
+ * If a language has explicitly been selected, returns that value. If following system preferences, tries to find a supported value.
+ * If no supported language tag can be determined from the system settings, falls back to using `'en'`.
+ *
+ * @returns The resolved language tag. `source` represents where the tag comes from and `value` represents the actual tag string
+ */
+export function getAppLanguageTag({
+  localeState,
+  systemLanguageTags,
+}: {
+  localeState: LocaleState;
+  systemLanguageTags: Array<string>;
+}): ResolvedLanguageTag {
+  if (!localeState.useSystemPreferences) {
+    return {
+      source: 'selected',
+      value: localeState.languageTag,
+    };
+  }
+
+  for (const t of systemLanguageTags) {
+    const usableSystemLanguageTag = getUsableLanguageTag(t);
+
+    if (usableSystemLanguageTag) {
+      return {source: 'system', value: usableSystemLanguageTag};
+    }
+  }
+
+  return {source: 'fallback', value: 'en'};
+}
 
-  if (SUPPORTED_LANGUAGES.find(lang => lang.locale === nonRegionalLocale))
-    return nonRegionalLocale as SupportedLanguageLocale;
+/**
+ * Really naïve function to get the language code component of a IETF BCP 47 language tag
+ *
+ * @param languageTag Language tag
+ * @returns Language code
+ */
+export function extractLanguageCode(languageTag: string): string {
+  // The language code is always the first component of a the language tag
+  // (https://en.wikipedia.org/wiki/IETF_language_tag#Syntax_of_language_tags)
+  return languageTag.split('-')[0]!;
 }
diff --git a/src/frontend/metrics/AppDiagnosticMetrics.ts b/src/frontend/metrics/AppDiagnosticMetrics.ts
index 577898b2ac..64c4297acf 100644
--- a/src/frontend/metrics/AppDiagnosticMetrics.ts
+++ b/src/frontend/metrics/AppDiagnosticMetrics.ts
@@ -4,13 +4,10 @@ import * as Application from 'expo-application';
 import {getLastKnownPositionAsync} from 'expo-location';
 import {AppState, Platform} from 'react-native';
 import {storage} from '../hooks/persistedState/createPersistedState';
-import {
-  getDeviceLanguageTag,
-  usePersistedLocale,
-} from '../hooks/persistedState/usePersistedLocale';
 import {assert} from '../lib/assert';
 import {MINUTE_MS, formatIsoUtc, parseIsoUtc} from '../lib/date';
 import {first} from '../lib/first';
+import {type SupportedLanguageTag} from '../lib/intl';
 import {last} from '../lib/last';
 import {maybeJsonParse} from '../lib/maybeJsonParse';
 import {OneAtATimeQueue} from '../lib/OneAtATimeQueue';
@@ -49,13 +46,20 @@ async function getCountry(): Promise<undefined | string> {
   return first(countries);
 }
 
-async function generateAppDiagnosticMetricsData(): Promise<AppDiagnosticMetricsReport> {
+async function generateAppDiagnosticMetricsData({
+  appLanguageTag,
+  deviceLanguageTag,
+}: {
+  appLanguageTag: SupportedLanguageTag;
+  deviceLanguageTag: string;
+}): Promise<AppDiagnosticMetricsReport> {
   const result: AppDiagnosticMetricsReport = {
     dateGenerated: formatIsoUtc(new Date()),
     os: Platform.OS,
     osVersion: Platform.Version,
-    deviceLocale: getDeviceLanguageTag(),
-    appLocale: usePersistedLocale.getState().locale,
+    // TODO: Probably more useful to get all of the preferred locales
+    deviceLocale: deviceLanguageTag,
+    appLocale: appLanguageTag,
     country: await getCountry(),
   };
 
@@ -84,7 +88,18 @@ export class AppDiagnosticMetrics {
   #update;
   #updateQueue = new OneAtATimeQueue();
 
-  constructor() {
+  #getLocaleInfo;
+
+  constructor({
+    getLocaleInfo,
+  }: {
+    getLocaleInfo: () => {
+      appLanguageTag: SupportedLanguageTag;
+      deviceLanguageTag: string;
+    };
+  }) {
+    this.#getLocaleInfo = getLocaleInfo;
+
     this.#update = () => {
       this.#updateQueue.add(() => this.#doUpdate());
     };
@@ -158,7 +173,10 @@ export class AppDiagnosticMetrics {
     if (!hasReportForToday(queue)) {
       queue = {
         ...queue,
-        reports: [...queue.reports, await generateAppDiagnosticMetricsData()],
+        reports: [
+          ...queue.reports,
+          await generateAppDiagnosticMetricsData(this.#getLocaleInfo()),
+        ],
       };
       hasChangedQueue = true;
     }
diff --git a/src/frontend/screens/Settings/AppSettings/LanguageSettings.tsx b/src/frontend/screens/Settings/AppSettings/LanguageSettings.tsx
index 28e3c63c83..1b37944fc2 100644
--- a/src/frontend/screens/Settings/AppSettings/LanguageSettings.tsx
+++ b/src/frontend/screens/Settings/AppSettings/LanguageSettings.tsx
@@ -1,10 +1,14 @@
 import * as React from 'react';
+import {defineMessages, useIntl} from 'react-intl';
 import {ScrollView} from 'react-native';
-import {defineMessages} from 'react-intl';
-import {usePersistedLocale} from '../../../hooks/persistedState/usePersistedLocale';
-import {SUPPORTED_LANGUAGES} from '../../../lib/intl';
+
+import {
+  useLocaleActions,
+  useLocaleState,
+} from '../../../contexts/LocaleStoreContext';
+import {USABLE_LANGUAGES} from '../../../lib/intl';
 import {SelectOne} from '../../../sharedComponents/SelectOne';
-import {NativeNavigationComponent} from '../../../sharedTypes/navigation';
+import {type NativeNavigationComponent} from '../../../sharedTypes/navigation';
 
 const m = defineMessages({
   title: {
@@ -12,29 +16,45 @@ const m = defineMessages({
     defaultMessage: 'Language',
     description: 'Title language settings screen',
   },
+  followSystemPreferences: {
+    id: 'screens.LanguageSettings.followSystemPreferences',
+    defaultMessage: 'Follow system preferences',
+    description: 'Label for language option to follow system preferences',
+  },
 });
 
+const LANGUAGE_OPTIONS = USABLE_LANGUAGES.map(
+  ({languageTag, nativeName, englishName}) => ({
+    value: languageTag,
+    label: nativeName,
+    hint: englishName,
+  }),
+);
+
 export const LanguageSettings: NativeNavigationComponent<
   'LanguageSettings'
 > = ({navigation}) => {
-  const locale = usePersistedLocale(store => store.locale);
-  const setLocale = usePersistedLocale(store => store.setLocale);
+  const {formatMessage: t} = useIntl();
+  const locale = useLocaleState();
+  const {setLanguageTag} = useLocaleActions();
 
-  const options = SUPPORTED_LANGUAGES.map(
-    ({locale: supportedLocale, nativeName, englishName}) => ({
-      value: supportedLocale,
-      label: nativeName,
-      hint: englishName,
-    }),
-  );
+  const options = React.useMemo(() => {
+    return [
+      {
+        value: null,
+        label: t(m.followSystemPreferences),
+      },
+      ...LANGUAGE_OPTIONS,
+    ];
+  }, [t]);
 
   return (
     <ScrollView testID="languageScrollView">
       <SelectOne
-        value={locale}
+        value={locale.languageTag}
         options={options}
-        onChange={selectedLocale => {
-          setLocale(selectedLocale);
+        onChange={selected => {
+          setLanguageTag(selected);
           navigation.popTo('AppSettings');
         }}
       />
diff --git a/tests/e2e/specs/settings/language.test.ts b/tests/e2e/specs/settings/language.test.ts
index 584d88e118..35d1bf0394 100644
--- a/tests/e2e/specs/settings/language.test.ts
+++ b/tests/e2e/specs/settings/language.test.ts
@@ -12,6 +12,8 @@ describe('Settings - Language Settings Flow', () => {
 
     const languageOption = await $(byTextMatches('Language'));
     await languageOption.click();
+
+    // TODO: Add assertion about the initially selected option on this screen
   });
 
   it('should scroll to Spanish, select it, and confirm language change', async () => {


Reviews:
user: login: achou11
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-26T16:50:57Z
user: login: cimigree
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T20:29:07Z
user: login: cimigree
body: Great idea to have Follow system preferences. That makes things so, so much clearer.
Look what happens with the app as soon as I change the system preference?
<image src="https://github.com/user-attachments/assets/d0d66aeb-5367-4547-890a-10d9deff9076" width="250" />
Very cool!
I switched back languages back and forth from English to French to Spanish in and out of the app, and it was quite responsive -- no race conditions that I could see
Loving those tests! They seem very comprehensive.
Since we are not using the source in the rest of the app, it might be better to simplify the API and just return the language value?
What is the migration plan? Sorry if I missed that. Or are we not worried about that?



state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T20:29:59Z
user: login: achou11
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-27T20:41:06Z
user: login: achou11
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-27T20:49:26Z

Review comments:
diff_hunk: @@ -0,0 +1,17 @@
+import {useLocales} from 'expo-localization';
+import {useMemo} from 'react';
+
+import {useLocaleState} from '../contexts/LocaleStoreContext';
+import {getAppLanguageTag} from '../lib/intl';
+
+export function useAppLanguageTag() {
path: src/frontend/hooks/useAppLanguageTag.ts
user: login: achou11
body: This currently returns an object that has this representation:

```ts
type ResolvedLanguageTag = {
  source: 'fallback' | 'system' | 'selected';
  value: SupportedLanguageTag;
};
```

The `source` field is not really used in the app, but I find it potentially helpful for debugging. Open to just returning the `value` field when it comes to this hook specifically though.
created_at: 2025-03-26T16:39:04Z
author_association: MEMBER
diff_hunk: @@ -47,24 +68,85 @@ function deriveSupportedLanguages(
   return result;
 }
 
-function isSupportedLanguageLocale(
-  locale: string,
-): locale is SupportedLanguageLocale {
-  return locale in LANGUAGES;
+export function isSupportedLanguageTag(
+  value: string,
+): value is SupportedLanguageTag {
+  return value in LANGUAGES;
 }
 
-// Device locale can be regional e.g. `en-US` but we might only have
-// translations for `en`. If we don't have translations for a given device
-// language, then we ignore it and fallback to `en` or the user selected
-// language for the app
-export function getSupportedLocale(
-  locale: string,
-): SupportedLanguageLocale | undefined {
-  if (SUPPORTED_LANGUAGES.find(lang => lang.locale === locale))
-    return locale as SupportedLanguageLocale;
+/**
+ * Returns the variant of a language tag that the app can use to show translated messages.
+ * If we have translations that match the primary language code component of a specified language tag,
+ * we return the language code as the language tag (since all language codes are valid language tags).
+ *
+ * Example: if the language tag is `'en-GB'` but we only have translations for `'en'`, then `'en'` is returned.
+ *
+ * @param languageTag Language tag
+ * @returns The usable language tag
+ */
+function getUsableLanguageTag(languageTag: string) {
+  for (const supported of USABLE_LANGUAGES) {
+    // Check if the language tag has a matching language tag that we support
+    if (languageTag === supported.languageTag) {
+      return supported.languageTag;
+    }
+
+    // Check if the language code component of the language tag has a matching language code that we support
+    const languageCode = extractLanguageCode(languageTag);
 
-  const nonRegionalLocale = locale.split('-')[0];
+    if (languageCode === supported.languageTag) {
+      return supported.languageTag;
+    }
+  }
+}
+
+type ResolvedLanguageTag = {
+  source: 'fallback' | 'system' | 'selected';
path: src/frontend/lib/intl.ts
user: login: cimigree
body: Should there be a comment to explain the source part and when/ where it will be used?
created_at: 2025-03-27T20:29:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -47,24 +68,85 @@ function deriveSupportedLanguages(
   return result;
 }
 
-function isSupportedLanguageLocale(
-  locale: string,
-): locale is SupportedLanguageLocale {
-  return locale in LANGUAGES;
+export function isSupportedLanguageTag(
+  value: string,
+): value is SupportedLanguageTag {
+  return value in LANGUAGES;
 }
 
-// Device locale can be regional e.g. `en-US` but we might only have
-// translations for `en`. If we don't have translations for a given device
-// language, then we ignore it and fallback to `en` or the user selected
-// language for the app
-export function getSupportedLocale(
-  locale: string,
-): SupportedLanguageLocale | undefined {
-  if (SUPPORTED_LANGUAGES.find(lang => lang.locale === locale))
-    return locale as SupportedLanguageLocale;
+/**
+ * Returns the variant of a language tag that the app can use to show translated messages.
+ * If we have translations that match the primary language code component of a specified language tag,
+ * we return the language code as the language tag (since all language codes are valid language tags).
+ *
+ * Example: if the language tag is `'en-GB'` but we only have translations for `'en'`, then `'en'` is returned.
+ *
+ * @param languageTag Language tag
+ * @returns The usable language tag
+ */
+function getUsableLanguageTag(languageTag: string) {
+  for (const supported of USABLE_LANGUAGES) {
+    // Check if the language tag has a matching language tag that we support
+    if (languageTag === supported.languageTag) {
+      return supported.languageTag;
+    }
+
+    // Check if the language code component of the language tag has a matching language code that we support
+    const languageCode = extractLanguageCode(languageTag);
 
-  const nonRegionalLocale = locale.split('-')[0];
+    if (languageCode === supported.languageTag) {
+      return supported.languageTag;
+    }
+  }
+}
+
+type ResolvedLanguageTag = {
+  source: 'fallback' | 'system' | 'selected';
path: src/frontend/lib/intl.ts
user: login: achou11
body: Yeah I'll leave a comment mentioning that it's mostly helpful for understanding the implementation and debugging
created_at: 2025-03-27T20:41:06Z
author_association: MEMBER
diff_hunk: @@ -47,24 +68,85 @@ function deriveSupportedLanguages(
   return result;
 }
 
-function isSupportedLanguageLocale(
-  locale: string,
-): locale is SupportedLanguageLocale {
-  return locale in LANGUAGES;
+export function isSupportedLanguageTag(
+  value: string,
+): value is SupportedLanguageTag {
+  return value in LANGUAGES;
 }
 
-// Device locale can be regional e.g. `en-US` but we might only have
-// translations for `en`. If we don't have translations for a given device
-// language, then we ignore it and fallback to `en` or the user selected
-// language for the app
-export function getSupportedLocale(
-  locale: string,
-): SupportedLanguageLocale | undefined {
-  if (SUPPORTED_LANGUAGES.find(lang => lang.locale === locale))
-    return locale as SupportedLanguageLocale;
+/**
+ * Returns the variant of a language tag that the app can use to show translated messages.
+ * If we have translations that match the primary language code component of a specified language tag,
+ * we return the language code as the language tag (since all language codes are valid language tags).
+ *
+ * Example: if the language tag is `'en-GB'` but we only have translations for `'en'`, then `'en'` is returned.
+ *
+ * @param languageTag Language tag
+ * @returns The usable language tag
+ */
+function getUsableLanguageTag(languageTag: string) {
+  for (const supported of USABLE_LANGUAGES) {
+    // Check if the language tag has a matching language tag that we support
+    if (languageTag === supported.languageTag) {
+      return supported.languageTag;
+    }
+
+    // Check if the language code component of the language tag has a matching language code that we support
+    const languageCode = extractLanguageCode(languageTag);
 
-  const nonRegionalLocale = locale.split('-')[0];
+    if (languageCode === supported.languageTag) {
+      return supported.languageTag;
+    }
+  }
+}
+
+type ResolvedLanguageTag = {
+  source: 'fallback' | 'system' | 'selected';
path: src/frontend/lib/intl.ts
user: login: achou11
body: addressed via https://github.com/digidem/comapeo-mobile/pull/1063/commits/877e36770be6cf58676dbe30ed88cb3eadcb52a6
created_at: 2025-03-27T20:49:26Z
author_association: MEMBER

PR comments:
user: login: achou11
created_at: 2025-03-27T17:22:59Z
body: Reminder to myself that if there's an e2e test related to the settings screen, I should update it to account for this new UI. ~Based on the fact that the CI passed, my guess is that there isn't. If that's the case, wondering if it'd be worth doing as part of this PR or as a follow-up~ EDIT: ah looks like there are tests. will update them to confirm that the initially selected option is this new option.

https://github.com/digidem/comapeo-mobile/blob/629129f7a45b9d447dcb03c3436b35ebdc4057ce/e2e/settings/language.yaml

https://github.com/digidem/comapeo-mobile/blob/629129f7a45b9d447dcb03c3436b35ebdc4057ce/tests/e2e/specs/settings/language.test.ts
author_association: MEMBER
user: login: achou11
created_at: 2025-03-27T18:45:56Z
body: > Reminder to myself that if there's an e2e test related to the settings screen, I should update it to account for this new UI.

I have a clear idea of the test I want to add (basically adding an assertion [here](https://github.com/digidem/comapeo-mobile/blob/629129f7a45b9d447dcb03c3436b35ebdc4057ce/tests/e2e/specs/settings/language.test.ts#L6-L15) but I think it'll require a decent amount of changes to do this properly. From my perspective, the general steps would be:

1. Add the appropriate [accessibility attributes](https://reactnative.dev/docs/0.76/accessibility#accessibilitystate) to the relevant element(s) within the `SelectOne` component (looking specifically at [`accessibilityState`](https://reactnative.dev/docs/0.76/accessibility#accessibilitystate) and [`aria-checked`](https://reactnative.dev/docs/0.76/accessibility#aria-checked))

2. Create a helper wdio selector to query `checked` elements ([android docs](https://developer.android.com/reference/androidx/test/uiautomator/UiSelector#checked(boolean)))

3. Update the relevant test to make use of the custom selector and write the assertion

I think (1) will require touching a decent number of things that I'm currently a little unfamiliar with, so I'm leaning towards doing this work as a follow-up as opposed to this PR. Open to other thoughts though
author_association: MEMBER
user: login: achou11
created_at: 2025-03-27T20:39:54Z
body: > Since we are not using the source in the rest of the app, it might be better to simplify the API and just return the language value?

Yeah I can update the `useLanguageTag` hook to just return the language tag string. I'll keep the helper the same because I do find it easier for understanding the implementation and debugging.

>  What is the migration plan? Sorry if I missed that. Or are we not worried about that?

There is no migration plan. Based on discussions we've had, we're okay with everyone being "reverted" back to `Follow system preferences` option and requiring them to go through the process of choosing a different language if needed
author_association: MEMBER