Skip to content

Commit 7f29e89

Browse files
authored
Merge pull request #591 from mCodex/refactor/generalImprovements
Refactor/general improvements
2 parents 5017b91 + a9a84f1 commit 7f29e89

35 files changed

Lines changed: 1473 additions & 251 deletions

.editorconfig

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
root = true
2+
3+
[*]
4+
charset = utf-8
5+
end_of_line = lf
6+
indent_style = tab
7+
indent_size = 2
8+
insert_final_newline = true
9+
trim_trailing_whitespace = true
10+
11+
[*.{md,markdown}]
12+
trim_trailing_whitespace = false
13+
14+
[*.{yml,yaml,json}]
15+
indent_style = space
16+
indent_size = 2

__tests__/app.plugin.test.ts

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
/**
2+
* Unit tests for the Expo config plugin (`app.plugin.js`).
3+
*
4+
* `@expo/config-plugins` is not a dev dependency of this package (it would normally be supplied
5+
* transitively by the consuming Expo project), so we mock the with-* helpers and capture the
6+
* mod functions they receive. This is enough to verify the plugin shapes Info.plist, gradle
7+
* properties, Podfile properties, and AndroidManifest correctly — and that it is idempotent.
8+
*/
9+
10+
type ModFn<T> = (config: { modResults: T }) => { modResults: T }
11+
12+
interface CapturedMods {
13+
infoPlist: ModFn<Record<string, unknown>>[]
14+
gradle: ModFn<Array<{ type: 'property'; name: string; value: string }>>[]
15+
podfile: ModFn<Record<string, unknown>>[]
16+
manifest: ModFn<{
17+
manifest: { 'uses-permission'?: Array<{ $: Record<string, string> }> }
18+
}>[]
19+
}
20+
21+
const captured: CapturedMods = {
22+
infoPlist: [],
23+
gradle: [],
24+
podfile: [],
25+
manifest: [],
26+
}
27+
28+
const addPermission = jest.fn(
29+
(
30+
manifest: {
31+
manifest: { 'uses-permission'?: Array<{ $: Record<string, string> }> }
32+
},
33+
permission: string
34+
) => {
35+
manifest.manifest['uses-permission'] ??= []
36+
const list = manifest.manifest['uses-permission']
37+
if (
38+
!list.some((entry) => entry.$ && entry.$['android:name'] === permission)
39+
) {
40+
list.push({ $: { 'android:name': permission } })
41+
}
42+
}
43+
)
44+
45+
jest.mock(
46+
'@expo/config-plugins',
47+
() => ({
48+
AndroidConfig: { Permissions: { addPermission } },
49+
createRunOncePlugin: (plugin: unknown) => plugin,
50+
withInfoPlist: (config: unknown, mod: ModFn<Record<string, unknown>>) => {
51+
captured.infoPlist.push(mod)
52+
return config
53+
},
54+
withGradleProperties: (
55+
config: unknown,
56+
mod: ModFn<Array<{ type: 'property'; name: string; value: string }>>
57+
) => {
58+
captured.gradle.push(mod)
59+
return config
60+
},
61+
withPodfileProperties: (
62+
config: unknown,
63+
mod: ModFn<Record<string, unknown>>
64+
) => {
65+
captured.podfile.push(mod)
66+
return config
67+
},
68+
withAndroidManifest: (
69+
config: unknown,
70+
mod: ModFn<{
71+
manifest: {
72+
'uses-permission'?: Array<{ $: Record<string, string> }>
73+
}
74+
}>
75+
) => {
76+
captured.manifest.push(mod)
77+
return config
78+
},
79+
}),
80+
{ virtual: true }
81+
)
82+
83+
const loadPlugin = () => {
84+
jest.resetModules()
85+
captured.infoPlist = []
86+
captured.gradle = []
87+
captured.podfile = []
88+
captured.manifest = []
89+
addPermission.mockClear()
90+
return require('../app.plugin.js') as (
91+
config: object,
92+
props?: {
93+
faceIDPermission?: string | null
94+
enableNewArchitecture?: boolean
95+
}
96+
) => object
97+
}
98+
99+
describe('app.plugin', () => {
100+
it('writes default Face ID permission when missing', () => {
101+
const plugin = loadPlugin()
102+
plugin({})
103+
const plist: Record<string, unknown> = {}
104+
captured.infoPlist[0]?.({ modResults: plist })
105+
expect(plist.NSFaceIDUsageDescription).toBe(
106+
'Authenticate to access your secure data.'
107+
)
108+
})
109+
110+
it('respects a user-supplied Face ID permission already present in Info.plist', () => {
111+
const plugin = loadPlugin()
112+
plugin({})
113+
const plist: Record<string, unknown> = {
114+
NSFaceIDUsageDescription: 'My custom prompt.',
115+
}
116+
captured.infoPlist[0]?.({ modResults: plist })
117+
expect(plist.NSFaceIDUsageDescription).toBe('My custom prompt.')
118+
})
119+
120+
it('uses the provided faceIDPermission prop', () => {
121+
const plugin = loadPlugin()
122+
plugin({}, { faceIDPermission: 'Custom prop value.' })
123+
const plist: Record<string, unknown> = {}
124+
captured.infoPlist[0]?.({ modResults: plist })
125+
expect(plist.NSFaceIDUsageDescription).toBe('Custom prop value.')
126+
})
127+
128+
it('skips the Info.plist modifier when faceIDPermission is null', () => {
129+
const plugin = loadPlugin()
130+
plugin({}, { faceIDPermission: null })
131+
expect(captured.infoPlist).toHaveLength(0)
132+
})
133+
134+
it('adds USE_BIOMETRIC and legacy USE_FINGERPRINT permissions', () => {
135+
const plugin = loadPlugin()
136+
plugin({})
137+
const manifest = { manifest: {} } as {
138+
manifest: { 'uses-permission'?: Array<{ $: Record<string, string> }> }
139+
}
140+
captured.manifest[0]?.({ modResults: manifest })
141+
const list = manifest.manifest['uses-permission']
142+
expect(addPermission).toHaveBeenCalledWith(
143+
expect.anything(),
144+
'android.permission.USE_BIOMETRIC'
145+
)
146+
const fingerprint = list?.find(
147+
(entry) =>
148+
entry.$['android:name'] === 'android.permission.USE_FINGERPRINT'
149+
)
150+
expect(fingerprint?.$['android:maxSdkVersion']).toBe('28')
151+
})
152+
153+
it('is idempotent across repeated runs (no duplicate uses-permission entries)', () => {
154+
const plugin = loadPlugin()
155+
const manifestState = {
156+
manifest: { 'uses-permission': [] },
157+
} as {
158+
manifest: { 'uses-permission': Array<{ $: Record<string, string> }> }
159+
}
160+
plugin({})
161+
captured.manifest[0]?.({ modResults: manifestState })
162+
// Run again with the same state.
163+
captured.manifest[0]?.({ modResults: manifestState })
164+
const fingerprintEntries = manifestState.manifest['uses-permission'].filter(
165+
(entry) =>
166+
entry.$['android:name'] === 'android.permission.USE_FINGERPRINT'
167+
)
168+
expect(fingerprintEntries).toHaveLength(1)
169+
})
170+
171+
it('writes new arch flags by default', () => {
172+
const plugin = loadPlugin()
173+
plugin({})
174+
const gradle: Array<{ type: 'property'; name: string; value: string }> = []
175+
captured.gradle[0]?.({ modResults: gradle })
176+
const podfile: Record<string, unknown> = {}
177+
captured.podfile[0]?.({ modResults: podfile })
178+
expect(gradle).toEqual(
179+
expect.arrayContaining([
180+
{ type: 'property', name: 'newArchEnabled', value: 'true' },
181+
{ type: 'property', name: 'expo.jsEngine', value: 'hermes' },
182+
])
183+
)
184+
expect(podfile).toMatchObject({
185+
new_arch_enabled: 'true',
186+
RCT_NEW_ARCH_ENABLED: '1',
187+
})
188+
})
189+
190+
it('skips new arch flags when enableNewArchitecture is false', () => {
191+
const plugin = loadPlugin()
192+
plugin({}, { enableNewArchitecture: false })
193+
expect(captured.gradle).toHaveLength(0)
194+
expect(captured.podfile).toHaveLength(0)
195+
})
196+
})

