Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
root = true

[*]
charset = utf-8
end_of_line = lf
indent_style = tab
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true

[*.{md,markdown}]
trim_trailing_whitespace = false

[*.{yml,yaml,json}]
indent_style = space
indent_size = 2
196 changes: 196 additions & 0 deletions __tests__/app.plugin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
/**
* Unit tests for the Expo config plugin (`app.plugin.js`).
*
* `@expo/config-plugins` is not a dev dependency of this package (it would normally be supplied
* transitively by the consuming Expo project), so we mock the with-* helpers and capture the
* mod functions they receive. This is enough to verify the plugin shapes Info.plist, gradle
* properties, Podfile properties, and AndroidManifest correctly — and that it is idempotent.
*/

type ModFn<T> = (config: { modResults: T }) => { modResults: T }

interface CapturedMods {
infoPlist: ModFn<Record<string, unknown>>[]
gradle: ModFn<Array<{ type: 'property'; name: string; value: string }>>[]
podfile: ModFn<Record<string, unknown>>[]
manifest: ModFn<{
manifest: { 'uses-permission'?: Array<{ $: Record<string, string> }> }
}>[]
}

const captured: CapturedMods = {
infoPlist: [],
gradle: [],
podfile: [],
manifest: [],
}

const addPermission = jest.fn(
(
manifest: {
manifest: { 'uses-permission'?: Array<{ $: Record<string, string> }> }
},
permission: string
) => {
manifest.manifest['uses-permission'] ??= []
const list = manifest.manifest['uses-permission']
if (
!list.some((entry) => entry.$ && entry.$['android:name'] === permission)
) {
list.push({ $: { 'android:name': permission } })
}
}
)

jest.mock(
'@expo/config-plugins',
() => ({
AndroidConfig: { Permissions: { addPermission } },
createRunOncePlugin: (plugin: unknown) => plugin,
withInfoPlist: (config: unknown, mod: ModFn<Record<string, unknown>>) => {
captured.infoPlist.push(mod)
return config
},
withGradleProperties: (
config: unknown,
mod: ModFn<Array<{ type: 'property'; name: string; value: string }>>
) => {
captured.gradle.push(mod)
return config
},
withPodfileProperties: (
config: unknown,
mod: ModFn<Record<string, unknown>>
) => {
captured.podfile.push(mod)
return config
},
withAndroidManifest: (
config: unknown,
mod: ModFn<{
manifest: {
'uses-permission'?: Array<{ $: Record<string, string> }>
}
}>
) => {
captured.manifest.push(mod)
return config
},
}),
{ virtual: true }
)

const loadPlugin = () => {
jest.resetModules()
captured.infoPlist = []
captured.gradle = []
captured.podfile = []
captured.manifest = []
addPermission.mockClear()
return require('../app.plugin.js') as (
config: object,
props?: {
faceIDPermission?: string | null
enableNewArchitecture?: boolean
}
) => object
}

describe('app.plugin', () => {
it('writes default Face ID permission when missing', () => {
const plugin = loadPlugin()
plugin({})
const plist: Record<string, unknown> = {}
captured.infoPlist[0]?.({ modResults: plist })
expect(plist.NSFaceIDUsageDescription).toBe(
'Authenticate to access your secure data.'
)
})

it('respects a user-supplied Face ID permission already present in Info.plist', () => {
const plugin = loadPlugin()
plugin({})
const plist: Record<string, unknown> = {
NSFaceIDUsageDescription: 'My custom prompt.',
}
captured.infoPlist[0]?.({ modResults: plist })
expect(plist.NSFaceIDUsageDescription).toBe('My custom prompt.')
})

it('uses the provided faceIDPermission prop', () => {
const plugin = loadPlugin()
plugin({}, { faceIDPermission: 'Custom prop value.' })
const plist: Record<string, unknown> = {}
captured.infoPlist[0]?.({ modResults: plist })
expect(plist.NSFaceIDUsageDescription).toBe('Custom prop value.')
})

it('skips the Info.plist modifier when faceIDPermission is null', () => {
const plugin = loadPlugin()
plugin({}, { faceIDPermission: null })
expect(captured.infoPlist).toHaveLength(0)
})

it('adds USE_BIOMETRIC and legacy USE_FINGERPRINT permissions', () => {
const plugin = loadPlugin()
plugin({})
const manifest = { manifest: {} } as {
manifest: { 'uses-permission'?: Array<{ $: Record<string, string> }> }
}
captured.manifest[0]?.({ modResults: manifest })
const list = manifest.manifest['uses-permission']
expect(addPermission).toHaveBeenCalledWith(
expect.anything(),
'android.permission.USE_BIOMETRIC'
)
const fingerprint = list?.find(
(entry) =>
entry.$['android:name'] === 'android.permission.USE_FINGERPRINT'
)
expect(fingerprint?.$['android:maxSdkVersion']).toBe('28')
})

it('is idempotent across repeated runs (no duplicate uses-permission entries)', () => {
const plugin = loadPlugin()
const manifestState = {
manifest: { 'uses-permission': [] },
} as {
manifest: { 'uses-permission': Array<{ $: Record<string, string> }> }
}
plugin({})
captured.manifest[0]?.({ modResults: manifestState })
// Run again with the same state.
captured.manifest[0]?.({ modResults: manifestState })
const fingerprintEntries = manifestState.manifest['uses-permission'].filter(
(entry) =>
entry.$['android:name'] === 'android.permission.USE_FINGERPRINT'
)
expect(fingerprintEntries).toHaveLength(1)
})

it('writes new arch flags by default', () => {
const plugin = loadPlugin()
plugin({})
const gradle: Array<{ type: 'property'; name: string; value: string }> = []
captured.gradle[0]?.({ modResults: gradle })
const podfile: Record<string, unknown> = {}
captured.podfile[0]?.({ modResults: podfile })
expect(gradle).toEqual(
expect.arrayContaining([
{ type: 'property', name: 'newArchEnabled', value: 'true' },
{ type: 'property', name: 'expo.jsEngine', value: 'hermes' },
])
)
expect(podfile).toMatchObject({
new_arch_enabled: 'true',
RCT_NEW_ARCH_ENABLED: '1',
})
})

it('skips new arch flags when enableNewArchitecture is false', () => {
const plugin = loadPlugin()
plugin({}, { enableNewArchitecture: false })
expect(captured.gradle).toHaveLength(0)
expect(captured.podfile).toHaveLength(0)
})
})
23 changes: 23 additions & 0 deletions app.plugin.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { ConfigPlugin } from '@expo/config-plugins'

