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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,7 @@ buck-out/

# Tests
coverage

# Expo config plugin (transpiled output ships with the package)
!plugin/build/
!plugin/build/**
25 changes: 25 additions & 0 deletions INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,31 @@ Linking the package manually is not required anymore with [Autolinking](https://
> - Some foregroundServiceType values may need additional permissions. For more information, refer to the [Android Documentation](https://developer.android.com/about/versions/14/changes/fgs-types-required).
> - Ensuring Compliance:
> - By setting the `foregroundServiceType` relevant to your use case, you ensure that your foreground services comply with the latest Android 14 requirements, allowing your application to function properly and meet the required security standards.

#### Using Expo (managed / prebuild workflow)

If you're using Expo Prebuild (the standard managed workflow with `app.json` / `app.config.js`), editing `AndroidManifest.xml` manually doesn't survive `expo prebuild`. This package ships with an Expo config plugin that adds the required permissions and the `android:foregroundServiceType` attribute to the service entry for you.

Add the plugin to your Expo config:

```json
{
"expo": {
"plugins": [
["react-native-background-actions", { "foregroundServiceType": "dataSync" }]
]
}
}
```

The plugin accepts a single string or an array of strings. The values must match the `foregroundServiceType` array you pass to `BackgroundService.start()`:

```json
["react-native-background-actions", { "foregroundServiceType": ["dataSync", "location"] }]
```

If the plugin is added without props, it defaults to `"dataSync"`. The corresponding `FOREGROUND_SERVICE_*` permission is added automatically for each type.

#### Using React Native < 0.60

You then need to link the native parts of the library for the platforms you are using. The easiest way to link the library is using the CLI tool by running this command from the root of your project:
Expand Down
97 changes: 97 additions & 0 deletions __tests__/plugin.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Lightweight smoke tests for the Expo config plugin.
// Mocks @expo/config-plugins so we don't need it as a real dependency.

jest.mock(
'@expo/config-plugins',
() => {
/** @param {any} config @param {(c: any) => any} mod */
const withAndroidManifest = (config, mod) => {
const next = { ...config, modResults: config._manifest };
const out = mod(next);
return { ...config, _manifest: out.modResults };
};
/** @param {any} config @param {string[]} permissions */
const withPermissions = (config, permissions) => {
const existing = config._permissions || [];
const merged = Array.from(new Set([...existing, ...permissions]));
return { ...config, _permissions: merged };
};
return {
withAndroidManifest,
AndroidConfig: { Permissions: { withPermissions } },
};
},
{ virtual: true }
);

const withBackgroundActions = require('../plugin/build/withBackgroundActions');

/** @returns {{_permissions: string[], _manifest: any}} */
const baseConfig = () => ({
_permissions: [],
_manifest: {
manifest: { application: [{ $: {}, service: [] }] },
},
});

/** @param {any} cfg */
const findService = (cfg) =>
cfg._manifest.manifest.application[0].service.find(
/** @param {any} s */ (s) =>
s.$['android:name'] === 'com.asterinet.react.bgactions.RNBackgroundActionsTask'
);

test('defaults to dataSync when called with no props', () => {
const cfg = withBackgroundActions(baseConfig());
const svc = findService(cfg);
expect(svc).toBeDefined();
expect(svc.$['android:foregroundServiceType']).toBe('dataSync');
expect(cfg._permissions).toContain('android.permission.FOREGROUND_SERVICE_DATA_SYNC');
expect(cfg._permissions).toContain('android.permission.FOREGROUND_SERVICE');
});

test('accepts a single type as a string', () => {
const cfg = withBackgroundActions(baseConfig(), {
foregroundServiceType: 'location',
});
expect(findService(cfg).$['android:foregroundServiceType']).toBe('location');
expect(cfg._permissions).toContain('android.permission.FOREGROUND_SERVICE_LOCATION');
});

test('merges multiple types with | and adds all permissions', () => {
const cfg = withBackgroundActions(baseConfig(), {
foregroundServiceType: ['dataSync', 'location'],
});
expect(findService(cfg).$['android:foregroundServiceType']).toBe('dataSync|location');
expect(cfg._permissions).toEqual(
expect.arrayContaining([
'android.permission.FOREGROUND_SERVICE_DATA_SYNC',
'android.permission.FOREGROUND_SERVICE_LOCATION',
])
);
});

