rebreak-monorepo/apps/rebreak-native/plugins/with-rebreak-protection-android.js
chahinebrini c7fc237dfd feat(android-protection): device-admin uninstall-block + boot-receiver + config plugin
Android self-bind protection auf nahezu MDM-Niveau ohne Device-Owner:
- Device-Admin (RebreakDeviceAdminReceiver) blockt Uninstall OS-seitig, aktiv ab
  Boot ohne Prozess/a11y. Deaktivierung nur via 24h-Cooldown (removeDeviceAdmin in
  forceDisable). a11y blockt die DeviceAdminAdd-Settings-Seite (Class-Match, auf
  Samsung One UI per Logcat verifiziert).
- Boot-Receiver (RebreakVpnBootReceiver) startet VPN+a11y nach Reboot, damit der
  Tamper-Lock ohne manuellen App-Start hochkommt.
- Manifest-Wiring (Device-Admin-Receiver, Boot-Receiver, RECEIVE_BOOT_COMPLETED,
  device_admin.xml) ins with-rebreak-protection-android Config-Plugin verlagert →
  ueberlebt 'expo prebuild' (android/ ist gitignored).
- a11y-Detection zurueck auf die funktionierende Version: zu breites 'loeschen'-
  Uninstall-Keyword raus (blockte halbe Settings); a11y-Label jetzt 'ReBreak Schutz'.
- a11y-Deeplink behaelt den Samsung-Step-Guide (openAccessibilitySettings).

Session-Frontend in diesem Batch:
- Avatar-Placeholder: neutrales clarity-avatar-line SVG statt dominantem Blau.
- DiGA-Milestone folgt kumulativen protectedDays (erreicht rueckfall-anfaellige User).
- Dev-Build crasht nicht mehr ohne CallKit-Native-Modul.
- VPN-Permission-Dialog nur noch im Bypass-Fall.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 04:52:49 +02:00

308 lines
10 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="systemExempted" + intent-filter
* android.net.VpnService + permission BIND_VPN_SERVICE.
* (`systemExempted` ist seit Android 14 der korrekte Type für
* VPN-/Filter-Foreground-Services — vorher war `specialUse`+content_filter
* angedacht aber bringt mehr Probleme als Nutzen.)
* 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',
'android:foregroundServiceType': 'systemExempted',
'android:exported': 'false',
},
'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',
'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;