/* 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 . * 2) Registriert 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 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 ────────────────────────────────────── function ensureToolsNamespace(manifest) { if (!manifest.manifest.$) manifest.manifest.$ = {}; if (!manifest.manifest.$['xmlns:tools']) { manifest.manifest.$['xmlns:tools'] = 'http://schemas.android.com/tools'; } } // ─── 2) -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) -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;