RebreakVpnService.onStartCommand crashed with SecurityException because Android 16's validateForegroundServiceType rejects the implicit 2-arg startForeground(). Now passes FOREGROUND_SERVICE_TYPE_SPECIAL_USE explicitly (Google's documented best practice) and guards the call so a failed foreground promotion stops the service cleanly instead of crashing the app. Verified vs reported Galaxy A54 / Android 16 signature (97% of crash events, 1-user crash loop). Bundles pending working-tree work across native/marketing/locales/mac + graphify-out rebuild. gitignore: google-services.json + /screenshots/. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
327 lines
11 KiB
JavaScript
327 lines
11 KiB
JavaScript
/* eslint-disable @typescript-eslint/no-var-requires */
|
|
/**
|
|
* Expo Config-Plugin — wires the Android VpnService (DNS-Filter) +
|
|
* AccessibilityService (URL filter Layer 2) into AndroidManifest.xml at
|
|
* prebuild time.
|
|
*
|
|
* Was es macht:
|
|
* 1) Sorgt für `xmlns:tools` auf <manifest>.
|
|
* 2) Registriert <service .RebreakVpnService> mit
|
|
* foregroundServiceType="specialUse" (+ PROPERTY_SPECIAL_USE_FGS_SUBTYPE=vpn)
|
|
* + intent-filter android.net.VpnService + permission BIND_VPN_SERVICE.
|
|
* (`systemExempted` wurde verworfen: es verlangt auf Android 16/API 36 zur
|
|
* startForeground-Zeit den AKTIVEN-VPN-Zustand [anyOf android:activate_vpn],
|
|
* der erst ~ms nach establish() kommt → SecurityException/Crash. `specialUse`
|
|
* hat keine Laufzeit-Vorbedingung; braucht FOREGROUND_SERVICE_SPECIAL_USE +
|
|
* einmalige Play-„Special Use"-Erklärung.)
|
|
* 3) Registriert <service .RebreakAccessibilityService> mit
|
|
* android:permission=BIND_ACCESSIBILITY_SERVICE + intent-filter
|
|
* android.accessibilityservice.AccessibilityService + meta-data
|
|
* android.accessibilityservice → @xml/accessibility_service_config.
|
|
*
|
|
* Wird aus app.config.ts via `plugins: ['./plugins/with-rebreak-protection-android']`
|
|
* registriert. Idempotent — kann beliebig oft via `expo prebuild` laufen.
|
|
*
|
|
* Native Source: `modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/`
|
|
* - VpnService: expo.modules.rebreakprotection.vpn.RebreakVpnService
|
|
* - AccessibilityService: expo.modules.rebreakprotection.accessibility.RebreakAccessibilityService
|
|
*/
|
|
|
|
const {
|
|
withAndroidManifest,
|
|
withStringsXml,
|
|
withDangerousMod,
|
|
AndroidConfig,
|
|
} = require('@expo/config-plugins');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const VPN_SERVICE_CLASS =
|
|
'expo.modules.rebreakprotection.vpn.RebreakVpnService';
|
|
const A11Y_SERVICE_CLASS =
|
|
'expo.modules.rebreakprotection.accessibility.RebreakAccessibilityService';
|
|
const ADMIN_RECEIVER_CLASS =
|
|
'expo.modules.rebreakprotection.admin.RebreakDeviceAdminReceiver';
|
|
const BOOT_RECEIVER_CLASS =
|
|
'expo.modules.rebreakprotection.vpn.RebreakVpnBootReceiver';
|
|
|
|
// ─── 1) tools-Namespace auf <manifest> ──────────────────────────────────────
|
|
|
|
function ensureToolsNamespace(manifest) {
|
|
if (!manifest.manifest.$) manifest.manifest.$ = {};
|
|
if (!manifest.manifest.$['xmlns:tools']) {
|
|
manifest.manifest.$['xmlns:tools'] = 'http://schemas.android.com/tools';
|
|
}
|
|
}
|
|
|
|
// ─── 2) <service>-Tag für RebreakVpnService ─────────────────────────────────
|
|
|
|
function ensureVpnService(manifest) {
|
|
const application = AndroidConfig.Manifest.getMainApplicationOrThrow(manifest);
|
|
|
|
if (!application.service) application.service = [];
|
|
|
|
const alreadyDeclared = application.service.some(
|
|
(svc) => svc.$ && svc.$['android:name'] === VPN_SERVICE_CLASS,
|
|
);
|
|
if (alreadyDeclared) return;
|
|
|
|
application.service.push({
|
|
$: {
|
|
'android:name': VPN_SERVICE_CLASS,
|
|
'android:permission': 'android.permission.BIND_VPN_SERVICE',
|
|
// specialUse statt systemExempted: systemExempted verlangt auf Android 16
|
|
// (API 36) zur startForeground-Zeit, dass die App bereits der AKTIVE VPN ist
|
|
// (anyOf [android:activate_vpn]) — dieser Zustand kommt aber erst ~ms nach
|
|
// establish() (Race) → SecurityException. specialUse hat keine Laufzeit-
|
|
// Vorbedingung und geht deterministisch durch (braucht nur die Permission
|
|
// FOREGROUND_SERVICE_SPECIAL_USE + die PROPERTY unten + Play-Special-Use-Decl).
|
|
'android:foregroundServiceType': 'specialUse',
|
|
'android:exported': 'false',
|
|
},
|
|
property: [
|
|
{
|
|
$: {
|
|
'android:name': 'android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE',
|
|
'android:value': 'vpn',
|
|
},
|
|
},
|
|
],
|
|
'intent-filter': [
|
|
{
|
|
action: [{ $: { 'android:name': 'android.net.VpnService' } }],
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
// ─── 3) <service>-Tag für RebreakAccessibilityService ───────────────────────
|
|
|
|
function ensureAccessibilityService(manifest) {
|
|
const application = AndroidConfig.Manifest.getMainApplicationOrThrow(manifest);
|
|
|
|
if (!application.service) application.service = [];
|
|
|
|
const alreadyDeclared = application.service.some(
|
|
(svc) => svc.$ && svc.$['android:name'] === A11Y_SERVICE_CLASS,
|
|
);
|
|
if (alreadyDeclared) return;
|
|
|
|
application.service.push({
|
|
$: {
|
|
'android:name': A11Y_SERVICE_CLASS,
|
|
'android:permission': 'android.permission.BIND_ACCESSIBILITY_SERVICE',
|
|
'android:label': '@string/accessibility_service_summary',
|
|
// ReBreak-Logo in der Bedienungshilfen-Liste, damit der User die Zeile
|
|
// klar als ReBreak erkennt (sonst generisches/Default-Icon).
|
|
'android:icon': '@mipmap/ic_launcher',
|
|
'android:exported': 'true',
|
|
},
|
|
'intent-filter': [
|
|
{
|
|
action: [
|
|
{
|
|
$: {
|
|
'android:name':
|
|
'android.accessibilityservice.AccessibilityService',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
'meta-data': [
|
|
{
|
|
$: {
|
|
'android:name': 'android.accessibilityservice',
|
|
'android:resource': '@xml/accessibility_service_config',
|
|
},
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
// ─── 3b) RECEIVE_BOOT_COMPLETED permission ──────────────────────────────────
|
|
|
|
function ensureBootPermission(manifest) {
|
|
if (!manifest.manifest['uses-permission']) {
|
|
manifest.manifest['uses-permission'] = [];
|
|
}
|
|
const PERM = 'android.permission.RECEIVE_BOOT_COMPLETED';
|
|
const exists = manifest.manifest['uses-permission'].some(
|
|
(p) => p.$ && p.$['android:name'] === PERM,
|
|
);
|
|
if (!exists) {
|
|
manifest.manifest['uses-permission'].push({ $: { 'android:name': PERM } });
|
|
}
|
|
}
|
|
|
|
// ─── 3c) Device-Admin- + Boot-Receiver ──────────────────────────────────────
|
|
// Device-Admin: macht die App OS-seitig nicht-direkt-deinstallierbar — greift ab
|
|
// Boot, ohne dass Prozess/a11y laufen müssen. Deaktivierung nur via 24h-Cooldown
|
|
// im App-Code (removeDeviceAdmin).
|
|
// Boot-Receiver: startet VPN+a11y nach Reboot/Package-Replace neu, damit der
|
|
// Tamper-Lock nicht erst nach manuellem App-Start hochkommt.
|
|
|
|
function ensureReceivers(manifest) {
|
|
const application = AndroidConfig.Manifest.getMainApplicationOrThrow(manifest);
|
|
if (!application.receiver) application.receiver = [];
|
|
|
|
if (
|
|
!application.receiver.some(
|
|
(r) => r.$ && r.$['android:name'] === ADMIN_RECEIVER_CLASS,
|
|
)
|
|
) {
|
|
application.receiver.push({
|
|
$: {
|
|
'android:name': ADMIN_RECEIVER_CLASS,
|
|
'android:permission': 'android.permission.BIND_DEVICE_ADMIN',
|
|
'android:exported': 'true',
|
|
},
|
|
'meta-data': [
|
|
{
|
|
$: {
|
|
'android:name': 'android.app.device_admin',
|
|
'android:resource': '@xml/device_admin',
|
|
},
|
|
},
|
|
],
|
|
'intent-filter': [
|
|
{
|
|
action: [
|
|
{
|
|
$: { 'android:name': 'android.app.action.DEVICE_ADMIN_ENABLED' },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
if (
|
|
!application.receiver.some(
|
|
(r) => r.$ && r.$['android:name'] === BOOT_RECEIVER_CLASS,
|
|
)
|
|
) {
|
|
application.receiver.push({
|
|
$: {
|
|
'android:name': BOOT_RECEIVER_CLASS,
|
|
'android:enabled': 'true',
|
|
'android:exported': 'true',
|
|
},
|
|
'intent-filter': [
|
|
{
|
|
$: { 'android:priority': '999' },
|
|
action: [
|
|
{ $: { 'android:name': 'android.intent.action.BOOT_COMPLETED' } },
|
|
{ $: { 'android:name': 'android.intent.action.QUICKBOOT_POWERON' } },
|
|
{ $: { 'android:name': 'com.htc.intent.action.QUICKBOOT_POWERON' } },
|
|
{ $: { 'android:name': 'android.intent.action.MY_PACKAGE_REPLACED' } },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
}
|
|
}
|
|
|
|
// ─── 4) String resources für a11y-service ───────────────────────────────────
|
|
|
|
const A11Y_DESCRIPTION_TEXT =
|
|
'Sichert deinen Schutz gegen impulsives Abschalten ab: Solange App-Lock aktiv ist, kann das ReBreak-VPN nicht in den Einstellungen deaktiviert und die App nicht deinstalliert werden. Das Blockieren von Glücksspielseiten selbst übernimmt das VPN — diese Berechtigung sichert es nur. Du kannst den Schutz jederzeit über die Abkühlphase in der App beenden.';
|
|
const A11Y_SUMMARY_TEXT =
|
|
'ReBreak Schutz';
|
|
|
|
function withA11yStringResource(config) {
|
|
return withStringsXml(config, (cfg) => {
|
|
cfg.modResults = AndroidConfig.Strings.setStringItem(
|
|
[
|
|
{
|
|
$: { name: 'accessibility_service_description', translatable: 'false' },
|
|
_: A11Y_DESCRIPTION_TEXT,
|
|
},
|
|
],
|
|
cfg.modResults,
|
|
);
|
|
cfg.modResults = AndroidConfig.Strings.setStringItem(
|
|
[
|
|
{
|
|
$: { name: 'accessibility_service_summary', translatable: 'false' },
|
|
_: A11Y_SUMMARY_TEXT,
|
|
},
|
|
],
|
|
cfg.modResults,
|
|
);
|
|
return cfg;
|
|
});
|
|
}
|
|
|
|
// ─── 5) XML-config für AccessibilityService ─────────────────────────────────
|
|
// Kopiert die Source-of-Truth aus dem Modul-Verzeichnis statt einen
|
|
// hardcoded String zu pflegen — so bleibt Plugin + Service-Config immer sync.
|
|
|
|
const MODULE_A11Y_XML = path.resolve(
|
|
__dirname,
|
|
'../modules/rebreak-protection/android/src/main/res/xml/accessibility_service_config.xml',
|
|
);
|
|
|
|
function withA11yConfigXml(config) {
|
|
return withDangerousMod(config, [
|
|
'android',
|
|
async (cfg) => {
|
|
const xmlDir = path.join(
|
|
cfg.modRequest.platformProjectRoot,
|
|
'app/src/main/res/xml',
|
|
);
|
|
fs.mkdirSync(xmlDir, { recursive: true });
|
|
fs.copyFileSync(
|
|
MODULE_A11Y_XML,
|
|
path.join(xmlDir, 'accessibility_service_config.xml'),
|
|
);
|
|
return cfg;
|
|
},
|
|
]);
|
|
}
|
|
|
|
// ─── 5b) XML-config für Device-Admin (@xml/device_admin) ────────────────────
|
|
|
|
const MODULE_DEVICE_ADMIN_XML = path.resolve(
|
|
__dirname,
|
|
'../modules/rebreak-protection/android/src/main/res/xml/device_admin.xml',
|
|
);
|
|
|
|
function withDeviceAdminXml(config) {
|
|
return withDangerousMod(config, [
|
|
'android',
|
|
async (cfg) => {
|
|
const xmlDir = path.join(
|
|
cfg.modRequest.platformProjectRoot,
|
|
'app/src/main/res/xml',
|
|
);
|
|
fs.mkdirSync(xmlDir, { recursive: true });
|
|
fs.copyFileSync(
|
|
MODULE_DEVICE_ADMIN_XML,
|
|
path.join(xmlDir, 'device_admin.xml'),
|
|
);
|
|
return cfg;
|
|
},
|
|
]);
|
|
}
|
|
|
|
// ─── Composition ────────────────────────────────────────────────────────────
|
|
|
|
function withRebreakProtectionAndroid(config) {
|
|
config = withAndroidManifest(config, (cfg) => {
|
|
ensureToolsNamespace(cfg.modResults);
|
|
ensureBootPermission(cfg.modResults);
|
|
ensureVpnService(cfg.modResults);
|
|
ensureAccessibilityService(cfg.modResults);
|
|
ensureReceivers(cfg.modResults);
|
|
return cfg;
|
|
});
|
|
config = withA11yStringResource(config);
|
|
config = withA11yConfigXml(config);
|
|
config = withDeviceAdminXml(config);
|
|
return config;
|
|
}
|
|
|
|
module.exports = withRebreakProtectionAndroid;
|