From ebcedb4d69186e591ea8b8e0a2a5ba63795e6f04 Mon Sep 17 00:00:00 2001 From: Ben James Date: Thu, 19 Feb 2026 20:48:30 +0000 Subject: [PATCH 1/3] feat: add localization() method to Device Return device locale, language, region, timezone, currency, and preferred language via the Device.GetLocale bridge function as an immutable Localization data object. --- src/Data/Localization.php | 15 +++++++++++++++ src/Device.php | 24 ++++++++++++++++++++++++ src/Facades/Device.php | 1 + 3 files changed, 40 insertions(+) create mode 100644 src/Data/Localization.php diff --git a/src/Data/Localization.php b/src/Data/Localization.php new file mode 100644 index 00000000..f5af8672 --- /dev/null +++ b/src/Data/Localization.php @@ -0,0 +1,15 @@ + Date: Fri, 20 Feb 2026 23:21:48 +0000 Subject: [PATCH 2/3] feat: inject locale config into iOS and Android builds When two or more locales are configured via `nativephp.locales`, the build now injects CFBundleLocalizations into Info.plist (iOS) and generates locales_config.xml with a manifest reference (Android). This fixes iOS reporting incorrect Locale.current and enables the per-app language picker on Android 13+. --- config/nativephp.php | 17 ++ src/Commands/BuildIosAppCommand.php | 71 +++++++ src/Traits/PreparesBuild.php | 57 ++++++ .../MocksPreparesBuildDependencies.php | 55 ++++++ tests/Unit/LocalizationConfigTest.php | 177 ++++++++++++++++++ 5 files changed, 377 insertions(+) create mode 100644 tests/Concerns/MocksPreparesBuildDependencies.php create mode 100644 tests/Unit/LocalizationConfigTest.php diff --git a/config/nativephp.php b/config/nativephp.php index 12cac48d..c1aa6437 100644 --- a/config/nativephp.php +++ b/config/nativephp.php @@ -375,4 +375,21 @@ 'landscape_right' => false, ], ], + + /* + |-------------------------------------------------------------------------- + | Supported Locales + |-------------------------------------------------------------------------- + | + | The locales your app supports (e.g. ['en', 'fr', 'es', 'de']). When + | two or more locales are listed, iOS will resolve Locale.current using + | the device's preferred language (via CFBundleLocalizations in + | Info.plist) and Android 13+ will show a per-app language picker + | (via locales_config.xml). + | + | Leave empty to skip locale configuration entirely. + | + */ + + 'locales' => [], ]; diff --git a/src/Commands/BuildIosAppCommand.php b/src/Commands/BuildIosAppCommand.php index c5628b9b..0181fb9f 100644 --- a/src/Commands/BuildIosAppCommand.php +++ b/src/Commands/BuildIosAppCommand.php @@ -125,6 +125,7 @@ private function configureXcodeProject(): bool $this->updateBuildNumber(); $this->setAppName(); $this->updateInfoPlistFiles(); + $this->updateLocalizationConfig(); $this->configureDeviceOrientations(); $this->updateEntitlementsFile(); $this->configureProvisioningProfile(); @@ -313,6 +314,76 @@ private function updateInfoPlistFiles(): void }); } + /** + * Inject CFBundleLocalizations into Info.plist files when two or more locales are configured. + */ + private function updateLocalizationConfig(): void + { + $locales = config('nativephp.locales', []); + + if (count($locales) < 2) { + return; + } + + $plistFiles = [ + $this->containerPath.'Info.plist', + $this->basePath.'/NativePHP-simulator-Info.plist', + ]; + + foreach ($plistFiles as $filePath) { + if (! file_exists($filePath)) { + continue; + } + + $this->injectCFBundleLocalizations($filePath, $locales); + } + } + + private function injectCFBundleLocalizations(string $filePath, array $locales): void + { + $dom = new \DOMDocument; + $dom->preserveWhiteSpace = false; + $dom->formatOutput = true; + + if (! $dom->load($filePath)) { + return; + } + + $rootDict = $dom->getElementsByTagName('dict')->item(0); + if (! $rootDict) { + return; + } + + $plistData = $this->parsePlistDict($rootDict); + + if (isset($plistData['CFBundleLocalizations'])) { + $arrayNode = $plistData['CFBundleLocalizations']['valueNode']; + while ($arrayNode->firstChild) { + $arrayNode->removeChild($arrayNode->firstChild); + } + } else { + $rootDict->appendChild($dom->createElement('key', 'CFBundleLocalizations')); + $arrayNode = $dom->createElement('array'); + $rootDict->appendChild($arrayNode); + } + + foreach ($locales as $locale) { + $arrayNode->appendChild($dom->createElement('string', $locale)); + } + + $xmlContent = $dom->saveXML(); + + if (! str_contains($xmlContent, '\s*/s', + "\n\n", + $xmlContent + ); + } + + file_put_contents($filePath, $xmlContent); + } + /** * Configure device orientations and targeting by updating the Xcode project settings directly. * Sets TARGETED_DEVICE_FAMILY and INFOPLIST_KEY_UISupportedInterfaceOrientations in project.pbxproj. diff --git a/src/Traits/PreparesBuild.php b/src/Traits/PreparesBuild.php index 46f56b51..98864006 100644 --- a/src/Traits/PreparesBuild.php +++ b/src/Traits/PreparesBuild.php @@ -131,6 +131,9 @@ protected function updateAndroidConfiguration(): void $this->logToFile(' Updating orientation configuration...'); $this->updateOrientationConfiguration(); + $this->logToFile(' Updating localization configuration...'); + $this->updateLocalizationConfiguration(); + $scheme = config('nativephp.deeplink_scheme'); $host = config('nativephp.deeplink_host'); $this->logToFile(' Updating deep link configuration...'); @@ -854,6 +857,60 @@ protected function updateBuildConfiguration(): void File::put($proguardPath, $proguardContent); } + /** + * Generate locales_config.xml and update AndroidManifest.xml when two or more locales are configured. + */ + protected function updateLocalizationConfiguration(): void + { + $locales = config('nativephp.locales', []); + $manifestPath = base_path('nativephp/android/app/src/main/AndroidManifest.xml'); + $xmlDir = base_path('nativephp/android/app/src/main/res/xml'); + $localesConfigPath = $xmlDir.'/locales_config.xml'; + + if (count($locales) < 2) { + // Clean up if locales were previously configured but now removed + if (File::exists($localesConfigPath)) { + File::delete($localesConfigPath); + } + + if (File::exists($manifestPath)) { + $contents = File::get($manifestPath); + $cleaned = preg_replace('/\s*android:localeConfig="@xml\/locales_config"/', '', $contents); + if ($contents !== $cleaned) { + File::put($manifestPath, $this->normalizeLineEndings($cleaned)); + } + } + + return; + } + + // Generate locales_config.xml + File::ensureDirectoryExists($xmlDir); + + $xml = ''."\n"; + $xml .= ''."\n"; + foreach ($locales as $locale) { + $xml .= ' '."\n"; + } + $xml .= ''."\n"; + + File::put($localesConfigPath, $xml); + + // Add android:localeConfig to tag in manifest + if (File::exists($manifestPath)) { + $contents = File::get($manifestPath); + + if (! str_contains($contents, 'android:localeConfig')) { + $contents = preg_replace( + '/(normalizeLineEndings($contents)); + } + } + } + /** * Replace the placeholder package name in all Kotlin files * This is used to update plugin files that use com.example.androidphp as a placeholder diff --git a/tests/Concerns/MocksPreparesBuildDependencies.php b/tests/Concerns/MocksPreparesBuildDependencies.php new file mode 100644 index 00000000..c0347edf --- /dev/null +++ b/tests/Concerns/MocksPreparesBuildDependencies.php @@ -0,0 +1,55 @@ +testProjectPath = sys_get_temp_dir().'/nativephp_localization_test_'.uniqid(); + app()->setBasePath($this->testProjectPath); + + $this->createDirectoryStructure($this->testProjectPath, [ + 'nativephp/android/app/src/main' => [ + 'AndroidManifest.xml' => ' + + +', + ], + ]); + } + + protected function tearDown(): void + { + File::deleteDirectory($this->testProjectPath); + parent::tearDown(); + } + + // iOS + + public function test_ios_injects_cf_bundle_localizations_into_plist() + { + $path = $this->writePlist('CFBundleIdentifiercom.test.app'); + + $this->injectLocalizations($path, ['en', 'fr', 'es']); + + $content = file_get_contents($path); + $this->assertStringContainsString('en', $content); + $this->assertStringContainsString('fr', $content); + $this->assertStringContainsString('es', $content); + $this->assertStringContainsString('CFBundleIdentifier', $content); + } + + public function test_ios_replaces_existing_cf_bundle_localizations() + { + $path = $this->writePlist( + 'CFBundleLocalizationsen' + ); + + $this->injectLocalizations($path, ['fr', 'de']); + + $content = file_get_contents($path); + $this->assertStringContainsString('fr', $content); + $this->assertStringContainsString('de', $content); + $this->assertStringNotContainsString('en', $content); + } + + // Android + + public function test_android_generates_locales_config_and_updates_manifest() + { + config(['nativephp.locales' => ['en', 'fr', 'es']]); + $this->runUpdateLocalizationConfiguration(); + + $xml = File::get($this->xmlPath()); + $this->assertStringContainsString('', $xml); + $this->assertStringContainsString('', $xml); + $this->assertStringContainsString('', $xml); + $this->assertStringContainsString('android:localeConfig="@xml/locales_config"', File::get($this->manifestPath())); + } + + public function test_android_skips_when_fewer_than_two_locales() + { + config(['nativephp.locales' => ['en']]); + $this->runUpdateLocalizationConfiguration(); + + $this->assertFileDoesNotExist($this->xmlPath()); + $this->assertStringNotContainsString('localeConfig', File::get($this->manifestPath())); + } + + public function test_android_cleans_up_when_locales_removed() + { + config(['nativephp.locales' => ['en', 'fr']]); + $this->runUpdateLocalizationConfiguration(); + $this->assertFileExists($this->xmlPath()); + + config(['nativephp.locales' => []]); + $this->runUpdateLocalizationConfiguration(); + + $this->assertFileDoesNotExist($this->xmlPath()); + $this->assertStringNotContainsString('localeConfig', File::get($this->manifestPath())); + } + + public function test_android_does_not_duplicate_locale_config_attribute() + { + config(['nativephp.locales' => ['en', 'fr']]); + $this->runUpdateLocalizationConfiguration(); + $this->runUpdateLocalizationConfiguration(); + + $this->assertEquals(1, substr_count(File::get($this->manifestPath()), 'android:localeConfig')); + } + + /** + * Helper methods + */ + private function manifestPath(): string + { + return $this->testProjectPath.'/nativephp/android/app/src/main/AndroidManifest.xml'; + } + + private function xmlPath(): string + { + return $this->testProjectPath.'/nativephp/android/app/src/main/res/xml/locales_config.xml'; + } + + private function writePlist(string $dictContent): string + { + $path = $this->testProjectPath.'/Info.plist'; + File::ensureDirectoryExists(dirname($path)); + file_put_contents($path, ' + +'.$dictContent.''); + + return $path; + } + + private function injectLocalizations(string $filePath, array $locales): void + { + $dom = new \DOMDocument; + $dom->preserveWhiteSpace = false; + $dom->formatOutput = true; + $dom->load($filePath); + + $rootDict = $dom->getElementsByTagName('dict')->item(0); + $existing = null; + + foreach ($rootDict->childNodes as $child) { + if ($child->nodeType === XML_ELEMENT_NODE && $child->nodeName === 'key' && $child->nodeValue === 'CFBundleLocalizations') { + $existing = $child->nextSibling; + while ($existing && $existing->nodeType !== XML_ELEMENT_NODE) { + $existing = $existing->nextSibling; + } + break; + } + } + + if ($existing) { + while ($existing->firstChild) { + $existing->removeChild($existing->firstChild); + } + $arrayNode = $existing; + } else { + $rootDict->appendChild($dom->createElement('key', 'CFBundleLocalizations')); + $arrayNode = $dom->createElement('array'); + $rootDict->appendChild($arrayNode); + } + + foreach ($locales as $locale) { + $arrayNode->appendChild($dom->createElement('string', $locale)); + } + + file_put_contents($filePath, $dom->saveXML()); + } +} From 4de1c68b2667da95a60e770f5da21ff0867ff43c Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 15 May 2026 12:57:30 +0100 Subject: [PATCH 3/3] Remove unused comment in LocalizationConfigTest To trigger CI --- tests/Unit/LocalizationConfigTest.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/Unit/LocalizationConfigTest.php b/tests/Unit/LocalizationConfigTest.php index 594ca5e9..94c5904d 100644 --- a/tests/Unit/LocalizationConfigTest.php +++ b/tests/Unit/LocalizationConfigTest.php @@ -113,9 +113,6 @@ public function test_android_does_not_duplicate_locale_config_attribute() $this->assertEquals(1, substr_count(File::get($this->manifestPath()), 'android:localeConfig')); } - /** - * Helper methods - */ private function manifestPath(): string { return $this->testProjectPath.'/nativephp/android/app/src/main/AndroidManifest.xml';