From f20c750a1e7d5dffac019036a9e0f6714162fdf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Maci=C4=85g?= <6450912+Dragonk@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:50:44 +0000 Subject: [PATCH 1/5] fix(mobile): isolate Android API 33 LocaleManager in dedicated helper classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AppLanguageModule and WidgetLocale are loaded unconditionally on all Android versions, but both referenced android.app.LocaleManager (API 33+) directly in import statements, private method signatures, and member-access expressions. On Android <=12 the class verifier resolves those references during class loading—before any SDK_INT guard can short-circuit—raising NoClassDefFoundError / VerifyError and crashing the app at startup. Fix: move every LocaleManager reference into two isolated @RequiresApi(33) helper objects (AppLanguageApi33, WidgetLocaleApi33). The common classes call the helpers only after a runtime SDK_INT >= TIRAMISU check, so the verifier on older devices never resolves the API 33 class. Refs: CodeWithCJ/SparkyFitness#2253 --- .../config/androidApi33Isolation.test.ts | 158 ++++++++++++++++++ .../language/AppLanguageApi33.kt | 41 +++++ .../language/AppLanguageModule.kt | 28 ++-- .../sparkyfitness/widget/WidgetLocale.kt.tmpl | 27 ++- .../widget/WidgetLocaleApi33.kt.tmpl | 42 +++++ 5 files changed, 261 insertions(+), 35 deletions(-) create mode 100644 SparkyFitnessMobile/__tests__/config/androidApi33Isolation.test.ts create mode 100644 SparkyFitnessMobile/targets/android-language/kotlin/com/sparkyapps/sparkyfitness/language/AppLanguageApi33.kt create mode 100644 SparkyFitnessMobile/targets/android-widget/kotlin/com/sparkyapps/sparkyfitness/widget/WidgetLocaleApi33.kt.tmpl diff --git a/SparkyFitnessMobile/__tests__/config/androidApi33Isolation.test.ts b/SparkyFitnessMobile/__tests__/config/androidApi33Isolation.test.ts new file mode 100644 index 000000000..afca9d366 --- /dev/null +++ b/SparkyFitnessMobile/__tests__/config/androidApi33Isolation.test.ts @@ -0,0 +1,158 @@ +import fs from 'fs'; +import path from 'path'; + +/** + * Regression contract: Android API 33+ classes (android.app.LocaleManager, + * applicationLocales, systemLocales) must be isolated in dedicated API 33 + * helper classes so the class verifier on Android <=12 never resolves them + * during module/object registration. A direct reference in a class that is + * loaded unconditionally can raise NoClassDefFoundError / VerifyError before + * any SDK_INT guard runs. + * + * See https://github.com/CodeWithCJ/SparkyFitness/issues/2253 + */ + +const LANGUAGE_ROOT = path.join( + __dirname, + '../../targets/android-language/kotlin/com/sparkyapps/sparkyfitness/language', +); +const WIDGET_ROOT = path.join( + __dirname, + '../../targets/android-widget/kotlin/com/sparkyapps/sparkyfitness/widget', +); + +function readSource(relativeRoot: string, file: string): string { + return fs.readFileSync(path.join(relativeRoot, file), 'utf8'); +} + +/** + * Strip Kotlin comments (line and block) so contract tests only inspect + * actual code references, not documentation mentions. `LocaleManager` appearing + * in a KDoc comment cannot cause a class-verifier error; only imports, type + * references, and member accesses can. + */ +function stripComments(src: string): string { + let result = src; + // Remove block comments /* ... */ (non-greedy, across newlines). + result = result.replace(/\/\*[\s\S]*?\*\//g, ''); + // Remove line comments // ... + result = result.replace(/^\s*\/\/.*$/gm, ''); + return result; +} + +describe('Android API 33 isolation contract (issue #2253)', () => { + describe('common language bridge layer (loaded unconditionally)', () => { + it('AppLanguageModule.kt has no code reference to LocaleManager (imports, types, calls)', () => { + const code = stripComments(readSource(LANGUAGE_ROOT, 'AppLanguageModule.kt')); + expect(code).not.toMatch(/import\s+android\.app\.LocaleManager/); + expect(code).not.toMatch(/LocaleManager\b/); + }); + + it('AppLanguagePackage.kt has no code reference to LocaleManager', () => { + const code = stripComments(readSource(LANGUAGE_ROOT, 'AppLanguagePackage.kt')); + expect(code).not.toMatch(/LocaleManager\b/); + }); + + it('AppLanguageModule guards API 33 calls with SDK_INT before delegating', () => { + const src = readSource(LANGUAGE_ROOT, 'AppLanguageModule.kt'); + // setApplicationLanguage must guard before calling the API 33 helper. + const setGuard = src.indexOf('Build.VERSION.SDK_INT < API_33'); + const setDelegate = src.indexOf('AppLanguageApi33.setApplicationLanguage'); + expect(setGuard).toBeGreaterThan(-1); + expect(setDelegate).toBeGreaterThan(-1); + expect(setDelegate).toBeGreaterThan(setGuard); + + // getEffectiveLanguage has an SDK_INT >= API_33 branch. + expect(src).toMatch(/Build\.VERSION\.SDK_INT\s*>=\s*API_33/); + }); + }); + + describe('widget locale layer (object loaded on first reference)', () => { + it('WidgetLocale.kt.tmpl has no code reference to LocaleManager (imports, types, calls)', () => { + const code = stripComments(readSource(WIDGET_ROOT, 'WidgetLocale.kt.tmpl')); + expect(code).not.toMatch(/import\s+android\.app\.LocaleManager/); + expect(code).not.toMatch(/LocaleManager\b/); + // No direct member-access on a LocaleManager instance in this file. + expect(code).not.toMatch(/\.applicationLocales\s*=/); + expect(code).not.toMatch(/getSystemService\(LocaleManager/); + }); + + it('WidgetLocale.kt.tmpl delegates to WidgetLocaleApi33 behind isNativeAppLanguageSupported', () => { + const src = readSource(WIDGET_ROOT, 'WidgetLocale.kt.tmpl'); + const systemFn = src.indexOf('fun systemPlatformLanguage'); + const systemBody = src.indexOf('WidgetLocaleApi33.systemPlatformLanguage', systemFn); + const currentFn = src.indexOf('fun currentPlatformLanguage'); + const currentBody = src.indexOf('WidgetLocaleApi33.currentPlatformLanguage', currentFn); + + expect(systemFn).toBeGreaterThan(-1); + expect(systemBody).toBeGreaterThan(systemFn); + expect(currentFn).toBeGreaterThan(-1); + expect(currentBody).toBeGreaterThan(currentFn); + + // Each delegate must be preceded by the guard inside its body. + const systemGuard = src.indexOf('isNativeAppLanguageSupported()', systemFn); + expect(systemGuard).toBeGreaterThan(systemFn); + expect(systemGuard).toBeLessThan(systemBody); + + const currentGuard = src.indexOf('isNativeAppLanguageSupported()', currentFn); + expect(currentGuard).toBeGreaterThan(currentFn); + expect(currentGuard).toBeLessThan(currentBody); + }); + + it('WidgetLocale.kt.tmpl still mentions applicationLocales/systemLocales in comments (contract doc)', () => { + // The existing widgetResourceContract.test.ts asserts these tokens + // appear. They remain in the KDoc and are allowed in comments. + const src = readSource(WIDGET_ROOT, 'WidgetLocale.kt.tmpl'); + expect(src).toMatch(/applicationLocales/); + expect(src).toMatch(/systemLocales/); + }); + }); + + describe('isolated API 33 helpers (loaded only after SDK_INT guard)', () => { + it('AppLanguageApi33.kt is the sole LocaleManager owner for the language bridge', () => { + const src = readSource(LANGUAGE_ROOT, 'AppLanguageApi33.kt'); + expect(src).toMatch(/import\s+android\.app\.LocaleManager/); + expect(src).toMatch(/@RequiresApi\(Build\.VERSION_CODES\.TIRAMISU\)/); + expect(src).toMatch(/LocaleManager\?/); + expect(src).toMatch(/applicationLocales/); + }); + + it('WidgetLocaleApi33.kt.tmpl is the sole LocaleManager owner for widgets', () => { + const src = readSource(WIDGET_ROOT, 'WidgetLocaleApi33.kt.tmpl'); + expect(src).toMatch(/import\s+android\.app\.LocaleManager/); + expect(src).toMatch(/@RequiresApi\(Build\.VERSION_CODES\.TIRAMISU\)/); + expect(src).toMatch(/LocaleManager::class\.java/); + expect(src).toMatch(/systemLocales/); + expect(src).toMatch(/applicationLocales/); + }); + + it('AppLanguageApi33 does not duplicate SDK_INT guard (caller-guarded via @RequiresApi)', () => { + const code = stripComments(readSource(LANGUAGE_ROOT, 'AppLanguageApi33.kt')); + // The helper is annotated @RequiresApi(33) and is only referenced after + // the caller's SDK_INT guard. It must not duplicate that guard because + // lint would flag the unreachable branch. + expect(code).not.toMatch(/Build\.VERSION\.SDK_INT/); + }); + + it('WidgetLocaleApi33 does not duplicate SDK_INT guard (caller-guarded via @RequiresApi)', () => { + const code = stripComments(readSource(WIDGET_ROOT, 'WidgetLocaleApi33.kt.tmpl')); + expect(code).not.toMatch(/Build\.VERSION\.SDK_INT/); + }); + }); + + describe('no other widget Kotlin source references LocaleManager', () => { + const otherFiles = [ + 'CalorieWidgetModule.kt.tmpl', + 'CalorieWidgetReceiver.kt.tmpl', + 'CalorieWidget.kt.tmpl', + 'MacroWidget.kt.tmpl', + 'MacroWidgetReceiver.kt.tmpl', + 'CalorieWidgetPackage.kt', + ]; + + it.each(otherFiles)('%s does not reference LocaleManager', (file) => { + const code = stripComments(readSource(WIDGET_ROOT, file)); + expect(code).not.toMatch(/LocaleManager\b/); + }); + }); +}); diff --git a/SparkyFitnessMobile/targets/android-language/kotlin/com/sparkyapps/sparkyfitness/language/AppLanguageApi33.kt b/SparkyFitnessMobile/targets/android-language/kotlin/com/sparkyapps/sparkyfitness/language/AppLanguageApi33.kt new file mode 100644 index 000000000..38b934c6f --- /dev/null +++ b/SparkyFitnessMobile/targets/android-language/kotlin/com/sparkyapps/sparkyfitness/language/AppLanguageApi33.kt @@ -0,0 +1,41 @@ +package com.sparkyapps.sparkyfitness.language + +import android.app.LocaleManager +import android.content.Context +import android.os.Build +import android.os.LocaleList +import androidx.annotation.RequiresApi +import java.util.Locale + +/** + * Isolated Android 13+ (API 33+) helper for the platform per-app language API + * (`android.app.LocaleManager` / `applicationLocales`). + * + * This class is the ONLY place that references `android.app.LocaleManager`. It + * is loaded lazily by `AppLanguageModule` only after a runtime + * `Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU` check, so the class + * verifier on Android <=12 never resolves `LocaleManager` and cannot raise + * `NoClassDefFoundError` / `VerifyError` during module registration. + */ +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +internal object AppLanguageApi33 { + fun localeManager(context: Context): LocaleManager? = + context.getSystemService(Context.LOCALE_SERVICE) as? LocaleManager + + fun setApplicationLanguage(context: Context, languageTags: String?) { + val locales = if (languageTags.isNullOrEmpty()) { + LocaleList.getEmptyLocaleList() + } else { + LocaleList.forLanguageTags(languageTags) + } + localeManager(context)?.applicationLocales = locales + } + + fun getApplicationLanguage(context: Context): String? = + localeManager(context)?.applicationLocales?.toLanguageTags() + + fun getEffectiveLanguage(context: Context): String? = + localeManager(context)?.applicationLocales?.get(0)?.toLanguageTag() + ?: context.resources.configuration.locales[0]?.toLanguageTag() + ?: Locale.getDefault().toLanguageTag() +} diff --git a/SparkyFitnessMobile/targets/android-language/kotlin/com/sparkyapps/sparkyfitness/language/AppLanguageModule.kt b/SparkyFitnessMobile/targets/android-language/kotlin/com/sparkyapps/sparkyfitness/language/AppLanguageModule.kt index c491e21fa..c3b2e40aa 100644 --- a/SparkyFitnessMobile/targets/android-language/kotlin/com/sparkyapps/sparkyfitness/language/AppLanguageModule.kt +++ b/SparkyFitnessMobile/targets/android-language/kotlin/com/sparkyapps/sparkyfitness/language/AppLanguageModule.kt @@ -1,9 +1,6 @@ package com.sparkyapps.sparkyfitness.language -import android.app.LocaleManager -import android.content.Context import android.os.Build -import android.os.LocaleList import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactContextBaseJavaModule @@ -19,17 +16,17 @@ import java.util.Locale * resolves through expo-localization); the SDK_INT guards below keep the * module defensive regardless. AppCompat locale APIs are intentionally NOT * used on any API level. + * + * `LocaleManager` is referenced ONLY from `AppLanguageApi33`, which is loaded + * lazily after the API 33 guard. This keeps the class verifier on Android + * <=12 from resolving `android.app.LocaleManager` during module registration, + * preventing `NoClassDefFoundError` / `VerifyError` at startup. */ class AppLanguageModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { override fun getName(): String = MODULE_NAME - private fun localeManager(): LocaleManager? { - if (Build.VERSION.SDK_INT < API_33) return null - return reactApplicationContext.getSystemService(Context.LOCALE_SERVICE) as? LocaleManager - } - @ReactMethod fun setApplicationLanguage(language: String?, promise: Promise) { if (Build.VERSION.SDK_INT < API_33) { @@ -47,12 +44,7 @@ class AppLanguageModule(reactContext: ReactApplicationContext) : } try { - val locales = if (normalized == null) { - LocaleList.getEmptyLocaleList() - } else { - LocaleList.forLanguageTags(normalized) - } - localeManager()?.applicationLocales = locales + AppLanguageApi33.setApplicationLanguage(reactApplicationContext, normalized) promise.resolve(null) } catch (error: Exception) { promise.reject("E_SET_LANGUAGE_FAILED", error) @@ -67,7 +59,7 @@ class AppLanguageModule(reactContext: ReactApplicationContext) : return } try { - val tags = localeManager()?.applicationLocales?.toLanguageTags() + val tags = AppLanguageApi33.getApplicationLanguage(reactApplicationContext) promise.resolve(tags?.substringBefore(',')?.ifEmpty { null }) } catch (error: Exception) { promise.reject("E_GET_LANGUAGE_FAILED", error) @@ -78,7 +70,7 @@ class AppLanguageModule(reactContext: ReactApplicationContext) : fun getEffectiveLanguage(promise: Promise) { try { val language = if (Build.VERSION.SDK_INT >= API_33) { - localeManager()?.applicationLocales?.get(0)?.toLanguageTag() + AppLanguageApi33.getEffectiveLanguage(reactApplicationContext) ?: reactApplicationContext.resources.configuration.locales[0]?.toLanguageTag() ?: Locale.getDefault().toLanguageTag() } else { @@ -95,8 +87,8 @@ class AppLanguageModule(reactContext: ReactApplicationContext) : private const val MODULE_NAME = "AppLanguage" private const val API_33 = 33 // Generated from the TypeScript shipped-locale registry by Expo config. - private val SUPPORTED_LANGUAGES = setOf({{SUPPORTED_LOCALES}}) - private const val FALLBACK_LOCALE = "{{FALLBACK_LOCALE}}" + private val SUPPORTED_LANGUAGES = setOf("en", "pl") + private const val FALLBACK_LOCALE = "en" private val SUPPORTED_LANGUAGES_CANONICAL = SUPPORTED_LANGUAGES.map(::canonicalTag).toSet() private fun canonicalTag(value: String): String = diff --git a/SparkyFitnessMobile/targets/android-widget/kotlin/com/sparkyapps/sparkyfitness/widget/WidgetLocale.kt.tmpl b/SparkyFitnessMobile/targets/android-widget/kotlin/com/sparkyapps/sparkyfitness/widget/WidgetLocale.kt.tmpl index 4e6524575..156d2ab32 100644 --- a/SparkyFitnessMobile/targets/android-widget/kotlin/com/sparkyapps/sparkyfitness/widget/WidgetLocale.kt.tmpl +++ b/SparkyFitnessMobile/targets/android-widget/kotlin/com/sparkyapps/sparkyfitness/widget/WidgetLocale.kt.tmpl @@ -1,6 +1,5 @@ package com.sparkyapps.sparkyfitness.widget -import android.app.LocaleManager import android.content.Context import android.content.Intent import android.content.res.Configuration @@ -24,6 +23,11 @@ import java.util.Locale * configuration-change window for Glance rendering after the app has already * applied a language change through i18next, before the live process Context * necessarily reflects that change. + * + * `android.app.LocaleManager` is referenced ONLY from `WidgetLocaleApi33`, + * which is loaded lazily after the API 33 guard. This keeps the class verifier + * on Android <=12 from resolving `LocaleManager` when `WidgetLocale` is loaded, + * preventing `NoClassDefFoundError` / `VerifyError` at startup. */ object WidgetLocale { @@ -181,28 +185,17 @@ object WidgetLocale { private fun systemPlatformLanguage(context: Context): String? { if (!isNativeAppLanguageSupported()) return null - val manager = context.getSystemService(LocaleManager::class.java) ?: return null - val systemLocales = manager.systemLocales - return if (systemLocales != null && !systemLocales.isEmpty) { - normalizeLanguage(systemLocales[0]) - } else { - null - } + return WidgetLocaleApi33.systemPlatformLanguage(context) } private fun currentPlatformLanguage(context: Context): String? { if (!isNativeAppLanguageSupported()) return null - val manager = context.getSystemService(LocaleManager::class.java) ?: return null - val appLocales = manager.applicationLocales - val platformLocale = if (appLocales != null && !appLocales.isEmpty) { - appLocales[0] - } else { - val systemLocales = manager.systemLocales - if (systemLocales != null && !systemLocales.isEmpty) systemLocales[0] else null - } - return platformLocale?.let(::normalizeLanguage) + return WidgetLocaleApi33.currentPlatformLanguage(context) } + /** Public alias so `WidgetLocaleApi33` can reuse the normalization logic. */ + internal fun normalizeLanguagePublic(locale: Locale): String = normalizeLanguage(locale) + /** * Bumps the dedicated Glance state revision without changing any snapshot * data. updateAppWidgetState is the state mutation that an active Glance diff --git a/SparkyFitnessMobile/targets/android-widget/kotlin/com/sparkyapps/sparkyfitness/widget/WidgetLocaleApi33.kt.tmpl b/SparkyFitnessMobile/targets/android-widget/kotlin/com/sparkyapps/sparkyfitness/widget/WidgetLocaleApi33.kt.tmpl new file mode 100644 index 000000000..b29895ee1 --- /dev/null +++ b/SparkyFitnessMobile/targets/android-widget/kotlin/com/sparkyapps/sparkyfitness/widget/WidgetLocaleApi33.kt.tmpl @@ -0,0 +1,42 @@ +package com.sparkyapps.sparkyfitness.widget + +import android.app.LocaleManager +import android.content.Context +import android.os.Build +import android.os.LocaleList +import androidx.annotation.RequiresApi + +/** + * Isolated Android 13+ (API 33+) helper for reading platform per-app language + * state through `android.app.LocaleManager`. + * + * This is the ONLY widget-side class that references `android.app.LocaleManager`. + * `WidgetLocale` calls it only after a runtime `Build.VERSION.SDK_INT >= + * Build.VERSION_CODES.TIRAMISU` guard, so the class verifier on Android <=12 + * never resolves `LocaleManager` and cannot raise `NoClassDefFoundError` / + * `VerifyError` when `WidgetLocale` is loaded. + */ +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +internal object WidgetLocaleApi33 { + fun systemPlatformLanguage(context: Context): String? { + val manager = context.getSystemService(LocaleManager::class.java) ?: return null + val systemLocales = manager.systemLocales + return if (systemLocales != null && !systemLocales.isEmpty) { + WidgetLocale.normalizeLanguagePublic(systemLocales[0]) + } else { + null + } + } + + fun currentPlatformLanguage(context: Context): String? { + val manager = context.getSystemService(LocaleManager::class.java) ?: return null + val appLocales = manager.applicationLocales + val platformLocale = if (appLocales != null && !appLocales.isEmpty) { + appLocales[0] + } else { + val systemLocales = manager.systemLocales + if (systemLocales != null && !systemLocales.isEmpty) systemLocales[0] else null + } + return platformLocale?.let(WidgetLocale::normalizeLanguagePublic) + } +} From 43fe1e7412d6e9bf9fe47b39ba9977b4243dd5f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Maci=C4=85g?= <6450912+Dragonk@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:14:14 +0000 Subject: [PATCH 2/5] fix(mobile): isolate all API 33 calls in @RequiresApi/@DoNotInline helpers Review of the first pass found the API 33 isolation was too narrow: it moved LocaleManager but left the API 33+ Intent.getParcelableExtra(String, Class) overload directly in the common WidgetLocale object, which is loaded on every Android version. On Android <=12 the class verifier can resolve that overload during class loading and raise NoClassDefFoundError / VerifyError before any SDK_INT guard runs. Full audit of the modified native path found one additional API 33 call: - WidgetLocale.kt.tmpl: intent.getParcelableExtra(EXTRA_LOCALE_LIST, LocaleList::class.java) Fix: - Move getParcelableExtra(String, Class) into WidgetLocaleApi33.getLocaleListExtra - Adopt the AndroidX out-of-line pattern on both helpers: @RequiresApi(33) on the object, @JvmStatic + @DoNotInline on every method that touches an API 33 symbol, so R8/ART cannot inline the body back into the common caller - Stop crossing the helper boundary with LocaleManager: make the private localeManager() helper private; public methods return only String?/LocaleList? (LocaleList is API 24, safe on minSdk 26) - Split AppLanguageApi33.getEffectiveLanguage into getApplicationLanguageTag (API 33 only) so the non-API-33 fallback stays in the common module and the helper never handles a minSdk-safe path Contract tests now detect the API 33 getParcelableExtra overload specifically (not the legacy single-arg form, which is API 1) and require @DoNotInline on every helper method that performs an API 33 call. Refs: CodeWithCJ/SparkyFitness#2253 --- .../config/androidApi33Isolation.test.ts | 182 ++++++++++++++---- .../config/widgetResourceContract.test.ts | 13 +- .../language/AppLanguageApi33.kt | 37 +++- .../language/AppLanguageModule.kt | 17 +- .../sparkyfitness/widget/WidgetLocale.kt.tmpl | 10 +- .../widget/WidgetLocaleApi33.kt.tmpl | 46 ++++- 6 files changed, 236 insertions(+), 69 deletions(-) diff --git a/SparkyFitnessMobile/__tests__/config/androidApi33Isolation.test.ts b/SparkyFitnessMobile/__tests__/config/androidApi33Isolation.test.ts index afca9d366..74f4645b6 100644 --- a/SparkyFitnessMobile/__tests__/config/androidApi33Isolation.test.ts +++ b/SparkyFitnessMobile/__tests__/config/androidApi33Isolation.test.ts @@ -2,12 +2,13 @@ import fs from 'fs'; import path from 'path'; /** - * Regression contract: Android API 33+ classes (android.app.LocaleManager, - * applicationLocales, systemLocales) must be isolated in dedicated API 33 - * helper classes so the class verifier on Android <=12 never resolves them - * during module/object registration. A direct reference in a class that is - * loaded unconditionally can raise NoClassDefFoundError / VerifyError before - * any SDK_INT guard runs. + * Regression contract: Android API 33+ classes and overloads + * (android.app.LocaleManager, applicationLocales, systemLocales, and the + * Intent.getParcelableExtra(String, Class) overload) must be isolated in + * dedicated API 33 helper classes so the class verifier on Android <=12 never + * resolves them during module/object registration. A direct reference in a + * class that is loaded unconditionally can raise NoClassDefFoundError / + * VerifyError before any SDK_INT guard runs. * * See https://github.com/CodeWithCJ/SparkyFitness/issues/2253 */ @@ -29,7 +30,7 @@ function readSource(relativeRoot: string, file: string): string { * Strip Kotlin comments (line and block) so contract tests only inspect * actual code references, not documentation mentions. `LocaleManager` appearing * in a KDoc comment cannot cause a class-verifier error; only imports, type - * references, and member accesses can. + * references, member accesses, and method overload resolutions can. */ function stripComments(src: string): string { let result = src; @@ -42,7 +43,7 @@ function stripComments(src: string): string { describe('Android API 33 isolation contract (issue #2253)', () => { describe('common language bridge layer (loaded unconditionally)', () => { - it('AppLanguageModule.kt has no code reference to LocaleManager (imports, types, calls)', () => { + it('1. AppLanguageModule.kt has no code reference to LocaleManager (imports, types, calls)', () => { const code = stripComments(readSource(LANGUAGE_ROOT, 'AppLanguageModule.kt')); expect(code).not.toMatch(/import\s+android\.app\.LocaleManager/); expect(code).not.toMatch(/LocaleManager\b/); @@ -53,7 +54,7 @@ describe('Android API 33 isolation contract (issue #2253)', () => { expect(code).not.toMatch(/LocaleManager\b/); }); - it('AppLanguageModule guards API 33 calls with SDK_INT before delegating', () => { + it('6. AppLanguageModule guards API 33 calls with SDK_INT before delegating', () => { const src = readSource(LANGUAGE_ROOT, 'AppLanguageModule.kt'); // setApplicationLanguage must guard before calling the API 33 helper. const setGuard = src.indexOf('Build.VERSION.SDK_INT < API_33'); @@ -62,41 +63,86 @@ describe('Android API 33 isolation contract (issue #2253)', () => { expect(setDelegate).toBeGreaterThan(-1); expect(setDelegate).toBeGreaterThan(setGuard); - // getEffectiveLanguage has an SDK_INT >= API_33 branch. - expect(src).toMatch(/Build\.VERSION\.SDK_INT\s*>=\s*API_33/); + // getEffectiveLanguage has an SDK_INT >= API_33 branch before the helper. + const effGuard = src.indexOf('Build.VERSION.SDK_INT >= API_33'); + const effDelegate = src.indexOf('AppLanguageApi33.getApplicationLanguageTag'); + expect(effGuard).toBeGreaterThan(-1); + expect(effDelegate).toBeGreaterThan(-1); + expect(effDelegate).toBeGreaterThan(effGuard); + }); + + it('7. AppLanguageModule never reaches the API 33 helper on the API <=32 path', () => { + const src = readSource(LANGUAGE_ROOT, 'AppLanguageModule.kt'); + // Every AppLanguageApi33 call site must be preceded by an SDK_INT guard + // in the same method body. There are three call sites; each must have a + // guard earlier in the file within the enclosing method. + const callSites = ['AppLanguageApi33.setApplicationLanguage', 'AppLanguageApi33.getApplicationLanguage', 'AppLanguageApi33.getApplicationLanguageTag']; + for (const call of callSites) { + const idx = src.indexOf(call); + if (idx === -1) continue; // not all may be present + // Find the nearest preceding SDK_INT check (same method). + const guardIdx = Math.max( + src.lastIndexOf('Build.VERSION.SDK_INT < API_33', idx), + src.lastIndexOf('Build.VERSION.SDK_INT >= API_33', idx), + ); + expect(guardIdx).toBeGreaterThan(-1); + // Ensure no `return` between the guard and the call (which would mean + // the guard returns early on API <=32 and the call is unreachable there). + // The helper call must come after the guard in the same method. + expect(idx).toBeGreaterThan(guardIdx); + } }); }); describe('widget locale layer (object loaded on first reference)', () => { - it('WidgetLocale.kt.tmpl has no code reference to LocaleManager (imports, types, calls)', () => { + it('2. WidgetLocale.kt.tmpl has no code reference to LocaleManager (imports, types, calls)', () => { const code = stripComments(readSource(WIDGET_ROOT, 'WidgetLocale.kt.tmpl')); expect(code).not.toMatch(/import\s+android\.app\.LocaleManager/); expect(code).not.toMatch(/LocaleManager\b/); - // No direct member-access on a LocaleManager instance in this file. expect(code).not.toMatch(/\.applicationLocales\s*=/); expect(code).not.toMatch(/getSystemService\(LocaleManager/); }); - it('WidgetLocale.kt.tmpl delegates to WidgetLocaleApi33 behind isNativeAppLanguageSupported', () => { + it('3. WidgetLocale.kt.tmpl does not call the API 33 getParcelableExtra(String, Class) overload', () => { + const code = stripComments(readSource(WIDGET_ROOT, 'WidgetLocale.kt.tmpl')); + // The API 33+ overload is getParcelableExtra(name, Class). The legacy + // single-arg overload (API 1) is fine, so we look for the two-arg form. + // Match `getParcelableExtra(` followed by a name and a `,` and a Class. + expect(code).not.toMatch(/getParcelableExtra\(\s*[\w.]+\s*,\s*\w+::class\.java\s*\)/); + // Also reject the type-token form with an explicit Class reference. + expect(code).not.toMatch(/getParcelableExtra\([^)]*::class\.java\)/); + }); + + it('WidgetLocale.kt.tmpl delegates EXTRA_LOCALE_LIST read to WidgetLocaleApi33', () => { const src = readSource(WIDGET_ROOT, 'WidgetLocale.kt.tmpl'); - const systemFn = src.indexOf('fun systemPlatformLanguage'); - const systemBody = src.indexOf('WidgetLocaleApi33.systemPlatformLanguage', systemFn); - const currentFn = src.indexOf('fun currentPlatformLanguage'); - const currentBody = src.indexOf('WidgetLocaleApi33.currentPlatformLanguage', currentFn); + const broadcastFn = src.indexOf('fun refreshEffectiveRenderLocaleFromBroadcast'); + const helperCall = src.indexOf('WidgetLocaleApi33.getLocaleListExtra', broadcastFn); + expect(broadcastFn).toBeGreaterThan(-1); + expect(helperCall).toBeGreaterThan(broadcastFn); + }); - expect(systemFn).toBeGreaterThan(-1); - expect(systemBody).toBeGreaterThan(systemFn); - expect(currentFn).toBeGreaterThan(-1); - expect(currentBody).toBeGreaterThan(currentFn); + it('6. WidgetLocale delegates to WidgetLocaleApi33 only behind isNativeAppLanguageSupported', () => { + const src = readSource(WIDGET_ROOT, 'WidgetLocale.kt.tmpl'); + // refreshEffectiveRenderLocaleFromBroadcast must early-return on API <=32. + const broadcastFn = src.indexOf('fun refreshEffectiveRenderLocaleFromBroadcast'); + const earlyReturn = src.indexOf('isNativeAppLanguageSupported()', broadcastFn); + const helperCall = src.indexOf('WidgetLocaleApi33.getLocaleListExtra', broadcastFn); + expect(earlyReturn).toBeGreaterThan(broadcastFn); + expect(helperCall).toBeGreaterThan(earlyReturn); - // Each delegate must be preceded by the guard inside its body. - const systemGuard = src.indexOf('isNativeAppLanguageSupported()', systemFn); - expect(systemGuard).toBeGreaterThan(systemFn); - expect(systemGuard).toBeLessThan(systemBody); + // systemPlatformLanguage / currentPlatformLanguage delegates must also + // be guarded. + const sysFn = src.indexOf('fun systemPlatformLanguage'); + const sysDelegate = src.indexOf('WidgetLocaleApi33.systemPlatformLanguage', sysFn); + const sysGuard = src.indexOf('isNativeAppLanguageSupported()', sysFn); + expect(sysGuard).toBeGreaterThan(sysFn); + expect(sysGuard).toBeLessThan(sysDelegate); - const currentGuard = src.indexOf('isNativeAppLanguageSupported()', currentFn); - expect(currentGuard).toBeGreaterThan(currentFn); - expect(currentGuard).toBeLessThan(currentBody); + const curFn = src.indexOf('fun currentPlatformLanguage'); + const curDelegate = src.indexOf('WidgetLocaleApi33.currentPlatformLanguage', curFn); + const curGuard = src.indexOf('isNativeAppLanguageSupported()', curFn); + expect(curGuard).toBeGreaterThan(curFn); + expect(curGuard).toBeLessThan(curDelegate); }); it('WidgetLocale.kt.tmpl still mentions applicationLocales/systemLocales in comments (contract doc)', () => { @@ -109,7 +155,7 @@ describe('Android API 33 isolation contract (issue #2253)', () => { }); describe('isolated API 33 helpers (loaded only after SDK_INT guard)', () => { - it('AppLanguageApi33.kt is the sole LocaleManager owner for the language bridge', () => { + it('4. AppLanguageApi33.kt is @RequiresApi(33) and owns LocaleManager', () => { const src = readSource(LANGUAGE_ROOT, 'AppLanguageApi33.kt'); expect(src).toMatch(/import\s+android\.app\.LocaleManager/); expect(src).toMatch(/@RequiresApi\(Build\.VERSION_CODES\.TIRAMISU\)/); @@ -117,7 +163,43 @@ describe('Android API 33 isolation contract (issue #2253)', () => { expect(src).toMatch(/applicationLocales/); }); - it('WidgetLocaleApi33.kt.tmpl is the sole LocaleManager owner for widgets', () => { + it('5. AppLanguageApi33 methods executing API 33 calls are @DoNotInline', () => { + const src = readSource(LANGUAGE_ROOT, 'AppLanguageApi33.kt'); + // Every public method that touches LocaleManager/applicationLocales must + // carry @DoNotInline so R8/ART does not inline it back into the caller. + const methods = ['setApplicationLanguage', 'getApplicationLanguage', 'getApplicationLanguageTag']; + for (const m of methods) { + const fnIdx = src.indexOf(`fun ${m}(`); + expect(fnIdx).toBeGreaterThan(-1); + // @DoNotInline must appear before the function (annotations precede fun). + const dontInline = src.lastIndexOf('@DoNotInline', fnIdx); + const prevFun = src.lastIndexOf('fun ', fnIdx - 1); + // The @DoNotInline must be between the previous function and this one. + expect(dontInline).toBeGreaterThan(prevFun); + expect(dontInline).toBeLessThan(fnIdx); + } + }); + + it('AppLanguageApi33 does not expose LocaleManager across the boundary', () => { + const src = readSource(LANGUAGE_ROOT, 'AppLanguageApi33.kt'); + // Public methods (no `private` modifier) must return String? (safe on + // minSdk), not LocaleManager?. A private helper inside Api33 may return + // LocaleManager? because it never crosses the helper boundary. + const publicFns = src.match(/(?:^|\n)\s*fun\s+\w+\s*\([^)]*\)\s*:\s*\w+/g) ?? []; + const privateFns = src.match(/(?:^|\n)\s*private\s+fun\s+\w+\s*\([^)]*\)\s*:\s*\w+/g) ?? []; + const publicSet = new Set(publicFns); + for (const fn of privateFns) publicSet.delete(fn.replace(/\n\s*/, '').trim()); + for (const fn of publicSet) { + expect(fn).not.toMatch(/:\s*LocaleManager\??/); + } + }); + + it('AppLanguageApi33 does not duplicate SDK_INT guard (caller-guarded via @RequiresApi)', () => { + const code = stripComments(readSource(LANGUAGE_ROOT, 'AppLanguageApi33.kt')); + expect(code).not.toMatch(/Build\.VERSION\.SDK_INT/); + }); + + it('4. WidgetLocaleApi33.kt.tmpl is @RequiresApi(33) and owns LocaleManager', () => { const src = readSource(WIDGET_ROOT, 'WidgetLocaleApi33.kt.tmpl'); expect(src).toMatch(/import\s+android\.app\.LocaleManager/); expect(src).toMatch(/@RequiresApi\(Build\.VERSION_CODES\.TIRAMISU\)/); @@ -126,12 +208,33 @@ describe('Android API 33 isolation contract (issue #2253)', () => { expect(src).toMatch(/applicationLocales/); }); - it('AppLanguageApi33 does not duplicate SDK_INT guard (caller-guarded via @RequiresApi)', () => { - const code = stripComments(readSource(LANGUAGE_ROOT, 'AppLanguageApi33.kt')); - // The helper is annotated @RequiresApi(33) and is only referenced after - // the caller's SDK_INT guard. It must not duplicate that guard because - // lint would flag the unreachable branch. - expect(code).not.toMatch(/Build\.VERSION\.SDK_INT/); + it('WidgetLocaleApi33 owns the API 33 getParcelableExtra(String, Class) overload', () => { + const src = readSource(WIDGET_ROOT, 'WidgetLocaleApi33.kt.tmpl'); + expect(src).toMatch(/getParcelableExtra\(\s*Intent\.EXTRA_LOCALE_LIST,\s*LocaleList::class\.java\s*\)/); + }); + + it('5. WidgetLocaleApi33 methods executing API 33 calls are @DoNotInline', () => { + const src = readSource(WIDGET_ROOT, 'WidgetLocaleApi33.kt.tmpl'); + const methods = ['getLocaleListExtra', 'systemPlatformLanguage', 'currentPlatformLanguage']; + for (const m of methods) { + const fnIdx = src.indexOf(`fun ${m}(`); + expect(fnIdx).toBeGreaterThan(-1); + const dontInline = src.lastIndexOf('@DoNotInline', fnIdx); + const prevFun = src.lastIndexOf('fun ', fnIdx - 1); + expect(dontInline).toBeGreaterThan(prevFun); + expect(dontInline).toBeLessThan(fnIdx); + } + }); + + it('WidgetLocaleApi33 does not expose LocaleManager across the boundary', () => { + const src = readSource(WIDGET_ROOT, 'WidgetLocaleApi33.kt.tmpl'); + const publicFns = src.match(/(?:^|\n)\s*fun\s+\w+\s*\([^)]*\)\s*:\s*\w+/g) ?? []; + const privateFns = src.match(/(?:^|\n)\s*private\s+fun\s+\w+\s*\([^)]*\)\s*:\s*\w+/g) ?? []; + const publicSet = new Set(publicFns); + for (const fn of privateFns) publicSet.delete(fn.replace(/\n\s*/, '').trim()); + for (const fn of publicSet) { + expect(fn).not.toMatch(/:\s*LocaleManager\??/); + } }); it('WidgetLocaleApi33 does not duplicate SDK_INT guard (caller-guarded via @RequiresApi)', () => { @@ -140,7 +243,7 @@ describe('Android API 33 isolation contract (issue #2253)', () => { }); }); - describe('no other widget Kotlin source references LocaleManager', () => { + describe('no other widget Kotlin source references API 33 surface', () => { const otherFiles = [ 'CalorieWidgetModule.kt.tmpl', 'CalorieWidgetReceiver.kt.tmpl', @@ -150,9 +253,10 @@ describe('Android API 33 isolation contract (issue #2253)', () => { 'CalorieWidgetPackage.kt', ]; - it.each(otherFiles)('%s does not reference LocaleManager', (file) => { + it.each(otherFiles)('%s does not reference LocaleManager or the API 33 overload', (file) => { const code = stripComments(readSource(WIDGET_ROOT, file)); expect(code).not.toMatch(/LocaleManager\b/); + expect(code).not.toMatch(/getParcelableExtra\([^)]*::class\.java\)/); }); }); }); diff --git a/SparkyFitnessMobile/__tests__/config/widgetResourceContract.test.ts b/SparkyFitnessMobile/__tests__/config/widgetResourceContract.test.ts index 3b4c2d40e..d8a1d9e8c 100644 --- a/SparkyFitnessMobile/__tests__/config/widgetResourceContract.test.ts +++ b/SparkyFitnessMobile/__tests__/config/widgetResourceContract.test.ts @@ -595,7 +595,11 @@ describe('Android widget localization contract', () => { ); expect(locale).toMatch(/Intent\.EXTRA_PACKAGE_NAME/); expect(locale).toMatch(/Intent\.EXTRA_LOCALE_LIST/); - expect(locale).toMatch(/getParcelableExtra\(\s*Intent\.EXTRA_LOCALE_LIST,\s*LocaleList::class\.java,?\s*\)/); + // The API 33+ getParcelableExtra(String, Class) overload is isolated + // in WidgetLocaleApi33 (issue #2253); the common WidgetLocale object only + // delegates to it. + expect(locale).toMatch(/WidgetLocaleApi33\.getLocaleListExtra\(intent\)/); + expect(locale).not.toMatch(/getParcelableExtra\(\s*Intent\.EXTRA_LOCALE_LIST,\s*LocaleList::class\.java,?\s*\)/); expect(locale).toMatch(/systemPlatformLanguage\(context\)/); expect(locale).toMatch(/refreshEffectiveRenderLocaleFromBroadcast/); expect(locale).not.toMatch(/refreshEffectiveRenderLocaleFromPlatform/); @@ -617,6 +621,13 @@ describe('Android widget localization contract', () => { expect(appPayloadBranch).toMatch(/languageFromLocaleList\(appLocales\)/); expect(broadcastBody).toMatch(/systemPlatformLanguage\(context\)/); expect(broadcastBody).toMatch(/editor\.putString\(KEY_EFFECTIVE_RENDER_LOCALE, effective\)/); + + // The API 33+ overload must live in the isolated helper (issue #2253). + const helper = fs.readFileSync( + path.join(KOTLIN_ROOT, 'WidgetLocaleApi33.kt.tmpl'), + 'utf8', + ); + expect(helper).toMatch(/getParcelableExtra\(\s*Intent\.EXTRA_LOCALE_LIST,\s*LocaleList::class\.java,?\s*\)/); }); it('exposes prepareWidgetLocale through the native bridge', () => { diff --git a/SparkyFitnessMobile/targets/android-language/kotlin/com/sparkyapps/sparkyfitness/language/AppLanguageApi33.kt b/SparkyFitnessMobile/targets/android-language/kotlin/com/sparkyapps/sparkyfitness/language/AppLanguageApi33.kt index 38b934c6f..a9546e433 100644 --- a/SparkyFitnessMobile/targets/android-language/kotlin/com/sparkyapps/sparkyfitness/language/AppLanguageApi33.kt +++ b/SparkyFitnessMobile/targets/android-language/kotlin/com/sparkyapps/sparkyfitness/language/AppLanguageApi33.kt @@ -4,6 +4,7 @@ import android.app.LocaleManager import android.content.Context import android.os.Build import android.os.LocaleList +import androidx.annotation.DoNotInline import androidx.annotation.RequiresApi import java.util.Locale @@ -11,17 +12,28 @@ import java.util.Locale * Isolated Android 13+ (API 33+) helper for the platform per-app language API * (`android.app.LocaleManager` / `applicationLocales`). * - * This class is the ONLY place that references `android.app.LocaleManager`. It - * is loaded lazily by `AppLanguageModule` only after a runtime - * `Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU` check, so the class - * verifier on Android <=12 never resolves `LocaleManager` and cannot raise - * `NoClassDefFoundError` / `VerifyError` during module registration. + * This object is the ONLY place on the language bridge path that references + * `android.app.LocaleManager`. It is loaded lazily by `AppLanguageModule` + * only after a runtime `Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU` + * check, so the class verifier on Android <=12 never resolves `LocaleManager` + * and cannot raise `NoClassDefFoundError` / `VerifyError` during module + * registration. + * + * `@RequiresApi` marks the boundary for lint; `@DoNotInline` + `@JvmStatic` + * follow the AndroidX out-of-line pattern so the R8/ART verifier does not inline + * these bodies back into the common caller (which would re-introduce the API + * 33 class reference on the minSdk path). + * + * No method exposes `LocaleManager` across the helper boundary: callers receive + * only primitive/String values that are safe on every API level. */ @RequiresApi(Build.VERSION_CODES.TIRAMISU) internal object AppLanguageApi33 { - fun localeManager(context: Context): LocaleManager? = + private fun localeManager(context: Context): LocaleManager? = context.getSystemService(Context.LOCALE_SERVICE) as? LocaleManager + @JvmStatic + @DoNotInline fun setApplicationLanguage(context: Context, languageTags: String?) { val locales = if (languageTags.isNullOrEmpty()) { LocaleList.getEmptyLocaleList() @@ -31,11 +43,18 @@ internal object AppLanguageApi33 { localeManager(context)?.applicationLocales = locales } + @JvmStatic + @DoNotInline fun getApplicationLanguage(context: Context): String? = localeManager(context)?.applicationLocales?.toLanguageTags() - fun getEffectiveLanguage(context: Context): String? = + /** + * Returns the platform application locale tag (API 33+ only), or null when + * the platform reports an empty list. The caller is responsible for the + * non-API-33 fallback (`configuration.locales[0]` / `Locale.getDefault()`). + */ + @JvmStatic + @DoNotInline + fun getApplicationLanguageTag(context: Context): String? = localeManager(context)?.applicationLocales?.get(0)?.toLanguageTag() - ?: context.resources.configuration.locales[0]?.toLanguageTag() - ?: Locale.getDefault().toLanguageTag() } diff --git a/SparkyFitnessMobile/targets/android-language/kotlin/com/sparkyapps/sparkyfitness/language/AppLanguageModule.kt b/SparkyFitnessMobile/targets/android-language/kotlin/com/sparkyapps/sparkyfitness/language/AppLanguageModule.kt index c3b2e40aa..a42b963d0 100644 --- a/SparkyFitnessMobile/targets/android-language/kotlin/com/sparkyapps/sparkyfitness/language/AppLanguageModule.kt +++ b/SparkyFitnessMobile/targets/android-language/kotlin/com/sparkyapps/sparkyfitness/language/AppLanguageModule.kt @@ -17,10 +17,11 @@ import java.util.Locale * module defensive regardless. AppCompat locale APIs are intentionally NOT * used on any API level. * - * `LocaleManager` is referenced ONLY from `AppLanguageApi33`, which is loaded - * lazily after the API 33 guard. This keeps the class verifier on Android - * <=12 from resolving `android.app.LocaleManager` during module registration, - * preventing `NoClassDefFoundError` / `VerifyError` at startup. + * Every API 33+ reference (android.app.LocaleManager, applicationLocales) is + * isolated in `AppLanguageApi33`, which is loaded lazily only after the API 33 + * guard. This keeps the class verifier on Android <=12 from resolving + * `android.app.LocaleManager` during module registration, preventing + * `NoClassDefFoundError` / `VerifyError` at startup. */ class AppLanguageModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { @@ -69,8 +70,11 @@ class AppLanguageModule(reactContext: ReactApplicationContext) : @ReactMethod fun getEffectiveLanguage(promise: Promise) { try { + // The API 33+ platform tag is preferred when available; the + // configuration/Locale fallbacks are safe on every API level and + // are kept here so the API 33 helper never has to handle them. val language = if (Build.VERSION.SDK_INT >= API_33) { - AppLanguageApi33.getEffectiveLanguage(reactApplicationContext) + AppLanguageApi33.getApplicationLanguageTag(reactApplicationContext) ?: reactApplicationContext.resources.configuration.locales[0]?.toLanguageTag() ?: Locale.getDefault().toLanguageTag() } else { @@ -88,12 +92,9 @@ class AppLanguageModule(reactContext: ReactApplicationContext) : private const val API_33 = 33 // Generated from the TypeScript shipped-locale registry by Expo config. private val SUPPORTED_LANGUAGES = setOf("en", "pl") - private const val FALLBACK_LOCALE = "en" private val SUPPORTED_LANGUAGES_CANONICAL = SUPPORTED_LANGUAGES.map(::canonicalTag).toSet() private fun canonicalTag(value: String): String = Locale.forLanguageTag(value).toLanguageTag().lowercase(Locale.ROOT) - - private fun fallbackTag(): String = FALLBACK_LOCALE } } diff --git a/SparkyFitnessMobile/targets/android-widget/kotlin/com/sparkyapps/sparkyfitness/widget/WidgetLocale.kt.tmpl b/SparkyFitnessMobile/targets/android-widget/kotlin/com/sparkyapps/sparkyfitness/widget/WidgetLocale.kt.tmpl index 156d2ab32..a1be4a958 100644 --- a/SparkyFitnessMobile/targets/android-widget/kotlin/com/sparkyapps/sparkyfitness/widget/WidgetLocale.kt.tmpl +++ b/SparkyFitnessMobile/targets/android-widget/kotlin/com/sparkyapps/sparkyfitness/widget/WidgetLocale.kt.tmpl @@ -132,10 +132,12 @@ object WidgetLocale { packageName == context.packageName && intent.hasExtra(Intent.EXTRA_LOCALE_LIST) val effective = if (hasAppLocaleExtras) { - val appLocales = intent.getParcelableExtra( - Intent.EXTRA_LOCALE_LIST, - LocaleList::class.java, - ) + // The API 33+ getParcelableExtra(String, Class) overload is + // isolated in WidgetLocaleApi33; the legacy overload lacks the type + // token and is unsafe on API 33+. Both paths are guarded by + // isNativeAppLanguageSupported() above, so this branch only runs on + // API 33+ where the helper is loadable. + val appLocales = WidgetLocaleApi33.getLocaleListExtra(intent) if (appLocales != null && !appLocales.isEmpty) { languageFromLocaleList(appLocales) } else { diff --git a/SparkyFitnessMobile/targets/android-widget/kotlin/com/sparkyapps/sparkyfitness/widget/WidgetLocaleApi33.kt.tmpl b/SparkyFitnessMobile/targets/android-widget/kotlin/com/sparkyapps/sparkyfitness/widget/WidgetLocaleApi33.kt.tmpl index b29895ee1..76d61fbcc 100644 --- a/SparkyFitnessMobile/targets/android-widget/kotlin/com/sparkyapps/sparkyfitness/widget/WidgetLocaleApi33.kt.tmpl +++ b/SparkyFitnessMobile/targets/android-widget/kotlin/com/sparkyapps/sparkyfitness/widget/WidgetLocaleApi33.kt.tmpl @@ -2,24 +2,52 @@ package com.sparkyapps.sparkyfitness.widget import android.app.LocaleManager import android.content.Context +import android.content.Intent import android.os.Build import android.os.LocaleList +import androidx.annotation.DoNotInline import androidx.annotation.RequiresApi /** * Isolated Android 13+ (API 33+) helper for reading platform per-app language - * state through `android.app.LocaleManager`. + * state through `android.app.LocaleManager` and the API 33+ + * `Intent.getParcelableExtra(String, Class)` overload. * - * This is the ONLY widget-side class that references `android.app.LocaleManager`. - * `WidgetLocale` calls it only after a runtime `Build.VERSION.SDK_INT >= - * Build.VERSION_CODES.TIRAMISU` guard, so the class verifier on Android <=12 - * never resolves `LocaleManager` and cannot raise `NoClassDefFoundError` / - * `VerifyError` when `WidgetLocale` is loaded. + * This is the ONLY widget-side class that references `android.app.LocaleManager` + * and the ONLY place that calls the API 33+ `getParcelableExtra` overload. + * `WidgetLocale` calls these methods only after a runtime + * `Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU` guard, so the class + * verifier on Android <=12 never resolves `LocaleManager` or the API 33 + * overload when `WidgetLocale` is loaded. + * + * `@RequiresApi` marks the boundary for lint; `@DoNotInline` + `@JvmStatic` + * follow the AndroidX out-of-line pattern so R8/ART does not inline these + * bodies back into the common `WidgetLocale` object (which would re-introduce + * the API 33 class/method references on the minSdk path). + * + * No method exposes `LocaleManager` or an API-33-only type across the helper + * boundary: callers receive only `String?` / `LocaleList?` values that are + * safe on every API level (`LocaleList` exists since API 24). */ @RequiresApi(Build.VERSION_CODES.TIRAMISU) internal object WidgetLocaleApi33 { + private fun localeManager(context: Context): LocaleManager? = + context.getSystemService(LocaleManager::class.java) + + /** + * Reads the API 33+ `Intent.EXTRA_LOCALE_LIST` parcelable using the + * `getParcelableExtra(String, Class)` overload added in API 33. + * Returns null when the extra is absent or not a `LocaleList`. + */ + @JvmStatic + @DoNotInline + fun getLocaleListExtra(intent: Intent): LocaleList? = + intent.getParcelableExtra(Intent.EXTRA_LOCALE_LIST, LocaleList::class.java) + + @JvmStatic + @DoNotInline fun systemPlatformLanguage(context: Context): String? { - val manager = context.getSystemService(LocaleManager::class.java) ?: return null + val manager = localeManager(context) ?: return null val systemLocales = manager.systemLocales return if (systemLocales != null && !systemLocales.isEmpty) { WidgetLocale.normalizeLanguagePublic(systemLocales[0]) @@ -28,8 +56,10 @@ internal object WidgetLocaleApi33 { } } + @JvmStatic + @DoNotInline fun currentPlatformLanguage(context: Context): String? { - val manager = context.getSystemService(LocaleManager::class.java) ?: return null + val manager = localeManager(context) ?: return null val appLocales = manager.applicationLocales val platformLocale = if (appLocales != null && !appLocales.isEmpty) { appLocales[0] From a2cd149b79285bddeb078ba30bc38b72f00d9d8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Maci=C4=85g?= <6450912+Dragonk@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:00:37 +0000 Subject: [PATCH 3/5] fix(mobile): preserve generated native locale registry The previous commit accidentally replaced the {{SUPPORTED_LOCALES}} and {{FALLBACK_LOCALE}} placeholders in AppLanguageModule.kt with hardcoded "en"/"pl" literals, and removed the dead-code FALLBACK_LOCALE/fallbackTag() declarations. The placeholders are intentional: withAppLanguage.ts substitutes them at prebuild time from the central localeRegistry, so the native module stays in sync when a new locale is shipped. Hardcoding the list would silently break the next shipped locale. Restore the placeholders and the dead-code declarations to keep the PR scope narrow (API 33 isolation only, no unrelated cleanup). Also drop the unused java.util.Locale import from AppLanguageApi33.kt. Add a contract test asserting AppLanguageModule.kt keeps the {{SUPPORTED_LOCALES}}/{{FALLBACK_LOCALE}} placeholders and does not hardcode an "en","pl" list. --- .../__tests__/config/androidApi33Isolation.test.ts | 10 ++++++++++ .../sparkyfitness/language/AppLanguageApi33.kt | 1 - .../sparkyfitness/language/AppLanguageModule.kt | 5 ++++- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/SparkyFitnessMobile/__tests__/config/androidApi33Isolation.test.ts b/SparkyFitnessMobile/__tests__/config/androidApi33Isolation.test.ts index 74f4645b6..7534feb79 100644 --- a/SparkyFitnessMobile/__tests__/config/androidApi33Isolation.test.ts +++ b/SparkyFitnessMobile/__tests__/config/androidApi33Isolation.test.ts @@ -49,6 +49,16 @@ describe('Android API 33 isolation contract (issue #2253)', () => { expect(code).not.toMatch(/LocaleManager\b/); }); + it('1a. AppLanguageModule.kt uses generated locale placeholders, not hardcoded locale lists', () => { + // The native supported-locale list MUST be generated from the central + // localeRegistry by the withAppLanguage config plugin. Hardcoding + // "en"/"pl" would silently break the next shipped locale. + const src = readSource(LANGUAGE_ROOT, 'AppLanguageModule.kt'); + expect(src).toMatch(/\{\{SUPPORTED_LOCALES\}\}/); + expect(src).toMatch(/\{\{FALLBACK_LOCALE\}\}/); + expect(src).not.toMatch(/setOf\(\s*"en"\s*,\s*"pl"\s*\)/); + }); + it('AppLanguagePackage.kt has no code reference to LocaleManager', () => { const code = stripComments(readSource(LANGUAGE_ROOT, 'AppLanguagePackage.kt')); expect(code).not.toMatch(/LocaleManager\b/); diff --git a/SparkyFitnessMobile/targets/android-language/kotlin/com/sparkyapps/sparkyfitness/language/AppLanguageApi33.kt b/SparkyFitnessMobile/targets/android-language/kotlin/com/sparkyapps/sparkyfitness/language/AppLanguageApi33.kt index a9546e433..3a3a1f6ce 100644 --- a/SparkyFitnessMobile/targets/android-language/kotlin/com/sparkyapps/sparkyfitness/language/AppLanguageApi33.kt +++ b/SparkyFitnessMobile/targets/android-language/kotlin/com/sparkyapps/sparkyfitness/language/AppLanguageApi33.kt @@ -6,7 +6,6 @@ import android.os.Build import android.os.LocaleList import androidx.annotation.DoNotInline import androidx.annotation.RequiresApi -import java.util.Locale /** * Isolated Android 13+ (API 33+) helper for the platform per-app language API diff --git a/SparkyFitnessMobile/targets/android-language/kotlin/com/sparkyapps/sparkyfitness/language/AppLanguageModule.kt b/SparkyFitnessMobile/targets/android-language/kotlin/com/sparkyapps/sparkyfitness/language/AppLanguageModule.kt index a42b963d0..979fb0bef 100644 --- a/SparkyFitnessMobile/targets/android-language/kotlin/com/sparkyapps/sparkyfitness/language/AppLanguageModule.kt +++ b/SparkyFitnessMobile/targets/android-language/kotlin/com/sparkyapps/sparkyfitness/language/AppLanguageModule.kt @@ -91,10 +91,13 @@ class AppLanguageModule(reactContext: ReactApplicationContext) : private const val MODULE_NAME = "AppLanguage" private const val API_33 = 33 // Generated from the TypeScript shipped-locale registry by Expo config. - private val SUPPORTED_LANGUAGES = setOf("en", "pl") + private val SUPPORTED_LANGUAGES = setOf({{SUPPORTED_LOCALES}}) + private const val FALLBACK_LOCALE = "{{FALLBACK_LOCALE}}" private val SUPPORTED_LANGUAGES_CANONICAL = SUPPORTED_LANGUAGES.map(::canonicalTag).toSet() private fun canonicalTag(value: String): String = Locale.forLanguageTag(value).toLanguageTag().lowercase(Locale.ROOT) + + private fun fallbackTag(): String = FALLBACK_LOCALE } } From db79fc8d366f2a75c7b84526f142167f79a82a3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Maci=C4=85g?= <6450912+Dragonk@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:10:37 +0000 Subject: [PATCH 4/5] test(mobile): scope Android API guard contracts per method body CodeRabbit finding (PR #2259): the SDK guard assertions for AppLanguageModule searched the entire source file with indexOf/lastIndexOf, so a guard from setApplicationLanguage could satisfy the assertion for getApplicationLanguage even if the latter lost its own guard. Fix: add a brace-balanced extractFunctionBody helper and scope every guard-vs-helper check to a single method body. Apply the same scoping to the WidgetLocale guard assertions for refreshEffectiveRenderLocaleFromBroadcast, systemPlatformLanguage, and currentPlatformLanguage. Add a mutation-style regression test that removes the guard from getApplicationLanguage in a synthetic copy and proves the per-body extraction detects the missing guard while the helper call remains. --- .../config/androidApi33Isolation.test.ts | 180 +++++++++++++----- 1 file changed, 135 insertions(+), 45 deletions(-) diff --git a/SparkyFitnessMobile/__tests__/config/androidApi33Isolation.test.ts b/SparkyFitnessMobile/__tests__/config/androidApi33Isolation.test.ts index 7534feb79..ba665ed9f 100644 --- a/SparkyFitnessMobile/__tests__/config/androidApi33Isolation.test.ts +++ b/SparkyFitnessMobile/__tests__/config/androidApi33Isolation.test.ts @@ -41,6 +41,43 @@ function stripComments(src: string): string { return result; } +/** + * Extract the body of a Kotlin `fun` (including the @ReactMethod annotation + * line if present) using a brace-balanced scan. This scopes guard-vs-helper + * assertions to a single method body, so a guard from another method can no + * longer satisfy the assertion for a different method (CodeRabbit finding on + * PR #2259). + * + * The scan starts at the first `fun name(` occurrence and returns the text + * from the first `{` to its matching `}`. String literals and comments inside + * the body are left as-is; for the assertions in this file that is safe + * because they look for specific Kotlin expressions that do not appear as + * string literals in the audited sources. + */ +function extractFunctionBody(source: string, functionName: string): string { + const functionIndex = source.indexOf(`fun ${functionName}(`); + if (functionIndex === -1) { + throw new Error(`Function ${functionName} not found`); + } + + const openBrace = source.indexOf('{', functionIndex); + if (openBrace === -1) { + throw new Error(`Function body for ${functionName} not found`); + } + + let depth = 0; + for (let i = openBrace; i < source.length; i += 1) { + if (source[i] === '{') depth += 1; + if (source[i] === '}') { + depth -= 1; + if (depth === 0) { + return source.slice(openBrace + 1, i); + } + } + } + throw new Error(`Unterminated function body for ${functionName}`); +} + describe('Android API 33 isolation contract (issue #2253)', () => { describe('common language bridge layer (loaded unconditionally)', () => { it('1. AppLanguageModule.kt has no code reference to LocaleManager (imports, types, calls)', () => { @@ -64,43 +101,99 @@ describe('Android API 33 isolation contract (issue #2253)', () => { expect(code).not.toMatch(/LocaleManager\b/); }); - it('6. AppLanguageModule guards API 33 calls with SDK_INT before delegating', () => { + it('6. AppLanguageModule guards API 33 calls with SDK_INT before delegating (per method body)', () => { const src = readSource(LANGUAGE_ROOT, 'AppLanguageModule.kt'); - // setApplicationLanguage must guard before calling the API 33 helper. - const setGuard = src.indexOf('Build.VERSION.SDK_INT < API_33'); - const setDelegate = src.indexOf('AppLanguageApi33.setApplicationLanguage'); + + // setApplicationLanguage: guard before helper, inside the same body. + const setBody = extractFunctionBody(src, 'setApplicationLanguage'); + const setGuard = setBody.indexOf('Build.VERSION.SDK_INT < API_33'); + const setDelegate = setBody.indexOf('AppLanguageApi33.setApplicationLanguage'); expect(setGuard).toBeGreaterThan(-1); expect(setDelegate).toBeGreaterThan(-1); expect(setDelegate).toBeGreaterThan(setGuard); - // getEffectiveLanguage has an SDK_INT >= API_33 branch before the helper. - const effGuard = src.indexOf('Build.VERSION.SDK_INT >= API_33'); - const effDelegate = src.indexOf('AppLanguageApi33.getApplicationLanguageTag'); + // getApplicationLanguage: its OWN guard before its OWN helper call. + const getBody = extractFunctionBody(src, 'getApplicationLanguage'); + const getGuard = getBody.indexOf('Build.VERSION.SDK_INT < API_33'); + const getDelegate = getBody.indexOf('AppLanguageApi33.getApplicationLanguage'); + expect(getGuard).toBeGreaterThan(-1); + expect(getDelegate).toBeGreaterThan(-1); + expect(getDelegate).toBeGreaterThan(getGuard); + + // getEffectiveLanguage: its OWN >= API_33 branch before its OWN helper. + const effBody = extractFunctionBody(src, 'getEffectiveLanguage'); + const effGuard = effBody.indexOf('Build.VERSION.SDK_INT >= API_33'); + const effDelegate = effBody.indexOf('AppLanguageApi33.getApplicationLanguageTag'); expect(effGuard).toBeGreaterThan(-1); expect(effDelegate).toBeGreaterThan(-1); expect(effDelegate).toBeGreaterThan(effGuard); }); - it('7. AppLanguageModule never reaches the API 33 helper on the API <=32 path', () => { + it('7. AppLanguageModule never reaches the API 33 helper on the API <=32 path (per method body)', () => { const src = readSource(LANGUAGE_ROOT, 'AppLanguageModule.kt'); - // Every AppLanguageApi33 call site must be preceded by an SDK_INT guard - // in the same method body. There are three call sites; each must have a - // guard earlier in the file within the enclosing method. - const callSites = ['AppLanguageApi33.setApplicationLanguage', 'AppLanguageApi33.getApplicationLanguage', 'AppLanguageApi33.getApplicationLanguageTag']; - for (const call of callSites) { - const idx = src.indexOf(call); - if (idx === -1) continue; // not all may be present - // Find the nearest preceding SDK_INT check (same method). - const guardIdx = Math.max( - src.lastIndexOf('Build.VERSION.SDK_INT < API_33', idx), - src.lastIndexOf('Build.VERSION.SDK_INT >= API_33', idx), - ); + + // Each method body must contain its OWN guard before its helper call. + // A guard from another method must not satisfy this assertion. + const cases: { fn: string; guard: string; helper: string }[] = [ + { fn: 'setApplicationLanguage', guard: 'Build.VERSION.SDK_INT < API_33', helper: 'AppLanguageApi33.setApplicationLanguage' }, + { fn: 'getApplicationLanguage', guard: 'Build.VERSION.SDK_INT < API_33', helper: 'AppLanguageApi33.getApplicationLanguage' }, + { fn: 'getEffectiveLanguage', guard: 'Build.VERSION.SDK_INT >= API_33', helper: 'AppLanguageApi33.getApplicationLanguageTag' }, + ]; + + for (const { fn, guard, helper } of cases) { + const body = extractFunctionBody(src, fn); + const guardIdx = body.indexOf(guard); + const helperIdx = body.indexOf(helper); expect(guardIdx).toBeGreaterThan(-1); - // Ensure no `return` between the guard and the call (which would mean - // the guard returns early on API <=32 and the call is unreachable there). - // The helper call must come after the guard in the same method. - expect(idx).toBeGreaterThan(guardIdx); + expect(helperIdx).toBeGreaterThan(-1); + expect(helperIdx).toBeGreaterThan(guardIdx); + } + }); + + it('7a. extractFunctionBody scopes guards per method (mutation regression)', () => { + // Prove the test above would FAIL if getApplicationLanguage lost its + // own guard. We synthesize a source where that guard is removed and + // assert the helper still appears WITHOUT the guard in the same body — + // which is exactly the regression the per-method test must catch. + const src = readSource(LANGUAGE_ROOT, 'AppLanguageModule.kt'); + const getBody = extractFunctionBody(src, 'getApplicationLanguage'); + + // Sanity: the real source has the guard. + expect(getBody).toContain('Build.VERSION.SDK_INT < API_33'); + + // Mutate: remove the guard from getApplicationLanguage only. + const mutatedGetBody = getBody.replace( + /if \(Build\.VERSION\.SDK_INT < API_33\)\s*\{[^}]*\}/, + '', + ); + // Confirm the mutation removed the guard but left the helper call. + expect(mutatedGetBody).not.toContain('Build.VERSION.SDK_INT < API_33'); + expect(mutatedGetBody).toContain('AppLanguageApi33.getApplicationLanguage'); + + // Reconstruct the full source with the mutated body so the per-method + // extraction in test 7 would see the missing guard. We replace the + // original body in the source by locating the function boundaries. + const fnStart = src.indexOf('fun getApplicationLanguage('); + const openBrace = src.indexOf('{', fnStart); + // Find the matching close brace using the same balanced scan. + let depth = 0; + let closeBrace = -1; + for (let i = openBrace; i < src.length; i += 1) { + if (src[i] === '{') depth += 1; + if (src[i] === '}') { + depth -= 1; + if (depth === 0) { closeBrace = i; break; } + } } + expect(closeBrace).toBeGreaterThan(openBrace); + const mutatedSrc = + src.slice(0, openBrace + 1) + mutatedGetBody + src.slice(closeBrace); + + // Now extract the mutated body and verify the guard is gone while the + // helper call remains — this is the condition test 7 rejects. + const reExtracted = extractFunctionBody(mutatedSrc, 'getApplicationLanguage'); + expect(reExtracted).not.toContain('Build.VERSION.SDK_INT < API_33'); + expect(reExtracted).toContain('AppLanguageApi33.getApplicationLanguage'); }); }); @@ -131,28 +224,25 @@ describe('Android API 33 isolation contract (issue #2253)', () => { expect(helperCall).toBeGreaterThan(broadcastFn); }); - it('6. WidgetLocale delegates to WidgetLocaleApi33 only behind isNativeAppLanguageSupported', () => { + it('6. WidgetLocale delegates to WidgetLocaleApi33 only behind isNativeAppLanguageSupported (per method body)', () => { const src = readSource(WIDGET_ROOT, 'WidgetLocale.kt.tmpl'); - // refreshEffectiveRenderLocaleFromBroadcast must early-return on API <=32. - const broadcastFn = src.indexOf('fun refreshEffectiveRenderLocaleFromBroadcast'); - const earlyReturn = src.indexOf('isNativeAppLanguageSupported()', broadcastFn); - const helperCall = src.indexOf('WidgetLocaleApi33.getLocaleListExtra', broadcastFn); - expect(earlyReturn).toBeGreaterThan(broadcastFn); - expect(helperCall).toBeGreaterThan(earlyReturn); - - // systemPlatformLanguage / currentPlatformLanguage delegates must also - // be guarded. - const sysFn = src.indexOf('fun systemPlatformLanguage'); - const sysDelegate = src.indexOf('WidgetLocaleApi33.systemPlatformLanguage', sysFn); - const sysGuard = src.indexOf('isNativeAppLanguageSupported()', sysFn); - expect(sysGuard).toBeGreaterThan(sysFn); - expect(sysGuard).toBeLessThan(sysDelegate); - - const curFn = src.indexOf('fun currentPlatformLanguage'); - const curDelegate = src.indexOf('WidgetLocaleApi33.currentPlatformLanguage', curFn); - const curGuard = src.indexOf('isNativeAppLanguageSupported()', curFn); - expect(curGuard).toBeGreaterThan(curFn); - expect(curGuard).toBeLessThan(curDelegate); + + // Each method must contain its OWN guard before its helper call, scoped + // to the method body so a guard from another method cannot satisfy it. + const cases: { fn: string; helper: string }[] = [ + { fn: 'refreshEffectiveRenderLocaleFromBroadcast', helper: 'WidgetLocaleApi33.getLocaleListExtra' }, + { fn: 'systemPlatformLanguage', helper: 'WidgetLocaleApi33.systemPlatformLanguage' }, + { fn: 'currentPlatformLanguage', helper: 'WidgetLocaleApi33.currentPlatformLanguage' }, + ]; + + for (const { fn, helper } of cases) { + const body = extractFunctionBody(src, fn); + const guardIdx = body.indexOf('isNativeAppLanguageSupported()'); + const helperIdx = body.indexOf(helper); + expect(guardIdx).toBeGreaterThan(-1); + expect(helperIdx).toBeGreaterThan(-1); + expect(helperIdx).toBeGreaterThan(guardIdx); + } }); it('WidgetLocale.kt.tmpl still mentions applicationLocales/systemLocales in comments (contract doc)', () => { From acd572430ed40ba0d49758e6865b8ecbd8ce4f35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Maci=C4=85g?= <6450912+Dragonk@users.noreply.github.com> Date: Wed, 26 Aug 2026 02:46:12 +0000 Subject: [PATCH 5/5] fix(mobile): address Android API isolation review feedback 1. Remove the normalizeLanguagePublic alias: it was internal, not public, and the name was misleading. Make WidgetLocale.normalizeLanguage internal and have WidgetLocaleApi33 call it directly. 2. Replace the hardcoded list of widget Kotlin files in the API 33 isolation contract test with dynamic directory discovery. A new widget .kt.tmpl importing LocaleManager is now caught automatically without needing to update the list. Only the intentional API 33 helper (WidgetLocaleApi33.kt.tmpl) is excluded. Added invariant assertions proving the discovery finds .kt and .kt.tmpl files and excludes the helper. --- .../config/androidApi33Isolation.test.ts | 47 ++++++++++++++----- .../sparkyfitness/widget/WidgetLocale.kt.tmpl | 5 +- .../widget/WidgetLocaleApi33.kt.tmpl | 4 +- 3 files changed, 37 insertions(+), 19 deletions(-) diff --git a/SparkyFitnessMobile/__tests__/config/androidApi33Isolation.test.ts b/SparkyFitnessMobile/__tests__/config/androidApi33Isolation.test.ts index ba665ed9f..38a5c7207 100644 --- a/SparkyFitnessMobile/__tests__/config/androidApi33Isolation.test.ts +++ b/SparkyFitnessMobile/__tests__/config/androidApi33Isolation.test.ts @@ -344,19 +344,40 @@ describe('Android API 33 isolation contract (issue #2253)', () => { }); describe('no other widget Kotlin source references API 33 surface', () => { - const otherFiles = [ - 'CalorieWidgetModule.kt.tmpl', - 'CalorieWidgetReceiver.kt.tmpl', - 'CalorieWidget.kt.tmpl', - 'MacroWidget.kt.tmpl', - 'MacroWidgetReceiver.kt.tmpl', - 'CalorieWidgetPackage.kt', - ]; - - it.each(otherFiles)('%s does not reference LocaleManager or the API 33 overload', (file) => { - const code = stripComments(readSource(WIDGET_ROOT, file)); - expect(code).not.toMatch(/LocaleManager\b/); - expect(code).not.toMatch(/getParcelableExtra\([^)]*::class\.java\)/); + // Dynamic discovery: audit every regular Kotlin file in the widget + // targets directory EXCEPT the intentional API 33 helper. This catches + // future regressions automatically — a new widget .kt.tmpl importing + // LocaleManager would be caught without needing to update this list. + // WidgetLocale.kt.tmpl is intentionally NOT excluded: redundancy with the + // dedicated tests above is desirable here. + const API_33_WIDGET_HELPERS = new Set([ + 'WidgetLocaleApi33.kt.tmpl', + ]); + + const widgetKotlinFiles = fs + .readdirSync(WIDGET_ROOT, { withFileTypes: true }) + .filter( + (entry) => + entry.isFile() && + (entry.name.endsWith('.kt') || entry.name.endsWith('.kt.tmpl')), + ) + .map((entry) => entry.name) + .filter((file) => !API_33_WIDGET_HELPERS.has(file)); + + it('directory discovery finds .kt and .kt.tmpl files and excludes the API 33 helper', () => { + expect(widgetKotlinFiles.length).toBeGreaterThan(0); + expect(widgetKotlinFiles).not.toContain('WidgetLocaleApi33.kt.tmpl'); + expect(widgetKotlinFiles.some((f) => f.endsWith('.kt'))).toBe(true); + expect(widgetKotlinFiles.some((f) => f.endsWith('.kt.tmpl'))).toBe(true); }); + + it.each(widgetKotlinFiles)( + '%s does not reference LocaleManager or the API 33 overload', + (file) => { + const code = stripComments(readSource(WIDGET_ROOT, file)); + expect(code).not.toMatch(/LocaleManager\b/); + expect(code).not.toMatch(/getParcelableExtra\([^)]*::class\.java\)/); + }, + ); }); }); diff --git a/SparkyFitnessMobile/targets/android-widget/kotlin/com/sparkyapps/sparkyfitness/widget/WidgetLocale.kt.tmpl b/SparkyFitnessMobile/targets/android-widget/kotlin/com/sparkyapps/sparkyfitness/widget/WidgetLocale.kt.tmpl index a1be4a958..b2f2fbc49 100644 --- a/SparkyFitnessMobile/targets/android-widget/kotlin/com/sparkyapps/sparkyfitness/widget/WidgetLocale.kt.tmpl +++ b/SparkyFitnessMobile/targets/android-widget/kotlin/com/sparkyapps/sparkyfitness/widget/WidgetLocale.kt.tmpl @@ -173,7 +173,7 @@ object WidgetLocale { return normalizeLanguage(locales[0]) } - private fun normalizeLanguage(locale: Locale): String { + internal fun normalizeLanguage(locale: Locale): String { val tag = canonicalTag(locale.toLanguageTag()) return SUPPORTED_LOCALES .map { it to canonicalTag(it) } @@ -195,9 +195,6 @@ object WidgetLocale { return WidgetLocaleApi33.currentPlatformLanguage(context) } - /** Public alias so `WidgetLocaleApi33` can reuse the normalization logic. */ - internal fun normalizeLanguagePublic(locale: Locale): String = normalizeLanguage(locale) - /** * Bumps the dedicated Glance state revision without changing any snapshot * data. updateAppWidgetState is the state mutation that an active Glance diff --git a/SparkyFitnessMobile/targets/android-widget/kotlin/com/sparkyapps/sparkyfitness/widget/WidgetLocaleApi33.kt.tmpl b/SparkyFitnessMobile/targets/android-widget/kotlin/com/sparkyapps/sparkyfitness/widget/WidgetLocaleApi33.kt.tmpl index 76d61fbcc..31501f192 100644 --- a/SparkyFitnessMobile/targets/android-widget/kotlin/com/sparkyapps/sparkyfitness/widget/WidgetLocaleApi33.kt.tmpl +++ b/SparkyFitnessMobile/targets/android-widget/kotlin/com/sparkyapps/sparkyfitness/widget/WidgetLocaleApi33.kt.tmpl @@ -50,7 +50,7 @@ internal object WidgetLocaleApi33 { val manager = localeManager(context) ?: return null val systemLocales = manager.systemLocales return if (systemLocales != null && !systemLocales.isEmpty) { - WidgetLocale.normalizeLanguagePublic(systemLocales[0]) + WidgetLocale.normalizeLanguage(systemLocales[0]) } else { null } @@ -67,6 +67,6 @@ internal object WidgetLocaleApi33 { val systemLocales = manager.systemLocales if (systemLocales != null && !systemLocales.isEmpty) systemLocales[0] else null } - return platformLocale?.let(WidgetLocale::normalizeLanguagePublic) + return platformLocale?.let(WidgetLocale::normalizeLanguage) } }