app.plugin.d.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { ConfigPlugin } from '@expo/config-plugins'
2+
3+
/** Options accepted by the `react-native-sensitive-info` Expo config plugin. */
4+
export interface SensitiveInfoPluginProps {
5+
/**
6+
* Override for `NSFaceIDUsageDescription` injected into the iOS Info.plist.
7+
*
8+
* @defaultValue `"Authenticate to access your secure data."`
9+
* @remarks Pass `null` to skip the modifier entirely (useful when another plugin owns the key).
10+
* A user-set value in the source Info.plist is always preserved.
11+
*/
12+
readonly faceIDPermission?: string | null
13+
/**
14+
* When `false`, skips writing React Native New Architecture flags
15+
* (`newArchEnabled`, `RCT_NEW_ARCH_ENABLED`).
16+
*
17+
* @defaultValue `true`
18+
*/
19+
readonly enableNewArchitecture?: boolean
20+
}
21+
22+
declare const plugin: ConfigPlugin<SensitiveInfoPluginProps | undefined>
23+
export default plugin

app.plugin.js

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,34 @@
1+
/**
2+
* Expo config plugin for react-native-sensitive-info.
3+
*
4+
* Configures:
5+
* - New Architecture flags for both platforms
6+
* - `NSFaceIDUsageDescription` in iOS Info.plist (so Face ID prompts include a usage string)
7+
* - `USE_BIOMETRIC` and (legacy) `USE_FINGERPRINT` permissions in AndroidManifest.xml
8+
*
9+
* @typedef {object} SensitiveInfoPluginProps
10+
* @property {string | null} [faceIDPermission] - Override for `NSFaceIDUsageDescription`.
11+
* Defaults to `"Authenticate to access your secure data."`. Pass `null` to skip the modifier
12+
* entirely (useful when another plugin owns the key).
13+
* @property {boolean} [enableNewArchitecture] - When `false`, skips RN New Arch flags.
14+
* Defaults to `true`.
15+
*
16+
* @param {import('@expo/config-plugins').ExpoConfig} config
17+
* @param {SensitiveInfoPluginProps} [props]
18+
*/
119
const {
20+
AndroidConfig,
221
createRunOncePlugin,
22+
withAndroidManifest,
323
withGradleProperties,
24+
withInfoPlist,
425
withPodfileProperties,
526
} = require('@expo/config-plugins')
627