test('throws on unknown type', () => {
expect(() =>
withBackgroundActions(baseConfig(), {
foregroundServiceType: 'nope',
})
).toThrow(/Unknown foregroundServiceType/);
});

test('updates existing service entry instead of duplicating', () => {
const cfg = baseConfig();
cfg._manifest.manifest.application[0].service.push({
$: {
'android:name': 'com.asterinet.react.bgactions.RNBackgroundActionsTask',
'android:foregroundServiceType': 'shortService',
},
});
const out = withBackgroundActions(cfg, { foregroundServiceType: 'dataSync' });
const services = out._manifest.manifest.application[0].service.filter(
/** @param {any} s */ (s) =>
s.$['android:name'] === 'com.asterinet.react.bgactions.RNBackgroundActionsTask'
);
expect(services).toHaveLength(1);
expect(services[0].$['android:foregroundServiceType']).toBe('dataSync');
});
1 change: 1 addition & 0 deletions app.plugin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('./plugin/build/withBackgroundActions');
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
"/windows",
"/src",
"/lib",
"/plugin/build",
"/app.plugin.js",
"/*.podspec",
"/jest"
],
Expand Down
89 changes: 89 additions & 0 deletions plugin/build/withBackgroundActions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// @ts-nocheck
const { withAndroidManifest, AndroidConfig } = require('@expo/config-plugins');

const SERVICE_NAME = 'com.asterinet.react.bgactions.RNBackgroundActionsTask';
const PKG = 'react-native-background-actions';

const TYPE_TO_PERMISSION = {
dataSync: 'android.permission.FOREGROUND_SERVICE_DATA_SYNC',
mediaPlayback: 'android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK',
phoneCall: 'android.permission.FOREGROUND_SERVICE_PHONE_CALL',
location: 'android.permission.FOREGROUND_SERVICE_LOCATION',
connectedDevice: 'android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE',
mediaProjection: 'android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION',
camera: 'android.permission.FOREGROUND_SERVICE_CAMERA',
microphone: 'android.permission.FOREGROUND_SERVICE_MICROPHONE',
health: 'android.permission.FOREGROUND_SERVICE_HEALTH',
remoteMessaging: 'android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING',
systemExempted: 'android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED',
shortService: 'android.permission.FOREGROUND_SERVICE_SHORT_SERVICE',
specialUse: 'android.permission.FOREGROUND_SERVICE_SPECIAL_USE',
};

const KNOWN_TYPES = Object.keys(TYPE_TO_PERMISSION);

function normalizeTypes(input) {
let raw;
if (input == null) raw = ['dataSync'];
else if (Array.isArray(input)) raw = input;
else raw = [input];

const types = [];
for (const t of raw) {
if (typeof t !== 'string' || !t.length) continue;
if (KNOWN_TYPES.indexOf(t) === -1)
throw new Error(
`[${PKG}] Unknown foregroundServiceType "${t}". Allowed: ${KNOWN_TYPES.join(', ')}`
);

if (types.indexOf(t) === -1) types.push(t);
}
if (!types.length) types.push('dataSync');
return types;
}

function setServiceForegroundType(androidManifest, manifestType) {
const application =
androidManifest.manifest.application && androidManifest.manifest.application[0];
if (!application) return;

application.service = application.service || [];
const existing = application.service.find(function(s) {
return s.$ && s.$['android:name'] === SERVICE_NAME;
});

if (existing) existing.$['android:foregroundServiceType'] = manifestType;
else
application.service.push({
$: {
'android:name': SERVICE_NAME,
'android:foregroundServiceType': manifestType,
'android:exported': 'false',
},
});
}

const withBackgroundActions = function(config, props) {
const opts = props || {};
const types = normalizeTypes(opts.foregroundServiceType);
// Android merges service-tag foregroundServiceType values via "|".
const manifestType = types.join('|');
const permissions = [
'android.permission.FOREGROUND_SERVICE',
'android.permission.WAKE_LOCK',
].concat(
types.map(function(t) {
return TYPE_TO_PERMISSION[t];
})
);

config = AndroidConfig.Permissions.withPermissions(config, permissions);

return withAndroidManifest(config, function(cfg) {
setServiceForegroundType(cfg.modResults, manifestType);
return cfg;
});
};

