Skip to content
Open
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
117 changes: 117 additions & 0 deletions plugin/src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
92 changes: 86 additions & 6 deletions plugin/src/index.js
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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 <application> 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);
99 changes: 94 additions & 5 deletions plugin/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import {
AndroidConfig,
ConfigPlugin,
withAndroidManifest,
withAppDelegate,
createRunOncePlugin,
} from '@expo/config-plugins';
Expand Down Expand Up @@ -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 <application> 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);