feat(android): Protection Module v2 — VPN self-heal, boot-receiver, multilang

- 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 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-06-01 04:30:07 +02:00
parent ab4b9c48e5
commit adf0d33f1b
9 changed files with 609 additions and 24 deletions

View File

@ -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<Pair<String, Intent>>()
// (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
}
}

View File

@ -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",
)
}
}

View File

@ -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

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="accessibility_service_description">يحمي حماية ReBreak من الإيقاف المتهور: طالما أن الحماية مفعّلة، لا يمكن تعطيل شبكة ReBreak VPN من الإعدادات ولا يمكن إلغاء تثبيت التطبيق. حظر مواقع المقامرة يتم عبر VPN — هذا الإذن يحميه فقط. يمكنك إنهاء الحماية في أي وقت عبر فترة التهدئة داخل التطبيق.</string>
<string name="accessibility_service_summary">ReBreak \u2014 الحماية</string>
<string name="a11y_guide_title">ReBreak خطوة بخطوة</string>
<string name="a11y_guide_btn_prev">رجوع</string>
<string name="a11y_guide_btn_next">التالي</string>
<string name="a11y_guide_btn_done">تم</string>
<string name="a11y_guide_btn_close">إغلاق</string>
<string name="a11y_overlay_permission_required">فعّل "الظهور فوق التطبيقات الأخرى" لتطبيق ReBreak.</string>
<string name="a11y_hint_step_open_installed">اضغط على «الخدمات المثبّتة».</string>
<string name="a11y_hint_step_select_rebreak">اضغط على «ReBreak — الحماية».</string>
<string name="a11y_hint_step_enable_toggle">فعّل المفتاح العلوي.</string>
<string name="a11y_hint_step_allow_confirm">اضغط على «سماح».</string>
</resources>

View File

@ -1,5 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="accessibility_service_description">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.</string>
<string name="accessibility_service_summary">Keeps protection from being switched off</string>
<string name="accessibility_service_description">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.</string>
<string name="accessibility_service_summary">ReBreak \u2014 Protection</string>
<string name="a11y_guide_title">ReBreak step-by-step</string>
<string name="a11y_guide_btn_prev">Back</string>
<string name="a11y_guide_btn_next">Next</string>
<string name="a11y_guide_btn_done">Done</string>
<string name="a11y_guide_btn_close">Close</string>
<string name="a11y_overlay_permission_required">Enable "Display over other apps" for ReBreak.</string>
<string name="a11y_hint_step_open_installed">Tap \u201CInstalled services\u201D.</string>
<string name="a11y_hint_step_select_rebreak">Tap \u201CReBreak \u2014 Protection\u201D.</string>
<string name="a11y_hint_step_enable_toggle">Turn on the top switch.</string>
<string name="a11y_hint_step_allow_confirm">Tap \u201CAllow\u201D.</string>
</resources>

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="accessibility_service_description">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.</string>
<string name="accessibility_service_summary">ReBreak \u2014 Protection</string>
<string name="a11y_guide_title">ReBreak pas à pas</string>
<string name="a11y_guide_btn_prev">Retour</string>
<string name="a11y_guide_btn_next">Suivant</string>
<string name="a11y_guide_btn_done">Terminé</string>
<string name="a11y_guide_btn_close">Fermer</string>
<string name="a11y_overlay_permission_required">Active «\u00A0Afficher par-dessus d\u2019autres applications\u00A0» pour ReBreak.</string>
<string name="a11y_hint_step_open_installed">Touche \u00AB\u00A0Services install\u00E9s\u00A0\u00BB.</string>
<string name="a11y_hint_step_select_rebreak">Touche \u00AB\u00A0ReBreak \u2014 Protection\u00A0\u00BB.</string>
<string name="a11y_hint_step_enable_toggle">Active l\'interrupteur du haut.</string>
<string name="a11y_hint_step_allow_confirm">Touche \u00AB\u00A0Autoriser\u00A0\u00BB.</string>
</resources>

View File

@ -1,5 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="accessibility_service_description">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.</string>
<string name="accessibility_service_summary">Sichert den Schutz gegen Abschalten ab</string>
<string name="accessibility_service_description">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.</string>
<string name="accessibility_service_summary">ReBreak \u2014 Schutz</string>
<string name="a11y_guide_title">ReBreak Schritt-für-Schritt</string>
<string name="a11y_guide_btn_prev">Zurück</string>
<string name="a11y_guide_btn_next">Weiter</string>
<string name="a11y_guide_btn_done">Fertig</string>
<string name="a11y_guide_btn_close">Schließen</string>
<string name="a11y_overlay_permission_required">Aktiviere "Über anderen Apps einblenden" für ReBreak.</string>
<string name="a11y_hint_step_open_installed">Tippe auf \u201EInstallierte Dienste\u201C.</string>
<string name="a11y_hint_step_select_rebreak">Tippe auf \u201EReBreak \u2014 Schutz\u201C.</string>
<string name="a11y_hint_step_enable_toggle">Schalte den oberen Schalter ein.</string>
<string name="a11y_hint_step_allow_confirm">Tippe auf \u201EZulassen\u201C.</string>
</resources>

View File

@ -199,8 +199,19 @@ declare class RebreakProtectionModule extends NativeModule<RebreakProtectionEven
isAccessibilityEnabled(): Promise<{ enabled: boolean }>;
/** 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<void>;
/** 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

View File

@ -63,6 +63,12 @@ class RebreakProtectionModuleWeb extends NativeModule<RebreakProtectionEvents> {
async openAccessibilitySettings() {
return { opened: false };
}
async dismissAccessibilityHint() {
// no-op
}
async openPowerDialog() {
return { opened: false };
}
async armTamperLock() {
return { armed: false };
}