Skip to content

Commit 2ea0d5a

Browse files
authored
feat: system language + navigation (#32)
* feat: add apis (systemLanguage, navigationMode) * feat: add kotlin, swift impl * feat: showcase app * docs: add docs (device-info, navigation mode) * fix: ktlintFormat
1 parent 8f5a0fe commit 2ea0d5a

File tree

8 files changed

+257
-28
lines changed

8 files changed

+257
-28
lines changed

android/src/main/java/com/margelo/nitro/nitrodeviceinfo/DeviceInfo.kt

Lines changed: 46 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,10 @@ import kotlinx.coroutines.suspendCancellableCoroutine
4444
import java.net.Inet4Address
4545
import java.net.NetworkInterface
4646
import java.security.KeyStore
47+
import java.util.Locale
4748
import kotlin.coroutines.resume
4849

49-
/**
50-
* Build information cache to avoid repeated Build.* lookups
51-
*/
50+
/** Build information cache to avoid repeated Build.* lookups */
5251
private data class BuildInfoCache(
5352
val serialNumber: String,
5453
val androidId: String,
@@ -115,16 +114,15 @@ class DeviceInfo : HybridDeviceInfoSpec() {
115114
}
116115

117116
/**
118-
* Get serial number with permission check
119-
* Requires READ_PHONE_STATE permission on Android 8.0+
117+
* Get serial number with permission check Requires READ_PHONE_STATE permission on Android 8.0+
120118
*/
121119
@SuppressLint("MissingPermission")
122120
private fun getSerialNumberInternal(): String {
123121
return try {
124122
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
125123
// Android 8.0+ requires READ_PHONE_STATE permission
126-
if (context.checkSelfPermission(android.Manifest.permission.READ_PHONE_STATE)
127-
== PackageManager.PERMISSION_GRANTED
124+
if (context.checkSelfPermission(android.Manifest.permission.READ_PHONE_STATE) ==
125+
PackageManager.PERMISSION_GRANTED
128126
) {
129127
Build.getSerial()
130128
} else {
@@ -147,7 +145,8 @@ class DeviceInfo : HybridDeviceInfoSpec() {
147145
Settings.Secure.getString(
148146
context.contentResolver,
149147
Settings.Secure.ANDROID_ID,
150-
) ?: "unknown",
148+
)
149+
?: "unknown",
151150
securityPatch = Build.VERSION.SECURITY_PATCH,
152151
bootloader = Build.BOOTLOADER,
153152
codename = Build.VERSION.CODENAME,
@@ -168,9 +167,7 @@ class DeviceInfo : HybridDeviceInfoSpec() {
168167

169168
/** Cached system features list */
170169
private val systemFeatures: List<String> by lazy {
171-
context.packageManager.systemAvailableFeatures
172-
.mapNotNull { it.name }
173-
.sorted()
170+
context.packageManager.systemAvailableFeatures.mapNotNull { it.name }.sorted()
174171
}
175172

176173
/** Cached supported media types */
@@ -179,9 +176,7 @@ class DeviceInfo : HybridDeviceInfoSpec() {
179176
val codecList = MediaCodecList(MediaCodecList.ALL_CODECS)
180177
val types = mutableSetOf<String>()
181178
codecList.codecInfos?.forEach { codecInfo ->
182-
codecInfo.supportedTypes.forEach { type ->
183-
types.add(type)
184-
}
179+
codecInfo.supportedTypes.forEach { type -> types.add(type) }
185180
}
186181

187182
types.sorted()
@@ -259,7 +254,8 @@ class DeviceInfo : HybridDeviceInfoSpec() {
259254
get() = false
260255

261256
/**
262-
* Check if device has Dynamic Island Android devices don't have Dynamic Island (iOS-only feature)
257+
* Check if device has Dynamic Island Android devices don't have Dynamic Island (iOS-only
258+
* feature)
263259
*/
264260
override val hasDynamicIsland: Boolean
265261
get() = false
@@ -329,7 +325,6 @@ class DeviceInfo : HybridDeviceInfoSpec() {
329325
when {
330326
batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_STATUS) ==
331327
BatteryManager.BATTERY_STATUS_FULL -> BatteryState.FULL
332-
333328
isCharging -> BatteryState.CHARGING
334329
else -> BatteryState.UNPLUGGED
335330
}
@@ -502,7 +497,8 @@ class DeviceInfo : HybridDeviceInfoSpec() {
502497
override val hasGms: Boolean
503498
get() {
504499
return try {
505-
val result = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context)
500+
val result =
501+
GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context)
506502
result == ConnectionResult.SUCCESS
507503
} catch (e: Exception) {
508504
Log.w(NAME, "GMS not available or GMS library not found", e)
@@ -600,7 +596,8 @@ class DeviceInfo : HybridDeviceInfoSpec() {
600596
get() {
601597
return try {
602598
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
603-
context.packageManager.getInstallSourceInfo(context.packageName).installingPackageName
599+
context.packageManager.getInstallSourceInfo(context.packageName)
600+
.installingPackageName
604601
?: "unknown"
605602
} else {
606603
@Suppress("DEPRECATION")
@@ -644,7 +641,6 @@ class DeviceInfo : HybridDeviceInfoSpec() {
644641
continuation.resume("unknown")
645642
}
646643
}
647-
648644
else -> {
649645
referrerClient.endConnection()
650646
continuation.resume("unknown")
@@ -787,7 +783,8 @@ class DeviceInfo : HybridDeviceInfoSpec() {
787783
val locationManager =
788784
context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
789785

790-
locationManager.allProviders
786+
locationManager
787+
.allProviders
791788
.filter { locationManager.isProviderEnabled(it) }
792789
.toTypedArray()
793790
} catch (e: Exception) {
@@ -835,9 +832,7 @@ class DeviceInfo : HybridDeviceInfoSpec() {
835832

836833
/** Get device token (iOS-specific, throws error on Android) */
837834
override fun getDeviceToken(): Promise<String> {
838-
return Promise.async {
839-
throw Exception("getDeviceToken() is only available on iOS")
840-
}
835+
return Promise.async { throw Exception("getDeviceToken() is only available on iOS") }
841836
}
842837

843838
/** Get IP address with 5-second cache */
@@ -938,8 +933,8 @@ class DeviceInfo : HybridDeviceInfoSpec() {
938933
override val isLiquidGlassAvailable: Boolean = false
939934

940935
/**
941-
* Check if hardware-backed key storage is available
942-
* Android KeyStore is always hardware-backed on API 23+ (TEE or StrongBox)
936+
* Check if hardware-backed key storage is available Android KeyStore is always hardware-backed
937+
* on API 23+ (TEE or StrongBox)
943938
*/
944939
override val isHardwareKeyStoreAvailable: Boolean
945940
get() {
@@ -1007,6 +1002,32 @@ class DeviceInfo : HybridDeviceInfoSpec() {
10071002
}
10081003
}
10091004

1005+
// MARK: - Localization & Navigation
1006+
1007+
/** Get device system language in BCP 47 format */
1008+
override val systemLanguage: String
1009+
get() = Locale.getDefault().toLanguageTag()
1010+
1011+
/** Get Android navigation mode Values: 0 = 3-button, 1 = 2-button, 2 = gesture */
1012+
override val navigationMode: NavigationMode
1013+
get() {
1014+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
1015+
// Pre-Android 10 only has 3-button navigation
1016+
return NavigationMode.BUTTONS
1017+
}
1018+
return try {
1019+
when (Settings.Secure.getInt(context.contentResolver, "navigation_mode", 0)) {
1020+
0 -> NavigationMode.BUTTONS // 3-button navigation
1021+
1 -> NavigationMode.TWOBUTTONS // 2-button navigation
1022+
2 -> NavigationMode.GESTURE // Gesture navigation
1023+
else -> NavigationMode.UNKNOWN
1024+
}
1025+
} catch (e: Exception) {
1026+
Log.w(NAME, "Failed to get navigation mode", e)
1027+
NavigationMode.UNKNOWN
1028+
}
1029+
}
1030+
10101031
companion object {
10111032
const val NAME = "NitroDeviceInfo"
10121033
}

docs/docs/api/device-info.md

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,87 @@ const deviceName = DeviceInfoModule.deviceName;
486486

487487
---
488488

489+
## Localization & Navigation
490+
491+
### `systemLanguage: string`
492+
493+
Get device system language in BCP 47 format.
494+
495+
```typescript
496+
const language = DeviceInfoModule.systemLanguage;
497+
// iOS: "en-US", "ko-KR", "ja-JP", "zh-Hans-CN"
498+
// Android: "en-US", "ko-KR", "ja-JP", "zh-Hans-CN"
499+
```
500+
501+
**Platform**: iOS, Android
502+
503+
**Examples**:
504+
505+
| Language | Code |
506+
|----------|------|
507+
| English (US) | `en-US` |
508+
| Korean | `ko-KR` |
509+
| Japanese | `ja-JP` |
510+
| Simplified Chinese | `zh-Hans-CN` |
511+
| French (France) | `fr-FR` |
512+
| German | `de-DE` |
513+
514+
**Use Case**:
515+
516+
```typescript
517+
const language = DeviceInfoModule.systemLanguage;
518+
519+
if (language.startsWith('ko')) {
520+
console.log('Korean device detected');
521+
} else if (language.startsWith('ja')) {
522+
console.log('Japanese device detected');
523+
}
524+
```
525+
526+
### `navigationMode: NavigationMode`
527+
528+
Get Android navigation mode.
529+
530+
```typescript
531+
const navMode = DeviceInfoModule.navigationMode;
532+
// Android with gesture nav → "gesture"
533+
// Android with 3-button nav → "buttons"
534+
// Android with 2-button nav → "twobuttons"
535+
// iOS → "unknown"
536+
```
537+
538+
**Type**: `'gesture' | 'buttons' | 'twobuttons' | 'unknown'`
539+
540+
**Platform**: Android only (returns "unknown" on iOS)
541+
542+
**Values**:
543+
544+
| Value | Description |
545+
|-------|-------------|
546+
| `gesture` | Full gesture navigation (swipe-based) |
547+
| `buttons` | Traditional 3-button navigation (Back, Home, Recent) |
548+
| `twobuttons` | 2-button navigation (Back, Home with swipe up) |
549+
| `unknown` | Cannot determine (always on iOS) |
550+
551+
**Use Case**:
552+
553+
```typescript
554+
const navMode = DeviceInfoModule.navigationMode;
555+
556+
if (navMode === 'gesture') {
557+
// Avoid bottom gesture conflicts
558+
// Add extra bottom padding for bottom sheets
559+
console.log('Using gesture navigation - add extra padding');
560+
} else if (navMode === 'buttons') {
561+
// Traditional navigation bar is present
562+
console.log('Using button navigation');
563+
}
564+
```
565+
566+
**Note**: Navigation mode detection requires Android 10 (API 29) or later. On older Android versions, returns `"buttons"` since gesture navigation was not available.
567+
568+
---
569+
489570
## Platform-Specific Properties
490571

491572
### `apiLevel: number`
@@ -903,6 +984,8 @@ This provides fast access while keeping data reasonably fresh.
903984
| Battery info ||| Low power mode iOS only |
904985
| System resources ||| All platforms |
905986
| Network info ||| MAC hardcoded on iOS 7+ |
987+
| System language ||| BCP 47 format |
988+
| Navigation mode ||| Android only (API 29+) |
906989
| Android Build info ||| Android only |
907990
| hasNotch/Dynamic Island ||| iOS only |
908991
| Hardware KeyStore ||| All platforms |

docs/docs/api/types.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
PowerState,
1111
BatteryState,
1212
DeviceType,
13+
NavigationMode,
1314
} from 'react-native-nitro-device-info';
1415
```
1516

@@ -92,6 +93,49 @@ switch (powerState.batteryState) {
9293
}
9394
```
9495

96+
### NavigationMode
97+
98+
Android navigation mode types.
99+
100+
```typescript
101+
type NavigationMode = 'gesture' | 'buttons' | 'twobuttons' | 'unknown';
102+
```
103+
104+
**Values**:
105+
106+
- **gesture**: Full gesture navigation (swipe-based)
107+
- **buttons**: Traditional 3-button navigation (Back, Home, Recent)
108+
- **twobuttons**: 2-button navigation (Back, Home with swipe up)
109+
- **unknown**: Cannot determine (always returns this on iOS)
110+
111+
**Usage**:
112+
113+
```typescript
114+
const navMode = DeviceInfoModule.navigationMode;
115+
116+
switch (navMode) {
117+
case 'gesture':
118+
console.log('Device uses gesture navigation');
119+
// Add extra bottom padding for gesture conflicts
120+
break;
121+
case 'buttons':
122+
console.log('Device uses 3-button navigation');
123+
break;
124+
case 'twobuttons':
125+
console.log('Device uses 2-button navigation');
126+
break;
127+
case 'unknown':
128+
console.log('Navigation mode unknown (iOS)');
129+
break;
130+
}
131+
```
132+
133+
**Platform Behavior**:
134+
135+
- **Android API 29+**: Returns actual navigation mode
136+
- **Android API < 29**: Returns `"buttons"` (gesture nav didn't exist)
137+
- **iOS**: Always returns `"unknown"`
138+
95139
### DeviceType
96140

97141
Device category classification.
@@ -242,6 +286,10 @@ interface DeviceInfo extends HybridObject {
242286
// Installation Methods
243287
getInstallReferrer(): Promise<string>;
244288

289+
// Localization & Navigation Properties
290+
readonly systemLanguage: string;
291+
readonly navigationMode: NavigationMode;
292+
245293
// Advanced Capability Properties
246294
readonly isWiredHeadphonesConnected: boolean;
247295
readonly isBluetoothHeadphonesConnected: boolean;

example/showcase/ios/Podfile.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ PODS:
88
- hermes-engine (0.81.1):
99
- hermes-engine/Pre-built (= 0.81.1)
1010
- hermes-engine/Pre-built (0.81.1)
11-
- NitroDeviceInfo (1.1.0):
11+
- NitroDeviceInfo (1.2.1):
1212
- NitroModules
1313
- React-callinvoker
1414
- React-Core
@@ -2528,7 +2528,7 @@ SPEC CHECKSUMS:
25282528
fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd
25292529
glog: 5683914934d5b6e4240e497e0f4a3b42d1854183
25302530
hermes-engine: 4f8246b1f6d79f625e0d99472d1f3a71da4d28ca
2531-
NitroDeviceInfo: 6e2e20647d7020d463dd3c80a45b947966777bea
2531+
NitroDeviceInfo: d7178fee34161c1e7aa640410d51242192f42e54
25322532
NitroModules: 5f474732a91ef6a48e7788fb73b355bcb52999ee
25332533
RCT-Folly: 59ec0ac1f2f39672a0c6e6cecdd39383b764646f
25342534
RCTDeprecation: c4b9e2fd0ab200e3af72b013ed6113187c607077

example/showcase/src/config/propertyCategories.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,27 @@ export const PROPERTY_CONFIGS: Omit<DeviceProperty, 'value' | 'errorState'>[] =
297297
},
298298

299299
// ==========================================================================
300-
// CATEGORY 8: Platform Capabilities
300+
// CATEGORY 8: Localization & Navigation
301+
// ==========================================================================
302+
{
303+
key: 'systemLanguage',
304+
label: 'System Language',
305+
category: PropertyCategory.LOCALIZATION_NAVIGATION,
306+
type: PropertyType.STRING,
307+
platform: PlatformAvailability.ALL,
308+
isSync: true,
309+
},
310+
{
311+
key: 'navigationMode',
312+
label: 'Navigation Mode',
313+
category: PropertyCategory.LOCALIZATION_NAVIGATION,
314+
type: PropertyType.STRING,
315+
platform: PlatformAvailability.ALL,
316+
isSync: true,
317+
},
318+
319+
// ==========================================================================
320+
// CATEGORY 9: Platform Capabilities
301321
// ==========================================================================
302322
{
303323
key: 'apiLevel',

example/showcase/src/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export enum PropertyCategory {
2222
SYSTEM_RESOURCES = 'System Resources',
2323
APP_METADATA = 'Application Metadata',
2424
NETWORK_CONNECTIVITY = 'Network & Connectivity',
25+
LOCALIZATION_NAVIGATION = 'Localization & Navigation',
2526
PLATFORM_CAPABILITIES = 'Platform Capabilities',
2627
ANDROID_BUILD_INFO = 'Android Build Information',
2728
ADVANCED_FEATURES = 'Advanced Features',

0 commit comments

Comments
 (0)