From adf0d33f1b149bc8831cbbb59c469f17abfa68f2 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Mon, 1 Jun 2026 04:30:07 +0200 Subject: [PATCH] =?UTF-8?q?feat(android):=20Protection=20Module=20v2=20?= =?UTF-8?q?=E2=80=94=20VPN=20self-heal,=20boot-receiver,=20multilang?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RebreakProtectionModule: reconcileVpn, Samsung overlay guide, openPowerDialog, armTamperLock preconditions (VPN+a11y required), openAccessibilitySettings fallback chain - RebreakVpnService: onRevoke auto-recover, blocklist self-heal on empty start, ACTION_RESTART für hard-reload nach blocklist-Änderung - VpnBootReceiver (neu): startet VPN nach Geräte-Neustart wenn filter_enabled=true - Strings: DE/EN/FR/AR a11y-Guide, Overlay-Permission, Hint-Steps - RebreakProtectionModule.ts: reconcileVpn, armTamperLock, disarmTamperLock, openPowerDialog, openAccessibilitySettings, dismissAccessibilityHint exports Co-Authored-By: Claude Sonnet 4.6 --- .../RebreakProtectionModule.kt | 451 +++++++++++++++++- .../vpn/RebreakVpnBootReceiver.kt | 61 +++ .../vpn/RebreakVpnService.kt | 42 ++ .../src/main/res/values-ar/strings.xml | 15 + .../src/main/res/values-en/strings.xml | 14 +- .../src/main/res/values-fr/strings.xml | 15 + .../android/src/main/res/values/strings.xml | 14 +- .../src/RebreakProtectionModule.ts | 15 +- .../src/RebreakProtectionModule.web.ts | 6 + 9 files changed, 609 insertions(+), 24 deletions(-) create mode 100644 apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/vpn/RebreakVpnBootReceiver.kt create mode 100644 apps/rebreak-native/modules/rebreak-protection/android/src/main/res/values-ar/strings.xml create mode 100644 apps/rebreak-native/modules/rebreak-protection/android/src/main/res/values-fr/strings.xml diff --git a/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/RebreakProtectionModule.kt b/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/RebreakProtectionModule.kt index ed21e43..5b9d8e8 100644 --- a/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/RebreakProtectionModule.kt +++ b/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/RebreakProtectionModule.kt @@ -6,10 +6,19 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import android.net.VpnService +import android.net.Uri import android.os.Build import android.provider.Settings +import android.graphics.Color +import android.graphics.PixelFormat import android.util.Log +import android.view.Gravity +import android.view.View +import android.view.WindowManager import android.view.accessibility.AccessibilityManager +import android.widget.Button +import android.widget.LinearLayout +import android.widget.TextView import expo.modules.kotlin.Promise import expo.modules.kotlin.exception.CodedException import expo.modules.kotlin.modules.Module @@ -46,6 +55,20 @@ class RebreakProtectionModule : Module() { private var pendingActivatePromise: Promise? = null private val ioExecutor = Executors.newCachedThreadPool() + // Sticky-Hint State: läuft als wiederholter Toast solange User in + // Accessibility-Settings navigiert. Stoppt automatisch sobald + // a11y-Service aktiviert ODER nach STICKY_HINT_MAX_SEC. + private val mainHandler = android.os.Handler(android.os.Looper.getMainLooper()) + private var stickyHintRunnable: Runnable? = null + private var stickyHintStepIndex: Int = 0 + private var samsungGuideOverlay: LinearLayout? = null + private var samsungGuideTextView: TextView? = null + private var samsungGuidePrevButton: Button? = null + private var samsungGuideNextButton: Button? = null + private var samsungGuideWindowManager: WindowManager? = null + private var samsungGuideLayoutParams: WindowManager.LayoutParams? = null + private var samsungGuideWatchRunnable: Runnable? = null + override fun definition() = ModuleDefinition { Name("RebreakProtection") @@ -215,30 +238,132 @@ class RebreakProtectionModule : Module() { mapOf("enabled" to isAccessibilityServiceEnabled(requireContext())) } + /** + * Stoppt den Repeating-Toast-Hint manuell. Sollte vom JS aufgerufen werden + * sobald die App wieder im Foreground ist (AppState-Listener) ODER wenn + * der User explizit „Abbrechen" tippt. Der Hint stoppt sonst von alleine + * sobald a11y-Service enabled oder nach STICKY_HINT_MAX_SEC. + */ + AsyncFunction("dismissAccessibilityHint") { + stopStickyHint() + stopSamsungGuideOverlay() + Unit + } + + AsyncFunction("openPowerDialog") { + val opened = RebreakAccessibilityService.requestPowerDialog() + if (!opened) { + Log.w(TAG, "openPowerDialog: accessibility service unavailable or action rejected") + } + mapOf("opened" to opened) + } + AsyncFunction("openAccessibilitySettings") { promise: Promise -> val ctx = requireContext() - val component = ComponentName(ctx, RebreakAccessibilityService::class.java) - val tries = listOf( - // Direkt zur Rebreak-Detail-Seite (Android 11+, viele OEMs) - Intent("android.settings.ACCESSIBILITY_DETAILS_SETTINGS").apply { - putExtra(":settings:fragment_args_key", component.flattenToString()) - val args = android.os.Bundle() - args.putString(":settings:fragment_args_key", component.flattenToString()) - putExtra(":settings:show_fragment_args", args) - flags = Intent.FLAG_ACTIVITY_NEW_TASK - }, - // Fallback: Allgemeine Bedienungshilfen-Liste - Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS).apply { + + // Für den Samsung-Schrittguide brauchen wir Overlay-Recht. + // Fehlt es, zuerst die Systemseite dafür öffnen (einmaliger Gatekeeper). + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(ctx)) { + if (openOverlayPermissionSettings(ctx)) { + promise.resolve(mapOf("opened" to true, "via" to "overlay-permission")) + return@AsyncFunction + } + } + + val rebreakA11yComponent = ComponentName(ctx, RebreakAccessibilityService::class.java) + val componentFlat = rebreakA11yComponent.flattenToString() + + // Fragment-Args, die viele Settings-Apps (Stock + Samsung One UI) als + // Auto-Scroll + Highlight-Hint verstehen — sowohl auf der Detail-Page + // als auch (als Fallback) auf der Listen-Page. + val highlightArgs = android.os.Bundle().apply { + putString(":settings:fragment_args_key", componentFlat) + } + + // Reihenfolge ist UX-kritisch — User soll IMMER so dicht wie möglich am + // Rebreak-Toggle landen (emotional belastete User, jede Extra-Tap kostet). + // 1) Modern (Android 13+): offizielles Extra → Pixel/AOSP zielsicher + // 2) Detail-Action + legacy Bundle-Args → meiste OEMs inkl. älterer Samsung + // 3) Direkter ComponentName auf AOSP-Detail-Activity → Stock-Android-Backup + // 4) Listen-Page + Fragment-Args → Samsung One UI scrollt/hebt hervor, der + // User sieht Rebreak markiert (nicht perfekt, aber 1 Tap statt 3-4) + // 5) Plain Listen-Page → letzte Notlösung, dann Toast-Anleitung + val tries = mutableListOf>() + + // (1) Android 13+ offizielle API + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + tries.add( + "details-modern-extra" to Intent("android.settings.ACCESSIBILITY_DETAILS_SETTINGS").apply { + // Settings.EXTRA_ACCESSIBILITY_SERVICE_COMPONENT_NAME (API 33+) + putExtra("android.provider.extra.ACCESSIBILITY_SERVICE_COMPONENT_NAME", componentFlat) + flags = Intent.FLAG_ACTIVITY_NEW_TASK + }, + ) + } + + // (2) Detail-Action mit legacy Fragment-Args + tries.add( + "details-legacy-args" to Intent("android.settings.ACCESSIBILITY_DETAILS_SETTINGS").apply { + putExtra(":settings:fragment_args_key", componentFlat) + putExtra(":settings:show_fragment_args", highlightArgs) flags = Intent.FLAG_ACTIVITY_NEW_TASK }, ) - for (intent in tries) { + + // (3) AOSP Detail-Activity direkt (ohne Action-Intent-Resolution) + tries.add( + "details-aosp-component" to Intent().apply { + setClassName( + "com.android.settings", + "com.android.settings.Settings\$AccessibilityDetailsSettingsActivity", + ) + putExtra(":settings:fragment_args_key", componentFlat) + putExtra(":settings:show_fragment_args", highlightArgs) + flags = Intent.FLAG_ACTIVITY_NEW_TASK + }, + ) + + // (4) Listen-Page MIT Highlight-Args → Samsung scrollt + markiert Rebreak + tries.add( + "list-highlighted" to Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS).apply { + putExtra(":settings:fragment_args_key", componentFlat) + putExtra(":settings:show_fragment_args", highlightArgs) + flags = Intent.FLAG_ACTIVITY_NEW_TASK + }, + ) + + // (5) Plain Listen-Page + tries.add( + "list-plain" to Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + }, + ) + + for ((tag, intent) in tries) { + // resolveActivity vorm Start checken — sonst zeigt Android bei ungültiger + // Action einen Toast „App nicht gefunden" und wir laufen blind durch alle + // Tries (verschwendete UI-Transitions). + val resolved = intent.resolveActivity(ctx.packageManager) + if (resolved == null) { + Log.d(TAG, "openA11y: $tag → no resolver, skip") + continue + } try { ctx.startActivity(intent) - promise.resolve(mapOf("opened" to true)) + Log.i(TAG, "openA11y: $tag → started ($resolved)") + // Bei den Fallback-Pfaden (4 + 5) Toast-Anleitung zeigen damit User + // emotional nicht in der generischen Eingabehilfe-Page verloren geht. + // Auf der Detail-Page (1-3) ist die UI klar genug, kein Toast nötig. + if (tag == "list-highlighted" || tag == "list-plain") { + if (!startSamsungGuideOverlay(ctx)) { + val hintText = currentStickyHintText(ctx) + startStickyHint(ctx, hintText) + } + } + promise.resolve(mapOf("opened" to true, "via" to tag)) return@AsyncFunction } catch (e: Exception) { - Log.w(TAG, "openAccessibilitySettings try failed: ${e.message}") + Log.w(TAG, "openA11y: $tag → failed: ${e.message}") } } promise.reject(CodedException("open_failed", "no accessibility settings activity available", null)) @@ -325,6 +450,291 @@ class RebreakProtectionModule : Module() { appContext.reactContext ?: throw CodedException("no_context", "ReactContext nicht verfügbar", null) + /** + * Sticky-Hint: wiederholter Toast als pragmatischer Ersatz für ein „echtes" + * Overlay (SYSTEM_ALERT_WINDOW würde noch ein Permission-Grant kosten, was + * für emotional belastete User onboarding-Friction wäre). + * + * PRE-enable ist kein A11y-Service aktiv; daher können wir den aktuellen + * Settings-Screen nicht zuverlässig erkennen (Vendor-abhängig). Statt + * Timer-Switching nutzen wir deshalb retry-basierten Progress: + * 1. Öffnen → "Installierte Dienste" + * 2. Nächstes Öffnen → "ReBreak — Schutz" + * 3. Nächstes Öffnen → "oberen Schalter + Zulassen" + * + * Damit bleibt jeder Toast kontextarm, klar und kurz — speziell für User in + * emotional belasteten Situationen. + * + * Toast.LENGTH_LONG ist hart auf ~3.5s gecapt; daher posten wir alle 3s neu. + * Auto-Stop: + * - sobald a11y-Service enabled (Polling via `Settings.Secure`) + * - nach STICKY_HINT_MAX_SEC (Safety-Net) + * - manuell via `stopStickyHint()` / `dismissAccessibilityHint` (App-Return) + * + * Wir machen explizit KEIN Foreground-Detection — wenn User aus Settings in + * unsere App zurückkommt während ein Toast feuert, ist das visuell etwas + * doppelt, aber harmlos. Eskalation auf Overlay wenn das in Praxis zu viel + * Lärm macht. + */ + private fun startStickyHint(ctx: Context, text: String) { + stopStickyHint() + val intervalMs = 3000L + val maxMs = STICKY_HINT_MAX_SEC * 1000L + val startedAt = android.os.SystemClock.uptimeMillis() + val runnable = object : Runnable { + override fun run() { + if (isAccessibilityServiceEnabled(ctx)) { + resetStickyHintProgress() + stopSamsungGuideOverlay() + bringRebreakToFront(ctx) + Log.i(TAG, "stickyHint: a11y service enabled → stop") + stickyHintRunnable = null + return + } + val elapsed = android.os.SystemClock.uptimeMillis() - startedAt + if (elapsed >= maxMs) { + Log.i(TAG, "stickyHint: timeout ${STICKY_HINT_MAX_SEC}s → stop") + stickyHintRunnable = null + return + } + android.widget.Toast + .makeText(ctx, text, android.widget.Toast.LENGTH_LONG) + .show() + mainHandler.postDelayed(this, intervalMs) + } + } + stickyHintRunnable = runnable + mainHandler.post(runnable) + } + + private fun startSamsungGuideOverlay(ctx: Context): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(ctx)) { + Log.w(TAG, "samsungGuide: SYSTEM_ALERT_WINDOW not granted, fallback to toast") + return false + } + stopStickyHint() + mainHandler.post { + try { + if (samsungGuideOverlay == null) { + samsungGuideWindowManager = ctx.getSystemService(Context.WINDOW_SERVICE) as WindowManager + + val container = LinearLayout(ctx).apply { + orientation = LinearLayout.VERTICAL + setPadding(26, 20, 26, 18) + setBackgroundColor(Color.parseColor("#E6191F24")) + } + + val title = TextView(ctx).apply { + text = ctx.getString(R.string.a11y_guide_title) + setTextColor(Color.WHITE) + textSize = 14f + } + + val message = TextView(ctx).apply { + setTextColor(Color.WHITE) + textSize = 16f + setPadding(0, 10, 0, 14) + } + + val actions = LinearLayout(ctx).apply { + orientation = LinearLayout.HORIZONTAL + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT, + ) + } + + val prev = Button(ctx).apply { + text = ctx.getString(R.string.a11y_guide_btn_prev) + setOnClickListener { + if (stickyHintStepIndex > 0) { + stickyHintStepIndex -= 1 + updateSamsungGuideOverlayUi(ctx) + } + } + } + + val next = Button(ctx).apply { + layoutParams = LinearLayout.LayoutParams( + 0, + LinearLayout.LayoutParams.WRAP_CONTENT, + 1f, + ).apply { + marginStart = 12 + } + setOnClickListener { + if (stickyHintStepIndex < 3) { + stickyHintStepIndex += 1 + updateSamsungGuideOverlayUi(ctx) + } else { + stopSamsungGuideOverlay() + } + } + } + + actions.addView(prev) + actions.addView(next) + + container.addView(title) + container.addView(message) + container.addView(actions) + + val lp = WindowManager.LayoutParams( + WindowManager.LayoutParams.WRAP_CONTENT, + WindowManager.LayoutParams.WRAP_CONTENT, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY + else WindowManager.LayoutParams.TYPE_PHONE, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, + PixelFormat.TRANSLUCENT, + ) + + samsungGuideOverlay = container + samsungGuideTextView = message + samsungGuidePrevButton = prev + samsungGuideNextButton = next + samsungGuideLayoutParams = lp + applySamsungGuideOverlayPosition() + samsungGuideWindowManager?.addView(container, lp) + } + updateSamsungGuideOverlayUi(ctx) + startSamsungGuideWatch(ctx) + } catch (e: Exception) { + Log.w(TAG, "samsungGuide: failed to show overlay: ${e.message}") + } + } + return true + } + + private fun startSamsungGuideWatch(ctx: Context) { + samsungGuideWatchRunnable?.let { mainHandler.removeCallbacks(it) } + val runnable = object : Runnable { + override fun run() { + if (samsungGuideOverlay == null) { + samsungGuideWatchRunnable = null + return + } + if (isAccessibilityServiceEnabled(ctx)) { + resetStickyHintProgress() + stopSamsungGuideOverlay() + bringRebreakToFront(ctx) + return + } + mainHandler.postDelayed(this, 700L) + } + } + samsungGuideWatchRunnable = runnable + mainHandler.postDelayed(runnable, 700L) + } + + private fun openOverlayPermissionSettings(ctx: Context): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return false + val intent = Intent( + Settings.ACTION_MANAGE_OVERLAY_PERMISSION, + Uri.parse("package:${ctx.packageName}"), + ).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + return try { + if (intent.resolveActivity(ctx.packageManager) != null) { + ctx.startActivity(intent) + Log.i(TAG, "overlayPermission: opened settings page") + true + } else { + Log.w(TAG, "overlayPermission: no resolver") + false + } + } catch (e: Exception) { + Log.w(TAG, "overlayPermission: failed: ${e.message}") + false + } + } + + private fun bringRebreakToFront(ctx: Context) { + try { + val launchIntent = ctx.packageManager.getLaunchIntentForPackage(ctx.packageName) + if (launchIntent != null) { + launchIntent.addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_CLEAR_TOP or + Intent.FLAG_ACTIVITY_SINGLE_TOP, + ) + ctx.startActivity(launchIntent) + Log.i(TAG, "bringToFront: launched app after a11y enabled") + } + } catch (e: Exception) { + Log.w(TAG, "bringToFront failed: ${e.message}") + } + } + + private fun updateSamsungGuideOverlayUi(ctx: Context) { + val messageRes = when (stickyHintStepIndex) { + 0 -> R.string.a11y_hint_step_open_installed + 1 -> R.string.a11y_hint_step_select_rebreak + 2 -> R.string.a11y_hint_step_enable_toggle + else -> R.string.a11y_hint_step_allow_confirm + } + samsungGuideTextView?.text = ctx.getString(messageRes) + samsungGuidePrevButton?.isEnabled = stickyHintStepIndex > 0 + samsungGuideNextButton?.text = + if (stickyHintStepIndex < 3) ctx.getString(R.string.a11y_guide_btn_next) + else ctx.getString(R.string.a11y_guide_btn_done) + applySamsungGuideOverlayPosition() + try { + val view = samsungGuideOverlay + val lp = samsungGuideLayoutParams + if (view != null && lp != null) samsungGuideWindowManager?.updateViewLayout(view, lp) + } catch (_: Exception) { + } + } + + private fun applySamsungGuideOverlayPosition() { + samsungGuideLayoutParams?.apply { + gravity = Gravity.CENTER + y = 0 + } + } + + private fun currentStickyHintText(ctx: Context): String = + when (stickyHintStepIndex) { + 0 -> ctx.getString(R.string.a11y_hint_step_open_installed) + 1 -> ctx.getString(R.string.a11y_hint_step_select_rebreak) + 2 -> ctx.getString(R.string.a11y_hint_step_enable_toggle) + else -> ctx.getString(R.string.a11y_hint_step_allow_confirm) + } + + private fun advanceStickyHintStep() { + if (stickyHintStepIndex < 3) stickyHintStepIndex += 1 + } + + private fun resetStickyHintProgress() { + stickyHintStepIndex = 0 + } + + private fun stopSamsungGuideOverlay() { + mainHandler.post { + try { + samsungGuideOverlay?.let { view -> + samsungGuideWindowManager?.removeView(view) + } + } catch (_: Exception) { + } finally { + samsungGuideWatchRunnable?.let { mainHandler.removeCallbacks(it) } + samsungGuideWatchRunnable = null + samsungGuideOverlay = null + samsungGuideTextView = null + samsungGuidePrevButton = null + samsungGuideNextButton = null + samsungGuideWindowManager = null + samsungGuideLayoutParams = null + } + } + } + + private fun stopStickyHint() { + stickyHintRunnable?.let { mainHandler.removeCallbacks(it) } + stickyHintRunnable = null + } + private fun startVpnService() { val ctx = requireContext() val intent = Intent(ctx, RebreakVpnService::class.java) @@ -389,8 +799,8 @@ class RebreakProtectionModule : Module() { /** * Live-Check: ist unser VPN tatsächlich aktiv? - * 1. VpnService.prepare(ctx) == null → wir haben Permission - * 2. UND (companion-Live-Flag ODER Prefs-Flag) + * 1. VpnService.prepare(ctx) == null → wir haben Permission + * 2. UND der VpnService läuft wirklich (`isRunning==true`) * * Wenn der User in System-Settings das VPN ausgeschaltet hat, returnt * prepare() ein non-null Intent (= "musst Permission neu holen") UND @@ -416,7 +826,10 @@ class RebreakProtectionModule : Module() { } return false } - return live || flag + // Kein Fallback mehr auf `filter_enabled` allein: nach Device-Reboot kann + // das Flag noch true sein, obwohl der VpnService noch nicht wieder läuft. + // Das führte zu falschem "voll geschützt"-Banner. + return live } /** @@ -542,5 +955,7 @@ class RebreakProtectionModule : Module() { private const val KEY_TAMPER_ARMED = "tamper_armed" private const val PREF_ETAG = "rebreak_blocklist_etag" private const val PREF_LAST_SYNC = "rebreak_blocklist_last_sync" + /** Max-Duration der Repeating-Toast-Hint nachdem User in Settings landet. */ + private const val STICKY_HINT_MAX_SEC = 30 } } diff --git a/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/vpn/RebreakVpnBootReceiver.kt b/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/vpn/RebreakVpnBootReceiver.kt new file mode 100644 index 0000000..3de1bec --- /dev/null +++ b/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/vpn/RebreakVpnBootReceiver.kt @@ -0,0 +1,61 @@ +package expo.modules.rebreakprotection.vpn + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.net.VpnService +import android.os.Build +import android.util.Log + +/** + * Re-establishes ReBreak VPN after device reboot/package replace when the user + * had protection enabled (`filter_enabled=true`). + */ +class RebreakVpnBootReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent?) { + val action = intent?.action ?: return + if (action !in BOOT_ACTIONS) return + + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val shouldBeOn = prefs.getBoolean(KEY_ENABLED, false) + if (!shouldBeOn) { + Log.i(TAG, "boot-restart skipped: filter_enabled=false") + return + } + + // Permission can be revoked by OS/user; then we cannot silently restart. + val consentIntent = try { VpnService.prepare(context) } catch (_: Exception) { null } + if (consentIntent != null) { + Log.w(TAG, "boot-restart skipped: VPN consent required") + prefs.edit().putBoolean(KEY_ENABLED, false).putBoolean(KEY_TAMPER_ARMED, false).apply() + return + } + + try { + val startIntent = Intent(context, RebreakVpnService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(startIntent) + } else { + context.startService(startIntent) + } + Log.i(TAG, "boot-restart: VPN service started") + } catch (e: Exception) { + Log.w(TAG, "boot-restart failed: ${e.message}") + } + } + + companion object { + private const val TAG = "RebreakVpnBoot" + private const val PREFS = "rebreak_filter_prefs" + private const val KEY_ENABLED = "filter_enabled" + private const val KEY_TAMPER_ARMED = "tamper_armed" + private val BOOT_ACTIONS = setOf( + Intent.ACTION_BOOT_COMPLETED, + Intent.ACTION_REBOOT, + Intent.ACTION_MY_PACKAGE_REPLACED, + "android.intent.action.QUICKBOOT_POWERON", + "com.htc.intent.action.QUICKBOOT_POWERON", + ) + } +} diff --git a/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/vpn/RebreakVpnService.kt b/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/vpn/RebreakVpnService.kt index 10cdcb2..57e583f 100644 --- a/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/vpn/RebreakVpnService.kt +++ b/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/vpn/RebreakVpnService.kt @@ -8,6 +8,8 @@ import android.content.Context import android.content.Intent import android.net.VpnService import android.os.Build +import android.os.Handler +import android.os.Looper import android.os.ParcelFileDescriptor import android.util.Log import androidx.core.app.NotificationCompat @@ -200,12 +202,52 @@ class RebreakVpnService : VpnService() { override fun onRevoke() { Log.i(TAG, "onRevoke: User hat VPN in System-Settings deaktiviert") + val prefs = applicationContext.getSharedPreferences("rebreak_filter_prefs", Context.MODE_PRIVATE) + val shouldBeOn = prefs.getBoolean("filter_enabled", false) + val tamperArmed = prefs.getBoolean("tamper_armed", false) + + // Tamper-Lock aktiv: Revoke als Bypass behandeln und VPN sofort + // wiederherstellen statt den Schutz-Status zu löschen. + if (shouldBeOn && tamperArmed) { + Log.w(TAG, "onRevoke: tamper bypass detected — attempting auto-recover") + stopVpn() + tryAutoRecoverAfterRevoke() + super.onRevoke() + return + } + clearEnabledFlag() stopVpn() stopSelf() super.onRevoke() } + private fun tryAutoRecoverAfterRevoke() { + val consentNeeded = try { VpnService.prepare(this) } catch (_: Exception) { null } + if (consentNeeded != null) { + Log.w(TAG, "auto-recover skipped: VPN consent required") + clearEnabledFlag() + stopSelf() + return + } + + Handler(Looper.getMainLooper()).postDelayed({ + try { + val intent = Intent(applicationContext, RebreakVpnService::class.java).apply { + action = ACTION_START + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + applicationContext.startForegroundService(intent) + } else { + applicationContext.startService(intent) + } + Log.i(TAG, "auto-recover: VPN service restart requested") + } catch (e: Exception) { + Log.w(TAG, "auto-recover failed: ${e.message}") + } + }, 350) + } + override fun onDestroy() { // KEIN clearEnabledFlag hier — onDestroy feuert auch wenn System uns // wegen Low-Memory killt. Wir wollen dann beim nächsten Start wieder diff --git a/apps/rebreak-native/modules/rebreak-protection/android/src/main/res/values-ar/strings.xml b/apps/rebreak-native/modules/rebreak-protection/android/src/main/res/values-ar/strings.xml new file mode 100644 index 0000000..69fb6e9 --- /dev/null +++ b/apps/rebreak-native/modules/rebreak-protection/android/src/main/res/values-ar/strings.xml @@ -0,0 +1,15 @@ + + + يحمي حماية ReBreak من الإيقاف المتهور: طالما أن الحماية مفعّلة، لا يمكن تعطيل شبكة ReBreak VPN من الإعدادات ولا يمكن إلغاء تثبيت التطبيق. حظر مواقع المقامرة يتم عبر VPN — هذا الإذن يحميه فقط. يمكنك إنهاء الحماية في أي وقت عبر فترة التهدئة داخل التطبيق. + ReBreak \u2014 الحماية + ReBreak خطوة بخطوة + رجوع + التالي + تم + إغلاق + فعّل "الظهور فوق التطبيقات الأخرى" لتطبيق ReBreak. + اضغط على «الخدمات المثبّتة». + اضغط على «ReBreak — الحماية». + فعّل المفتاح العلوي. + اضغط على «سماح». + diff --git a/apps/rebreak-native/modules/rebreak-protection/android/src/main/res/values-en/strings.xml b/apps/rebreak-native/modules/rebreak-protection/android/src/main/res/values-en/strings.xml index fcb9f82..3680992 100644 --- a/apps/rebreak-native/modules/rebreak-protection/android/src/main/res/values-en/strings.xml +++ b/apps/rebreak-native/modules/rebreak-protection/android/src/main/res/values-en/strings.xml @@ -1,5 +1,15 @@ - Keeps your protection from being switched off on impulse: while App-Lock is on, the ReBreak VPN can\'t be disabled in Settings and the app can\'t be uninstalled. Blocking gambling sites itself is handled by the VPN — this permission only safeguards it. You can always end protection via the cooldown in the app. - Keeps protection from being switched off + Safeguards your ReBreak protection from being switched off on impulse: while protection is on, the ReBreak VPN can\'t be disabled in Settings and the app can\'t be uninstalled. Blocking gambling sites itself is handled by the VPN — this permission only safeguards it. You can always end protection via the cooldown in the app. + ReBreak \u2014 Protection + ReBreak step-by-step + Back + Next + Done + Close + Enable "Display over other apps" for ReBreak. + Tap \u201CInstalled services\u201D. + Tap \u201CReBreak \u2014 Protection\u201D. + Turn on the top switch. + Tap \u201CAllow\u201D. diff --git a/apps/rebreak-native/modules/rebreak-protection/android/src/main/res/values-fr/strings.xml b/apps/rebreak-native/modules/rebreak-protection/android/src/main/res/values-fr/strings.xml new file mode 100644 index 0000000..e67b44e --- /dev/null +++ b/apps/rebreak-native/modules/rebreak-protection/android/src/main/res/values-fr/strings.xml @@ -0,0 +1,15 @@ + + + Protège ta protection ReBreak contre une désactivation impulsive : tant que la protection est active, le VPN ReBreak ne peut pas être désactivé dans les Paramètres et l\'application ne peut pas être désinstallée. Le blocage des sites de jeux d\'argent est assuré par le VPN — cette autorisation ne fait que le sécuriser. Tu peux mettre fin à la protection à tout moment via la phase de refroidissement dans l\'app. + ReBreak \u2014 Protection + ReBreak pas à pas + Retour + Suivant + Terminé + Fermer + Active «\u00A0Afficher par-dessus d\u2019autres applications\u00A0» pour ReBreak. + Touche \u00AB\u00A0Services install\u00E9s\u00A0\u00BB. + Touche \u00AB\u00A0ReBreak \u2014 Protection\u00A0\u00BB. + Active l\'interrupteur du haut. + Touche \u00AB\u00A0Autoriser\u00A0\u00BB. + diff --git a/apps/rebreak-native/modules/rebreak-protection/android/src/main/res/values/strings.xml b/apps/rebreak-native/modules/rebreak-protection/android/src/main/res/values/strings.xml index 54b7295..3e63468 100644 --- a/apps/rebreak-native/modules/rebreak-protection/android/src/main/res/values/strings.xml +++ b/apps/rebreak-native/modules/rebreak-protection/android/src/main/res/values/strings.xml @@ -1,5 +1,15 @@ - 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. - Sichert den Schutz gegen Abschalten ab + Sichert deinen ReBreak-Schutz gegen impulsives Abschalten ab: Solange der Schutz 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. + ReBreak \u2014 Schutz + ReBreak Schritt-für-Schritt + Zurück + Weiter + Fertig + Schließen + Aktiviere "Über anderen Apps einblenden" für ReBreak. + Tippe auf \u201EInstallierte Dienste\u201C. + Tippe auf \u201EReBreak \u2014 Schutz\u201C. + Schalte den oberen Schalter ein. + Tippe auf \u201EZulassen\u201C. diff --git a/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.ts b/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.ts index ea4d9d7..c513c13 100644 --- a/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.ts +++ b/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.ts @@ -199,8 +199,19 @@ declare class RebreakProtectionModule extends NativeModule; /** Android: Öffnet Settings → Bedienungshilfen, möglichst tief auf die - * Rebreak-Detail-Page (deep-link). Fallback: generelle A11y-Liste. */ - openAccessibilitySettings(): Promise<{ opened: boolean }>; + * Rebreak-Detail-Page (deep-link). Fallback: generelle A11y-Liste. Bei + * Fallback startet automatisch ein Repeating-Toast-Hint („Tippe …") der + * alle 3s wiederholt wird bis a11y aktiviert oder ~30s vergangen. */ + openAccessibilitySettings(): Promise<{ opened: boolean; via?: string }>; + + /** Android: Stoppt den Repeating-Toast-Hint manuell. JS sollte das bei + * AppState → 'active' aufrufen, damit nicht weiter Toasts über die App + * hereinflattern wenn User zurückgekommt ist. */ + dismissAccessibilityHint(): Promise; + + /** Android: Öffnet den System-Power-Dialog über den aktiven A11y-Service. + * Liefert `opened=false`, wenn der Service nicht aktiv ist oder OEM blockt. */ + openPowerDialog(): Promise<{ opened: boolean }>; /** Android: Aktiviert Tamper-Lock-Watchdog (Settings-Page-Blockade durch * AccessibilityService). Wirft `preconditions_not_met` wenn VPN oder A11y diff --git a/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.web.ts b/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.web.ts index a88130f..271d029 100644 --- a/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.web.ts +++ b/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.web.ts @@ -63,6 +63,12 @@ class RebreakProtectionModuleWeb extends NativeModule { async openAccessibilitySettings() { return { opened: false }; } + async dismissAccessibilityHint() { + // no-op + } + async openPowerDialog() { + return { opened: false }; + } async armTamperLock() { return { armed: false }; }