728
const pkg = require('./package.json')
829

30+
const DEFAULT_FACE_ID_PERMISSION = 'Authenticate to access your secure data.'
31+
932
function ensureGradleProperty(gradleProperties, name, value) {
1033
const property = gradleProperties.find((item) => item.name === name)
1134
if (property) {
@@ -31,9 +54,57 @@ function withIosNewArchitecture(config) {
3154
})
3255
}
3356

34-
function withSensitiveInfoExpo(config) {
35-
config = withAndroidNewArchitecture(config)
36-
config = withIosNewArchitecture(config)
57+
function withFaceIDUsageDescription(config, faceIDPermission) {
58+
if (faceIDPermission === null) return config
59+
return withInfoPlist(config, (modConfig) => {
60+
// Respect any user-set value (including empty strings); only fill when truly missing.
61+
if (modConfig.modResults.NSFaceIDUsageDescription == null) {
62+
modConfig.modResults.NSFaceIDUsageDescription =
63+
faceIDPermission ?? DEFAULT_FACE_ID_PERMISSION
64+
}
65+
return modConfig
66+
})
67+
}
68+
69+
function withBiometricPermissions(config) {
70+
return withAndroidManifest(config, (modConfig) => {
71+
const manifest = modConfig.modResults
72+
// USE_BIOMETRIC for API 28+. addUsesPermission is idempotent.
73+
AndroidConfig.Permissions.addPermission(
74+
manifest,
75+
'android.permission.USE_BIOMETRIC'
76+
)
77+
78+
// USE_FINGERPRINT for API ≤ 28. Manually upsert so we can pin maxSdkVersion=28.
79+
manifest.manifest['uses-permission'] ??= []
80+
const list = manifest.manifest['uses-permission']
81+
const existing = list.find(
82+
(entry) =>
83+
entry.$ &&
84+
entry.$['android:name'] === 'android.permission.USE_FINGERPRINT'
85+
)
86+
if (existing) {
87+
existing.$['android:maxSdkVersion'] = '28'
88+
} else {
89+
list.push({
90+
$: {
91+
'android:name': 'android.permission.USE_FINGERPRINT',
92+
'android:maxSdkVersion': '28',
93+
},
94+
})
95+
}
96+
return modConfig
97+
})
98+
}
99+
100+
function withSensitiveInfoExpo(config, props = {}) {
101+
const enableNewArchitecture = props.enableNewArchitecture !== false
102+
if (enableNewArchitecture) {
103+
config = withAndroidNewArchitecture(config)
104+
config = withIosNewArchitecture(config)
105+
}
106+
config = withFaceIDUsageDescription(config, props.faceIDPermission)
107+
config = withBiometricPermissions(config)
37108
return config
38109
}
39110

0 commit comments

Comments
 (0)