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
21 changes: 0 additions & 21 deletions __mocks__/expo-task-manager.ts

This file was deleted.

40 changes: 19 additions & 21 deletions app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,11 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
// and legacy storage permissions even when a transitive native dependency
// contributes them during manifest merging.
blockedPermissions: [
// Background location was removed from the app (Play policy: no declarable
// background-location feature). Block the permissions outright so a transitive
// native dependency cannot reintroduce them during manifest merging.
'android.permission.ACCESS_BACKGROUND_LOCATION',
'android.permission.FOREGROUND_SERVICE_LOCATION',
// Contributed by expo-notifications. withRestrictedBootReceivers strips every
// BOOT_COMPLETED intent-filter (Android 15 crashes apps that launch restricted
// foreground service types from boot), so nothing here listens for boot and the
Expand Down Expand Up @@ -158,28 +163,18 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
[
'expo-location',
{
// Foreground-only. The IC app centers the map and computes distances while the
// user has it open; it has no background-location feature, so the background /
// foreground-service flags and the task manager block stay off. Turning any of
// them back on re-adds ACCESS_BACKGROUND_LOCATION and gets the Play listing
// rejected for an undeclared background-location feature.
locationWhenInUsePermission: 'Allow Resgrid IC to show current location on map.',
locationAlwaysAndWhenInUsePermission: 'Allow Resgrid IC to use your location for department updates.',
locationAlwaysPermission: 'Resgrid IC needs to track your location for department AVL.',
isIosBackgroundLocationEnabled: true,
isAndroidBackgroundLocationEnabled: true,
isAndroidForegroundServiceEnabled: true,
taskManager: {
locationTaskName: 'location-updates',
locationTaskOptions: {
accuracy: 'balanced',
distanceInterval: 10,
timeInterval: 5000,
},
},
},
],
[
'expo-task-manager',
{
taskManager: {
taskName: 'location-updates',
},
// `false` deletes the key from Info.plist entirely (the plugin otherwise fills in
// its own default text). The "Always" strings advertise background location on
// iOS, and nothing here uses Core Motion.
locationAlwaysAndWhenInUsePermission: false,
locationAlwaysPermission: false,
motionUsagePermission: false,
},
],
[
Expand Down Expand Up @@ -263,6 +258,9 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
'./customManifest.plugin.js',
// Must run after customManifest.plugin.js: both edit the merged application node.
'./plugins/withRestrictedBootReceivers.js',
// Strips expo-location's location-typed foreground service: this app tracks location
// only in the foreground, so nothing may ship a background-location surface.
'./plugins/withoutBackgroundLocation.js',
'./plugins/withNotificationSounds.js',
'./plugins/withMediaButtonModule.js',
'./plugins/withInCallAudioModule.js',
Expand Down
2 changes: 1 addition & 1 deletion customManifest.plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const withForegroundService = (config) => {
mainApplication['service'].push({
$: {
'android:name': 'app.notifee.core.ForegroundService',
'android:foregroundServiceType': 'microphone|mediaPlayback|connectedDevice',
'android:foregroundServiceType': 'microphone|connectedDevice',
'tools:replace': 'android:foregroundServiceType',
},
});
Expand Down
6 changes: 6 additions & 0 deletions jest-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,10 +176,16 @@ jest.mock('@notifee/react-native', () => {
UNSPECIFIED: 'unspecified',
};

const AndroidForegroundServiceType = {
FOREGROUND_SERVICE_TYPE_MICROPHONE: 128,
FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE: 16,
};

return {
__esModule: true,
default: mockNotifee,
AndroidImportance,
AndroidForegroundServiceType,
};
});

Expand Down
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,6 @@
"expo-splash-screen": "~56.0.14",
"expo-status-bar": "~56.0.4",
"expo-system-ui": "~56.0.5",
"expo-task-manager": "~56.0.26",
"expo-video": "~56.1.4",
"expo-web-browser": "~56.0.6",
"geojson": "0.5.0",
Expand Down
32 changes: 18 additions & 14 deletions plugins/__tests__/android-boot-receivers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ type Manifest = {
};
};

// Mirrors what expo-task-manager and expo-notifications contribute during manifest merging.
// Mirrors what expo-notifications contributes during manifest merging.
const createManifest = (): Manifest => ({
manifest: {
$: { 'xmlns:android': 'http://schemas.android.com/apk/res/android' },
Expand All @@ -25,11 +25,11 @@ const createManifest = (): Manifest => ({
$: { 'android:name': '.MainApplication' },
receiver: [
{
$: { 'android:name': 'expo.modules.taskManager.TaskBroadcastReceiver', 'android:exported': 'false' },
$: { 'android:name': 'expo.modules.notifications.service.NotificationsService', 'android:enabled': 'true', 'android:exported': 'false' },
'intent-filter': [
{
action: [
{ $: { 'android:name': 'expo.modules.taskManager.TaskBroadcastReceiver.INTENT_ACTION' } },
{ $: { 'android:name': 'expo.modules.notifications.NOTIFICATION_EVENT' } },
{ $: { 'android:name': 'android.intent.action.BOOT_COMPLETED' } },
{ $: { 'android:name': 'android.intent.action.MY_PACKAGE_REPLACED' } },
],
Expand All @@ -47,16 +47,20 @@ const findReceiver = (manifest: Manifest, name: string) => manifest.manifest.app
const actionsOf = (manifest: Manifest, name: string) => (findReceiver(manifest, name)?.['intent-filter'] ?? []).flatMap((filter) => (filter.action ?? []).map((action) => action.$['android:name']));

describe('Android 15 boot receivers', () => {
it('drops BOOT_COMPLETED from the task manager receiver while keeping its explicit-intent registration', () => {
it('keeps MY_PACKAGE_REPLACED on the notifications receiver', () => {
const manifest = applyBootReceiverOverrides(createManifest()) as Manifest;
const actions = actionsOf(manifest, 'expo.modules.taskManager.TaskBroadcastReceiver');
const actions = actionsOf(manifest, 'expo.modules.notifications.service.NotificationsService');

expect(findReceiver(manifest, 'expo.modules.taskManager.TaskBroadcastReceiver')).toBeDefined();
expect(actions).not.toContain('android.intent.action.BOOT_COMPLETED');
expect(actions).toContain('expo.modules.taskManager.TaskBroadcastReceiver.INTENT_ACTION');
expect(findReceiver(manifest, 'expo.modules.notifications.service.NotificationsService')).toBeDefined();
expect(actions).toContain('android.intent.action.MY_PACKAGE_REPLACED');
});

it('does not declare the expo-task-manager boot receiver (background location removed)', () => {
const manifest = applyBootReceiverOverrides(createManifest()) as Manifest;

expect(findReceiver(manifest, 'expo.modules.taskManager.TaskBroadcastReceiver')).toBeUndefined();
});

it('keeps the notifications receiver resolvable by action so push delivery still works', () => {
const manifest = applyBootReceiverOverrides(createManifest()) as Manifest;
const actions = actionsOf(manifest, 'expo.modules.notifications.service.NotificationsService');
Expand All @@ -68,21 +72,19 @@ describe('Android 15 boot receivers', () => {
expect(actions).not.toContain('com.htc.intent.action.QUICKBOOT_POWERON');
});

it('marks both receivers as merger replacements and declares the tools namespace', () => {
it('marks the receiver as a merger replacement and declares the tools namespace', () => {
const manifest = applyBootReceiverOverrides(createManifest()) as Manifest;

expect(manifest.manifest.$['xmlns:tools']).toBe('http://schemas.android.com/tools');
['expo.modules.taskManager.TaskBroadcastReceiver', 'expo.modules.notifications.service.NotificationsService'].forEach((name) => {
expect(findReceiver(manifest, name)?.$['tools:node']).toBe('replace');
});
expect(findReceiver(manifest, 'expo.modules.notifications.service.NotificationsService')?.$['tools:node']).toBe('replace');
});

it('is idempotent across repeated prebuilds', () => {
const once = applyBootReceiverOverrides(createManifest()) as Manifest;
const twice = applyBootReceiverOverrides(once) as Manifest;

expect(twice.manifest.application[0].receiver).toHaveLength(2);
expect(actionsOf(twice, 'expo.modules.taskManager.TaskBroadcastReceiver')).not.toContain('android.intent.action.BOOT_COMPLETED');
expect(twice.manifest.application[0].receiver).toHaveLength(1);
expect(actionsOf(twice, 'expo.modules.notifications.service.NotificationsService')).not.toContain('android.intent.action.BOOT_COMPLETED');
});

it('blocks RECEIVE_BOOT_COMPLETED and registers the plugin', () => {
Expand All @@ -96,5 +98,7 @@ describe('Android 15 boot receivers', () => {
expect(config.android?.blockedPermissions).toContain('android.permission.RECEIVE_BOOT_COMPLETED');
expect(config.android?.permissions).not.toContain('android.permission.RECEIVE_BOOT_COMPLETED');
expect(config.plugins).toContain('./plugins/withRestrictedBootReceivers.js');
expect(config.android?.blockedPermissions).toContain('android.permission.ACCESS_BACKGROUND_LOCATION');
expect(config.android?.blockedPermissions).toContain('android.permission.FOREGROUND_SERVICE_LOCATION');
});
});
78 changes: 78 additions & 0 deletions plugins/__tests__/without-background-location.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import type { ConfigContext } from '@expo/config';

import createExpoConfig from '../../app.config';

const { removeLocationTaskService, LOCATION_TASK_SERVICE } = require('../withoutBackgroundLocation');

jest.mock('zod', () => jest.requireActual('zod'));

type Manifest = {
manifest: {
$: Record<string, string>;
application: {
$: Record<string, string>;
service?: { $: Record<string, string> }[];
}[];
};
};

// Mirrors what expo-location contributes during manifest merging.
const createManifest = (): Manifest => ({
manifest: {
$: { 'xmlns:android': 'http://schemas.android.com/apk/res/android' },
application: [
{
$: { 'android:name': '.MainApplication' },
service: [{ $: { 'android:name': LOCATION_TASK_SERVICE, 'android:exported': 'false', 'android:foregroundServiceType': 'location' } }],
},
],
},
});

const findService = (manifest: Manifest, name: string) => manifest.manifest.application[0].service?.find((service) => service.$['android:name'] === name);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Undefined property access in plugins/__tests__/without-background-location.test.ts: manifest.manifest.application[0] may be absent, so dereferencing .service can throw in the findService helper, including at line 59. Guard the nested path with optional chaining before accessing application[0].service.

Kody rule violation: Add null checks before accessing properties

const findService = (manifest: Manifest, name: string) => manifest.manifest.application?.[0]?.service?.find((service) => service.$['android:name'] === name);
Prompt for LLM

File plugins/__tests__/without-background-location.test.ts:

Line 32:

Undefined property access in `plugins/__tests__/without-background-location.test.ts`: `manifest.manifest.application[0]` may be absent, so dereferencing `.service` can throw in the `findService` helper, including at line 59. Guard the nested path with optional chaining before accessing `application[0].service`.

Suggested Code:

const findService = (manifest: Manifest, name: string) => manifest.manifest.application?.[0]?.service?.find((service) => service.$['android:name'] === name);

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Undefined property access in plugins/__tests__/without-background-location.test.ts: application[0] may be undefined, so the findService helper can throw when it dereferences .service, including at line 59. Add optional chaining or equivalent null checks on manifest.manifest.application?.[0]?.service before accessing nested members.

Kody rule violation: Add null checks to prevent NullReferenceException

const findService = (manifest: Manifest, name: string) => manifest.manifest.application?.[0]?.service?.find((service) => service.$['android:name'] === name);
Prompt for LLM

File plugins/__tests__/without-background-location.test.ts:

Line 32:

Undefined property access in `plugins/__tests__/without-background-location.test.ts`: `application[0]` may be undefined, so the `findService` helper can throw when it dereferences `.service`, including at line 59. Add optional chaining or equivalent null checks on `manifest.manifest.application?.[0]?.service` before accessing nested members.

Suggested Code:

const findService = (manifest: Manifest, name: string) => manifest.manifest.application?.[0]?.service?.find((service) => service.$['android:name'] === name);

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.


describe('background location removal', () => {
it('replaces the location task service with a merger removal directive', () => {
const manifest = removeLocationTaskService(createManifest()) as Manifest;
const service = findService(manifest, LOCATION_TASK_SERVICE);

expect(service?.$['tools:node']).toBe('remove');
expect(service?.$['android:foregroundServiceType']).toBeUndefined();
expect(manifest.manifest.$['xmlns:tools']).toBe('http://schemas.android.com/tools');
});

it('declares the removal even when the library manifest has not been merged in yet', () => {
const manifest = removeLocationTaskService({
manifest: {
$: { 'xmlns:android': 'http://schemas.android.com/apk/res/android' },
application: [{ $: { 'android:name': '.MainApplication' } }],
},
} as Manifest) as Manifest;

expect(findService(manifest, LOCATION_TASK_SERVICE)?.$['tools:node']).toBe('remove');
});

it('is idempotent across repeated prebuilds', () => {
const once = removeLocationTaskService(createManifest()) as Manifest;
const twice = removeLocationTaskService(once) as Manifest;

expect(twice.manifest.application[0].service).toHaveLength(1);
expect(findService(twice, LOCATION_TASK_SERVICE)?.$['tools:node']).toBe('remove');
});

it('keeps background location out of the app config', () => {
const config = createExpoConfig({
config: {
name: 'Resgrid IC',
slug: 'resgrid-ic',
},
} as ConfigContext);

expect(config.plugins).toContain('./plugins/withoutBackgroundLocation.js');
expect(config.android?.blockedPermissions).toContain('android.permission.ACCESS_BACKGROUND_LOCATION');
expect(config.android?.permissions).not.toContain('android.permission.ACCESS_BACKGROUND_LOCATION');
expect(config.ios?.infoPlist?.UIBackgroundModes).not.toContain('location');
expect(config.ios?.infoPlist?.NSLocationAlwaysUsageDescription).toBeUndefined();
expect(config.ios?.infoPlist?.NSLocationAlwaysAndWhenInUseUsageDescription).toBeUndefined();
});
});
34 changes: 8 additions & 26 deletions plugins/withRestrictedBootReceivers.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,46 +9,28 @@ const TOOLS_NAMESPACE = 'http://schemas.android.com/tools';
* throws ForegroundServiceStartNotAllowedException and crashes the app.
*
* This app declares several of those types (notifee's ForegroundService is
* microphone|mediaPlayback|connectedDevice, CallKeep's VoiceConnectionService is
* phoneCall, expo-audio's AudioControlsService is mediaPlayback, react-native-webrtc
* contributes mediaProjection), and two dependency manifests register receivers for
* microphone|connectedDevice, CallKeep's VoiceConnectionService is phoneCall,
* react-native-webrtc contributes mediaProjection), and expo-notifications registers a receiver for
* BOOT_COMPLETED that can reach `startForegroundService`:
*
* - expo.modules.taskManager.TaskBroadcastReceiver — on boot it restarts every
* registered task; the expo-location consumer calls startForegroundService.
* - expo.modules.notifications.service.NotificationsService — on boot it re-arms
* scheduled local notifications.
*
* Neither boot path is needed here: background location is opt-in and started from
* inside the app (src/services/location.ts), and the app never schedules local
* notifications — all notifications are push-delivered.
* That boot path is not needed here: the app never schedules local notifications — all
* notifications are push-delivered.
*
* Library manifests are merged in by Gradle, so the boot actions cannot be edited
* directly. Instead we re-declare each receiver in the app manifest with
* directly. Instead we re-declare the receiver in the app manifest with
* tools:node="replace", which makes the manifest merger take OUR element — attributes
* and intent-filters — verbatim in place of the library's.
*
* Both receivers MUST stay declared:
* - TaskBroadcastReceiver is targeted by explicit intents (TaskManagerUtils#createTaskIntent).
* - NotificationsService is resolved with queryBroadcastReceivers() on the
* expo.modules.notifications.NOTIFICATION_EVENT action — dropping that filter would
* kill ALL notification delivery, so it is preserved here.
* NotificationsService MUST stay declared: it is resolved with queryBroadcastReceivers()
* on the expo.modules.notifications.NOTIFICATION_EVENT action — dropping that filter
* would kill ALL notification delivery, so it is preserved here.
*
* MY_PACKAGE_REPLACED is kept: the Android 15 restriction is specific to BOOT_COMPLETED.
*/
const RECEIVER_OVERRIDES = [
{
name: 'expo.modules.taskManager.TaskBroadcastReceiver',
attributes: {
'android:exported': 'false',
},
intentFilters: [
{
attributes: {},
actions: ['expo.modules.taskManager.TaskBroadcastReceiver.INTENT_ACTION', 'android.intent.action.MY_PACKAGE_REPLACED'],
},
],
},
{
name: 'expo.modules.notifications.service.NotificationsService',
attributes: {
Expand Down
59 changes: 59 additions & 0 deletions plugins/withoutBackgroundLocation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
const { withAndroidManifest, AndroidConfig } = require('expo/config-plugins');

const TOOLS_NAMESPACE = 'http://schemas.android.com/tools';

/**
* The IC app tracks location only while it is in the foreground (map centering and
* distance calculations — see src/services/location.ts). It has no background-location
* feature, which is why app.config.ts leaves every expo-location background flag off and
* blocks ACCESS_BACKGROUND_LOCATION / FOREGROUND_SERVICE_LOCATION outright.
*
* expo-location's own library manifest still contributes this during merging:
*
* <service android:name=".services.LocationTaskService"
* android:foregroundServiceType="location" />
*
* Nothing starts it (the app never calls Location.startLocationUpdatesAsync and
* expo-task-manager is not installed), but it leaves a location-typed foreground service
* in the shipped manifest — exactly the signal a Play policy review reads as background
* location. Library manifests cannot be edited directly, so declare the same service in
* the app manifest with tools:node="remove": the merger drops the element and emits
* nothing for it.
*/
const LOCATION_TASK_SERVICE = 'expo.modules.location.services.LocationTaskService';

/**
* Pure manifest transform, exported for tests.
*
* @param {object} androidManifest parsed AndroidManifest.xml (xml2js shape)
* @returns {object} the same manifest, mutated
*/
const removeLocationTaskService = (androidManifest) => {
if (!androidManifest.manifest.$['xmlns:tools']) {
androidManifest.manifest.$['xmlns:tools'] = TOOLS_NAMESPACE;
}

const mainApplication = AndroidConfig.Manifest.getMainApplicationOrThrow(androidManifest);
const services = mainApplication.service ?? [];
const removal = { $: { 'android:name': LOCATION_TASK_SERVICE, 'tools:node': 'remove' } };
const existingIndex = services.findIndex((service) => service.$?.['android:name'] === LOCATION_TASK_SERVICE);

if (existingIndex >= 0) {
services[existingIndex] = removal;
} else {
services.push(removal);
}

mainApplication.service = services;
return androidManifest;
};

const withoutBackgroundLocation = (config) =>
withAndroidManifest(config, (config) => {
config.modResults = removeLocationTaskService(config.modResults);
return config;
});

module.exports = withoutBackgroundLocation;
module.exports.removeLocationTaskService = removeLocationTaskService;
module.exports.LOCATION_TASK_SERVICE = LOCATION_TASK_SERVICE;
Loading
Loading