From 5e863aa62a5edc278a566363283dff3964d794d2 Mon Sep 17 00:00:00 2001 From: Antoine Miller Date: Thu, 4 Jun 2026 11:32:13 +0100 Subject: [PATCH 1/2] fix(expo): inject Android Health Connect permissions-rationale activity The Expo config plugin only wired up iOS (background delivery) and added nothing to the Android manifest. Health Connect requires the app to expose a permissions-rationale component; apps targeting Android 14+ (strictly enforced at API 36) that omit it have their health read permission silently revoked, so Health Connect reads (incl. Samsung-origin data) return empty while auth still succeeds. Managed Expo apps relying on this plugin never got the entry, so the rationale activity-alias was missing from their merged manifest. Extend the plugin to also run withAndroidManifest, adding the legacy ACTION_SHOW_PERMISSIONS_RATIONALE intent-filter to the launcher activity and an Android 14+ ViewPermissionUsageActivity alias (VIEW_PERMISSION_USAGE + HEALTH_PERMISSIONS). Each insert is skipped when an equivalent component already exists, so it is idempotent across prebuilds and coexists with apps that declare the rationale themselves. Health permissions and the healthdata are already supplied by the terra-android library manifest, so they are not re-added. Co-Authored-By: Claude Opus 4.8 --- plugin/src/__tests__/index.test.ts | 117 +++++++++++++++++++++++++++++ plugin/src/index.js | 97 ++++++++++++++++++++++-- plugin/src/index.ts | 104 +++++++++++++++++++++++-- 3 files changed, 307 insertions(+), 11 deletions(-) create mode 100644 plugin/src/__tests__/index.test.ts 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..dfdcdbf 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,89 @@ 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) + ); + +// Health Connect needs the app to expose a permissions-rationale component, or +// apps targeting Android 14+ (strictly enforced at API 36) have their health +// read permission silently revoked: reads come back empty while auth still +// succeeds. Managed Expo builds never declare it, so add it here. Each insert is +// skipped when an equivalent component already exists, so this is idempotent +// across repeated prebuilds and coexists with apps that declare it themselves. +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..c8d0200 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,101 @@ 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) + ); + +// Health Connect needs the app to expose a permissions-rationale component, or +// apps targeting Android 14+ (strictly enforced at API 36) have their health +// read permission silently revoked: reads come back empty while auth still +// succeeds. Managed Expo builds never declare it, so add it here. Each insert is +// skipped when an equivalent component already exists, so this is idempotent +// across repeated prebuilds and coexists with apps that declare it themselves. +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); From db6858ad5e98c68d456d49abcfdb52f764962192 Mon Sep 17 00:00:00 2001 From: Antoine Miller Date: Tue, 4 Aug 2026 08:45:00 +0100 Subject: [PATCH 2/2] docs(expo): state the rationale-component consequence in one line Co-Authored-By: Claude Opus 5 --- plugin/src/index.js | 7 +------ plugin/src/index.ts | 7 +------ 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/plugin/src/index.js b/plugin/src/index.js index dfdcdbf..359129b 100644 --- a/plugin/src/index.js +++ b/plugin/src/index.js @@ -61,12 +61,7 @@ const declaresAction = (component, action) => (filter.action ?? []).some((entry) => entry.$['android:name'] === action) ); -// Health Connect needs the app to expose a permissions-rationale component, or -// apps targeting Android 14+ (strictly enforced at API 36) have their health -// read permission silently revoked: reads come back empty while auth still -// succeeds. Managed Expo builds never declare it, so add it here. Each insert is -// skipped when an equivalent component already exists, so this is idempotent -// across repeated prebuilds and coexists with apps that declare it themselves. +// 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) { diff --git a/plugin/src/index.ts b/plugin/src/index.ts index c8d0200..fe05d53 100644 --- a/plugin/src/index.ts +++ b/plugin/src/index.ts @@ -77,12 +77,7 @@ const declaresAction = ( (filter.action ?? []).some((entry) => entry.$['android:name'] === action) ); -// Health Connect needs the app to expose a permissions-rationale component, or -// apps targeting Android 14+ (strictly enforced at API 36) have their health -// read permission silently revoked: reads come back empty while auth still -// succeeds. Managed Expo builds never declare it, so add it here. Each insert is -// skipped when an equivalent component already exists, so this is idempotent -// across repeated prebuilds and coexists with apps that declare it themselves. +// 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 {