module.exports = withBackgroundActions;
module.exports.default = withBackgroundActions;
122 changes: 122 additions & 0 deletions plugin/src/withBackgroundActions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import {
AndroidConfig,
ConfigPlugin,
withAndroidManifest,
} from "@expo/config-plugins";

type ForegroundServiceType =
| "dataSync"
| "mediaPlayback"
| "phoneCall"
| "location"
| "connectedDevice"
| "mediaProjection"
| "camera"
| "microphone"
| "health"
| "remoteMessaging"
| "systemExempted"
| "shortService"
| "specialUse";

export type WithBackgroundActionsProps = {
/**
* Foreground service type(s) declared in AndroidManifest.xml. Must match
* the `foregroundServiceType` array passed to BackgroundService.start().
*
* Accepts a single string or an array of strings. Multiple values are
* merged with "|" (Android allows combining types).
*
* Defaults to "dataSync".
*
* @see https://developer.android.com/about/versions/14/changes/fgs-types-required
*/
foregroundServiceType?: ForegroundServiceType | ForegroundServiceType[];
};

const SERVICE_NAME = "com.asterinet.react.bgactions.RNBackgroundActionsTask";
const PKG = "react-native-background-actions";

const TYPE_TO_PERMISSION: Record<ForegroundServiceType, string> = {
dataSync: "android.permission.FOREGROUND_SERVICE_DATA_SYNC",
mediaPlayback: "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
phoneCall: "android.permission.FOREGROUND_SERVICE_PHONE_CALL",
location: "android.permission.FOREGROUND_SERVICE_LOCATION",
connectedDevice: "android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE",
mediaProjection: "android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION",
camera: "android.permission.FOREGROUND_SERVICE_CAMERA",
microphone: "android.permission.FOREGROUND_SERVICE_MICROPHONE",
health: "android.permission.FOREGROUND_SERVICE_HEALTH",
remoteMessaging: "android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING",
systemExempted: "android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED",
shortService: "android.permission.FOREGROUND_SERVICE_SHORT_SERVICE",
specialUse: "android.permission.FOREGROUND_SERVICE_SPECIAL_USE",
};

const KNOWN_TYPES = Object.keys(TYPE_TO_PERMISSION) as ForegroundServiceType[];

function normalizeTypes(
input: WithBackgroundActionsProps["foregroundServiceType"],
): ForegroundServiceType[] {
const raw = input == null ? ["dataSync" as const] : Array.isArray(input) ? input : [input];
const types: ForegroundServiceType[] = [];
for (const t of raw) {
if (typeof t !== "string" || !t.length) continue;
if (!KNOWN_TYPES.includes(t as ForegroundServiceType)) {
throw new Error(
`[${PKG}] Unknown foregroundServiceType "${t}". Allowed: ${KNOWN_TYPES.join(", ")}`,
);
}
if (!types.includes(t as ForegroundServiceType))
types.push(t as ForegroundServiceType);
}
if (!types.length) types.push("dataSync");
return types;
}

function setServiceForegroundType(
androidManifest: AndroidConfig.Manifest.AndroidManifest,
manifestType: string,
) {
const application = androidManifest.manifest.application?.[0];
if (!application) return;

application.service = application.service || [];
const existing = application.service.find(
(s) => s.$?.["android:name"] === SERVICE_NAME,
);

if (existing) {
existing.$["android:foregroundServiceType"] = manifestType;
} else {
application.service.push({
$: {
"android:name": SERVICE_NAME,
"android:foregroundServiceType": manifestType,
"android:exported": "false",
},
});
}
}

const withBackgroundActions: ConfigPlugin<WithBackgroundActionsProps | void> = (
config,
props,
) => {
const types = normalizeTypes(props?.foregroundServiceType);
const manifestType = types.join("|");
const permissions = [
"android.permission.FOREGROUND_SERVICE",
"android.permission.WAKE_LOCK",
...types.map((t) => TYPE_TO_PERMISSION[t]),
];

config = AndroidConfig.Permissions.withPermissions(config, permissions);

return withAndroidManifest(config, (cfg) => {
setServiceForegroundType(cfg.modResults, manifestType);
return cfg;
});
};

export default withBackgroundActions;