/** Options accepted by the `react-native-sensitive-info` Expo config plugin. */
export interface SensitiveInfoPluginProps {
/**
* Override for `NSFaceIDUsageDescription` injected into the iOS Info.plist.
*
* @defaultValue `"Authenticate to access your secure data."`
* @remarks Pass `null` to skip the modifier entirely (useful when another plugin owns the key).
* A user-set value in the source Info.plist is always preserved.
*/
readonly faceIDPermission?: string | null
/**
* When `false`, skips writing React Native New Architecture flags
* (`newArchEnabled`, `RCT_NEW_ARCH_ENABLED`).
*
* @defaultValue `true`
*/
readonly enableNewArchitecture?: boolean
}

declare const plugin: ConfigPlugin<SensitiveInfoPluginProps | undefined>
export default plugin
77 changes: 74 additions & 3 deletions app.plugin.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,34 @@
/**
* Expo config plugin for react-native-sensitive-info.
*
* Configures:
* - New Architecture flags for both platforms
* - `NSFaceIDUsageDescription` in iOS Info.plist (so Face ID prompts include a usage string)
* - `USE_BIOMETRIC` and (legacy) `USE_FINGERPRINT` permissions in AndroidManifest.xml
*
* @typedef {object} SensitiveInfoPluginProps
* @property {string | null} [faceIDPermission] - Override for `NSFaceIDUsageDescription`.
* Defaults to `"Authenticate to access your secure data."`. Pass `null` to skip the modifier
* entirely (useful when another plugin owns the key).
* @property {boolean} [enableNewArchitecture] - When `false`, skips RN New Arch flags.
* Defaults to `true`.
*
* @param {import('@expo/config-plugins').ExpoConfig} config
* @param {SensitiveInfoPluginProps} [props]
*/
const {
AndroidConfig,
createRunOncePlugin,
withAndroidManifest,
withGradleProperties,
withInfoPlist,
withPodfileProperties,
} = require('@expo/config-plugins')

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

const DEFAULT_FACE_ID_PERMISSION = 'Authenticate to access your secure data.'

function ensureGradleProperty(gradleProperties, name, value) {
const property = gradleProperties.find((item) => item.name === name)
if (property) {
Expand All @@ -31,9 +54,57 @@ function withIosNewArchitecture(config) {
})
}

function withSensitiveInfoExpo(config) {
config = withAndroidNewArchitecture(config)
config = withIosNewArchitecture(config)
function withFaceIDUsageDescription(config, faceIDPermission) {
if (faceIDPermission === null) return config
return withInfoPlist(config, (modConfig) => {
// Respect any user-set value (including empty strings); only fill when truly missing.
if (modConfig.modResults.NSFaceIDUsageDescription == null) {
modConfig.modResults.NSFaceIDUsageDescription =
faceIDPermission ?? DEFAULT_FACE_ID_PERMISSION
}
return modConfig
})
}

function withBiometricPermissions(config) {
return withAndroidManifest(config, (modConfig) => {
const manifest = modConfig.modResults
// USE_BIOMETRIC for API 28+. addUsesPermission is idempotent.
AndroidConfig.Permissions.addPermission(
manifest,
'android.permission.USE_BIOMETRIC'
)

// USE_FINGERPRINT for API ≤ 28. Manually upsert so we can pin maxSdkVersion=28.
manifest.manifest['uses-permission'] ??= []
const list = manifest.manifest['uses-permission']
const existing = list.find(
(entry) =>
entry.$ &&
entry.$['android:name'] === 'android.permission.USE_FINGERPRINT'
)
if (existing) {
existing.$['android:maxSdkVersion'] = '28'
} else {
list.push({
$: {
'android:name': 'android.permission.USE_FINGERPRINT',
'android:maxSdkVersion': '28',
},
})
}
return modConfig
})
}

function withSensitiveInfoExpo(config, props = {}) {
const enableNewArchitecture = props.enableNewArchitecture !== false
if (enableNewArchitecture) {
config = withAndroidNewArchitecture(config)
config = withIosNewArchitecture(config)
}
config = withFaceIDUsageDescription(config, props.faceIDPermission)
config = withBiometricPermissions(config)
return config
}

Expand Down
Loading
Loading