diff --git a/plugin/src/__tests__/index.test.ts b/plugin/src/__tests__/index.test.ts new file mode 100644 index 0000000..f33bc7d --- /dev/null +++ b/plugin/src/__tests__/index.test.ts @@ -0,0 +1,117 @@ +import { addHealthConnectPermissionsRationale } from '../index'; + +// The plugin module reads its own package.json at load time; stub it so the +// test does not depend on terra-react being resolvable from node_modules. +jest.mock( + 'terra-react/package.json', + () => ({ name: 'terra-react', version: '0.0.0' }), + { virtual: true } +); + +const SHOW_RATIONALE = 'androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE'; +const VIEW_PERMISSION_USAGE = 'android.intent.action.VIEW_PERMISSION_USAGE'; +const HEALTH_PERMISSIONS = 'android.intent.category.HEALTH_PERMISSIONS'; + +const freshManifest = (): any => ({ + manifest: { + $: { 'xmlns:android': 'http://schemas.android.com/apk/res/android' }, + application: [ + { + $: { 'android:name': '.MainApplication' }, + activity: [ + { + $: { 'android:name': '.MainActivity', 'android:exported': 'true' }, + 'intent-filter': [ + { + action: [ + { $: { 'android:name': 'android.intent.action.MAIN' } }, + ], + category: [ + { $: { 'android:name': 'android.intent.category.LAUNCHER' } }, + ], + }, + ], + }, + ], + }, + ], + }, +}); + +const filtersWithAction = (component: any, action: string) => + (component['intent-filter'] ?? []).filter((filter: any) => + (filter.action ?? []).some((a: any) => a.$['android:name'] === action) + ); + +describe('addHealthConnectPermissionsRationale', () => { + it('adds the legacy rationale filter and the Android 14+ activity-alias', () => { + const manifest = freshManifest(); + addHealthConnectPermissionsRationale(manifest); + + const application = manifest.manifest.application[0]; + const mainActivity = application.activity[0]; + + expect(filtersWithAction(mainActivity, SHOW_RATIONALE)).toHaveLength(1); + // The launcher intent-filter must survive. + expect(filtersWithAction(mainActivity, 'android.intent.action.MAIN')).toHaveLength(1); + + expect(application['activity-alias']).toHaveLength(1); + const alias = application['activity-alias'][0]; + expect(alias.$['android:exported']).toBe('true'); + expect(alias.$['android:targetActivity']).toBe('.MainActivity'); + expect(alias.$['android:permission']).toBe( + 'android.permission.START_VIEW_PERMISSION_USAGE' + ); + expect(alias['intent-filter'][0].action[0].$['android:name']).toBe( + VIEW_PERMISSION_USAGE + ); + expect(alias['intent-filter'][0].category[0].$['android:name']).toBe( + HEALTH_PERMISSIONS + ); + }); + + it('is idempotent across repeated prebuilds', () => { + const manifest = freshManifest(); + addHealthConnectPermissionsRationale(manifest); + addHealthConnectPermissionsRationale(manifest); + addHealthConnectPermissionsRationale(manifest); + + const application = manifest.manifest.application[0]; + expect(filtersWithAction(application.activity[0], SHOW_RATIONALE)).toHaveLength(1); + expect(application['activity-alias']).toHaveLength(1); + }); + + it('does not duplicate a rationale the app already declares itself', () => { + const manifest = freshManifest(); + manifest.manifest.application[0].activity[0]['intent-filter'].push({ + action: [{ $: { 'android:name': SHOW_RATIONALE } }], + }); + manifest.manifest.application[0]['activity-alias'] = [ + { + $: { 'android:name': 'ExistingAlias', 'android:targetActivity': '.MainActivity' }, + 'intent-filter': [ + { + action: [{ $: { 'android:name': VIEW_PERMISSION_USAGE } }], + category: [{ $: { 'android:name': HEALTH_PERMISSIONS } }], + }, + ], + }, + ]; + + addHealthConnectPermissionsRationale(manifest); + + const application = manifest.manifest.application[0]; + expect(filtersWithAction(application.activity[0], SHOW_RATIONALE)).toHaveLength(1); + expect(application['activity-alias']).toHaveLength(1); + expect(application['activity-alias'][0].$['android:name']).toBe('ExistingAlias'); + }); + + it('throws when the manifest has no main activity', () => { + const manifest: any = { + manifest: { + application: [{ $: { 'android:name': '.MainApplication' }, activity: [] }], + }, + }; + expect(() => addHealthConnectPermissionsRationale(manifest)).toThrow(); + }); +}); diff --git a/plugin/src/index.js b/plugin/src/index.js index f3074cc..359129b 100644 --- a/plugin/src/index.js +++ b/plugin/src/index.js @@ -1,4 +1,9 @@ -import { withAppDelegate, createRunOncePlugin } from '@expo/config-plugins'; +import { + AndroidConfig, + withAndroidManifest, + withAppDelegate, + createRunOncePlugin, +} from '@expo/config-plugins'; const withTerraBackgroundDelivery = (config) => { config = withAppDelegate(config, (delegateConfig) => { const { contents } = delegateConfig.modResults; @@ -41,9 +46,84 @@ const withTerraBackgroundDelivery = (config) => { }); return config; }; + +const SHOW_RATIONALE_ACTION = + 'androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE'; +const VIEW_PERMISSION_USAGE_ACTION = + 'android.intent.action.VIEW_PERMISSION_USAGE'; +const HEALTH_PERMISSIONS_CATEGORY = 'android.intent.category.HEALTH_PERMISSIONS'; +const START_VIEW_PERMISSION_USAGE = + 'android.permission.START_VIEW_PERMISSION_USAGE'; +const RATIONALE_ALIAS_NAME = 'ViewPermissionUsageActivity'; + +const declaresAction = (component, action) => + (component['intent-filter'] ?? []).some((filter) => + (filter.action ?? []).some((entry) => entry.$['android:name'] === action) + ); + +// Without this rationale component, Android 14+/API 36 silently revokes Health Connect reads: auth still succeeds, reads return empty. +export function addHealthConnectPermissionsRationale(androidManifest) { + const application = androidManifest.manifest.application?.[0]; + if (!application) { + throw new Error( + 'terra-react: AndroidManifest is missing the element' + ); + } + + const existing = [ + ...(application.activity ?? []), + ...(application['activity-alias'] ?? []), + ]; + const mainActivity = + AndroidConfig.Manifest.getMainActivityOrThrow(androidManifest); + + if ( + !existing.some((component) => declaresAction(component, SHOW_RATIONALE_ACTION)) + ) { + mainActivity['intent-filter'] = mainActivity['intent-filter'] ?? []; + mainActivity['intent-filter'].push({ + action: [{ $: { 'android:name': SHOW_RATIONALE_ACTION } }], + }); + } + + if ( + !existing.some((component) => + declaresAction(component, VIEW_PERMISSION_USAGE_ACTION) + ) + ) { + application['activity-alias'] = application['activity-alias'] ?? []; + application['activity-alias'].push({ + $: { + 'android:name': RATIONALE_ALIAS_NAME, + 'android:exported': 'true', + 'android:targetActivity': mainActivity.$['android:name'], + 'android:permission': START_VIEW_PERMISSION_USAGE, + }, + 'intent-filter': [ + { + action: [{ $: { 'android:name': VIEW_PERMISSION_USAGE_ACTION } }], + category: [{ $: { 'android:name': HEALTH_PERMISSIONS_CATEGORY } }], + }, + ], + }); + } + + return androidManifest; +} + +const withTerraHealthConnectRationale = (config) => + withAndroidManifest(config, (manifestConfig) => { + manifestConfig.modResults = addHealthConnectPermissionsRationale( + manifestConfig.modResults + ); + return manifestConfig; + }); + +const withTerra = (config) => { + config = withTerraBackgroundDelivery(config); + config = withTerraHealthConnectRationale(config); + return config; +}; + const pkg = require('terra-react/package.json'); -export default createRunOncePlugin( - withTerraBackgroundDelivery, - pkg.name, - pkg.version -); +export default createRunOncePlugin(withTerra, pkg.name, pkg.version); diff --git a/plugin/src/index.ts b/plugin/src/index.ts index 5530225..fe05d53 100644 --- a/plugin/src/index.ts +++ b/plugin/src/index.ts @@ -1,5 +1,7 @@ import { + AndroidConfig, ConfigPlugin, + withAndroidManifest, withAppDelegate, createRunOncePlugin, } from '@expo/config-plugins'; @@ -52,9 +54,96 @@ const withTerraBackgroundDelivery: ConfigPlugin = (config) => { return config; }; +const SHOW_RATIONALE_ACTION = + 'androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE'; +const VIEW_PERMISSION_USAGE_ACTION = + 'android.intent.action.VIEW_PERMISSION_USAGE'; +const HEALTH_PERMISSIONS_CATEGORY = 'android.intent.category.HEALTH_PERMISSIONS'; +const START_VIEW_PERMISSION_USAGE = + 'android.permission.START_VIEW_PERMISSION_USAGE'; +const RATIONALE_ALIAS_NAME = 'ViewPermissionUsageActivity'; + +type ManifestActivity = AndroidConfig.Manifest.ManifestActivity; +type ManifestApplicationWithAliases = + AndroidConfig.Manifest.ManifestApplication & { + 'activity-alias'?: ManifestActivity[]; + }; + +const declaresAction = ( + component: ManifestActivity, + action: string +): boolean => + (component['intent-filter'] ?? []).some((filter) => + (filter.action ?? []).some((entry) => entry.$['android:name'] === action) + ); + +// Without this rationale component, Android 14+/API 36 silently revokes Health Connect reads: auth still succeeds, reads return empty. +export function addHealthConnectPermissionsRationale( + androidManifest: AndroidConfig.Manifest.AndroidManifest +): AndroidConfig.Manifest.AndroidManifest { + const application = androidManifest.manifest.application?.[0] as + | ManifestApplicationWithAliases + | undefined; + if (!application) { + throw new Error( + 'terra-react: AndroidManifest is missing the element' + ); + } + + const existing: ManifestActivity[] = [ + ...(application.activity ?? []), + ...(application['activity-alias'] ?? []), + ]; + const mainActivity = + AndroidConfig.Manifest.getMainActivityOrThrow(androidManifest); + + if ( + !existing.some((component) => declaresAction(component, SHOW_RATIONALE_ACTION)) + ) { + mainActivity['intent-filter'] = mainActivity['intent-filter'] ?? []; + mainActivity['intent-filter'].push({ + action: [{ $: { 'android:name': SHOW_RATIONALE_ACTION } }], + }); + } + + if ( + !existing.some((component) => + declaresAction(component, VIEW_PERMISSION_USAGE_ACTION) + ) + ) { + application['activity-alias'] = application['activity-alias'] ?? []; + application['activity-alias'].push({ + $: { + 'android:name': RATIONALE_ALIAS_NAME, + 'android:exported': 'true', + 'android:targetActivity': mainActivity.$['android:name'], + 'android:permission': START_VIEW_PERMISSION_USAGE, + }, + 'intent-filter': [ + { + action: [{ $: { 'android:name': VIEW_PERMISSION_USAGE_ACTION } }], + category: [{ $: { 'android:name': HEALTH_PERMISSIONS_CATEGORY } }], + }, + ], + }); + } + + return androidManifest; +} + +const withTerraHealthConnectRationale: ConfigPlugin = (config) => + withAndroidManifest(config, (manifestConfig) => { + manifestConfig.modResults = addHealthConnectPermissionsRationale( + manifestConfig.modResults + ); + return manifestConfig; + }); + +const withTerra: ConfigPlugin = (config) => { + config = withTerraBackgroundDelivery(config); + config = withTerraHealthConnectRationale(config); + return config; +}; + const pkg = require('terra-react/package.json'); -export default createRunOncePlugin( - withTerraBackgroundDelivery, - pkg.name, - pkg.version -); +export default createRunOncePlugin(withTerra, pkg.name, pkg.version);