fix(rebreak-native): track custom native module source (was swallowed by .gitignore)

apps/rebreak-native/.gitignore had bare `ios/` + `android/` patterns meant for the
Expo-prebuild output dirs — but with no leading slash they also matched
modules/rebreak-protection/{android,ios}, so the entire custom expo native module
(RebreakProtectionModule.kt, RebreakAccessibilityService.kt, RebreakVpnService.kt,
the DNS filter, the iOS NEFilter extension, podspec, ...) was never tracked. A
fresh clone / CI / `git clean` would lose it.

Anchor the prebuild patterns (`/ios/`, `/android/`), keep ignoring the module's
build artifacts (build/, .cxx/, .gradle/, Pods/), and commit the source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-11 17:22:22 +02:00
parent 5291a8a95a
commit a80cc8b08d
17 changed files with 2613 additions and 3 deletions

View File

@ -7,9 +7,17 @@ dist/
web-build/
expo-env.d.ts
# Native
ios/
android/
# Native (Expo-prebuild output — regenerated by `expo prebuild`, not tracked).
# Anchored so it does NOT swallow the custom native module's source under modules/.
/ios/
/android/
# Custom native module (modules/rebreak-protection/{android,ios}) source IS tracked,
# but its build artifacts are not.
modules/*/android/build/
modules/*/android/.cxx/
modules/*/android/.gradle/
modules/*/ios/build/
modules/*/ios/Pods/
*.jks
*.p12
*.key

View File

@ -0,0 +1,25 @@
plugins {
id 'com.android.library'
id 'kotlin-android'
id 'expo-module-gradle-plugin'
}
group = 'org.rebreak'
version = '0.1.0'
android {
namespace "expo.modules.rebreakprotection"
defaultConfig {
versionCode 1
versionName "0.1.0"
minSdkVersion 26
}
testOptions {
unitTests.returnDefaultValues = true
}
}
dependencies {
implementation "androidx.core:core-ktx:1.12.0"
testImplementation "junit:junit:4.13.2"
}

View File

@ -0,0 +1,494 @@
package expo.modules.rebreakprotection
import android.accessibilityservice.AccessibilityServiceInfo
import android.app.Activity
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.net.VpnService
import android.os.Build
import android.provider.Settings
import android.util.Log
import android.view.accessibility.AccessibilityManager
import expo.modules.kotlin.Promise
import expo.modules.kotlin.exception.CodedException
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.rebreakprotection.accessibility.RebreakAccessibilityService
import expo.modules.rebreakprotection.vpn.RebreakVpnService
import java.io.File
import java.net.HttpURLConnection
import java.net.UnknownHostException
import java.net.URL
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import java.util.concurrent.Executors
/**
* Step-3-Implementation des Android-Pendants zu iOS NEFilter.
*
* activate() VpnService.prepare() User-Consent start RebreakVpnService
* disable() ACTION_STOP Service stoppt
* getDeviceState() vpn / accessibility / tamperLock + Blocklist-Metadaten
* syncBlocklist() HTTP-Download + ETag-Caching + atomic-replace + Service-Reload
* runHealthProbe() HEAD bet365.com blocked/loaded/timeout/offline
* openSystemSettings(target) Generic Settings-Intent
* isAccessibilityEnabled() Live-Check via AccessibilityManager + Settings.Secure
* openAccessibilitySettings() Deep-link zu Rebreak-Detail-Page (fallback liste)
* armTamperLock() setzt SharedPref-Flag, requires VPN+A11y
* disarmTamperLock() cleart SharedPref-Flag
* getProtectionStatus() vpn/a11y/blocklistCount/tamperArmed
*/
class RebreakProtectionModule : Module() {
private var pendingActivatePromise: Promise? = null
private val ioExecutor = Executors.newCachedThreadPool()
override fun definition() = ModuleDefinition {
Name("RebreakProtection")
Events("onLayerChange")
OnActivityResult { _, payload ->
if (payload.requestCode != VPN_CONSENT_REQUEST_CODE) return@OnActivityResult
val promise = pendingActivatePromise ?: return@OnActivityResult
pendingActivatePromise = null
if (payload.resultCode == Activity.RESULT_OK) {
startVpnService()
saveEnabled(true)
promise.resolve(activateSuccessResult())
} else {
saveEnabled(false)
promise.resolve(
mapOf(
"allLayersOn" to false,
"missingLayers" to listOf("vpn", "accessibility", "tamperLock"),
"errors" to listOf("vpn_permission_denied"),
)
)
}
sendLayerChange()
}
AsyncFunction("activate") { promise: Promise ->
val activity = appContext.currentActivity
?: return@AsyncFunction promise.reject(
CodedException("no_activity", "Activity nicht verfügbar — App im Hintergrund?", null)
)
val consentIntent = VpnService.prepare(activity)
if (consentIntent == null) {
startVpnService()
saveEnabled(true)
promise.resolve(activateSuccessResult())
sendLayerChange()
} else {
if (pendingActivatePromise != null) {
promise.reject(CodedException("consent_in_flight", "Bereits laufender VPN-Consent-Dialog", null))
return@AsyncFunction
}
pendingActivatePromise = promise
activity.startActivityForResult(consentIntent, VPN_CONSENT_REQUEST_CODE)
}
}
AsyncFunction("disable") {
val ctx = requireContext()
val intent = Intent(ctx, RebreakVpnService::class.java).apply {
action = RebreakVpnService.ACTION_STOP
}
try {
ctx.startService(intent)
} catch (e: Exception) {
Log.w(TAG, "stopService failed: ${e.message}")
}
saveEnabled(false)
// Tamper-Lock IMMER mit-disarmen — egal welcher Caller `disable()` aufruft.
// Sonst läuft der Accessibility-Watchdog (Settings-Block + Gambling-Block)
// nach dem Cooldown weiter, obwohl der User legitim deaktiviert hat.
disarmTamperLock(ctx)
sendLayerChange()
mapOf("allLayersOff" to true)
}
AsyncFunction("getDeviceState") {
val ctx = requireContext()
buildDeviceState(ctx)
}
AsyncFunction("syncBlocklist") { opts: Map<String, Any?>, promise: Promise ->
val baseURL = opts["baseURL"] as? String
val authToken = opts["authToken"] as? String
if (baseURL.isNullOrBlank() || authToken.isNullOrBlank()) {
promise.reject(CodedException("missing_params", "baseURL und authToken required", null))
return@AsyncFunction
}
val ctx = requireContext()
ioExecutor.execute {
try {
val result = downloadBlocklist(ctx, baseURL, authToken)
// VpnService-Reload — NUR wenn der Filter tatsächlich an ist.
// `startService` würde den Service sonst re-createn, obwohl der
// User den Schutz (ggf. nach Cooldown) deaktiviert hat.
if (isVpnEffectivelyOn(ctx)) {
val reload = Intent(ctx, RebreakVpnService::class.java).apply {
action = RebreakVpnService.ACTION_RELOAD
}
try { ctx.startService(reload) } catch (_: Exception) {}
}
promise.resolve(result)
sendLayerChange()
} catch (e: Exception) {
promise.reject(CodedException("sync_failed", e.message ?: "unknown", e))
}
}
}
AsyncFunction("runHealthProbe") { opts: Map<String, Any?>?, promise: Promise ->
val target = (opts?.get("target") as? String) ?: "https://bet365.com"
val timeoutMs = (((opts?.get("timeoutSeconds") as? Number) ?: 5).toInt()) * 1000
ioExecutor.execute {
val started = System.currentTimeMillis()
var outcome = "offline"
var reason = "init"
try {
val url = URL(target)
val conn = (url.openConnection() as HttpURLConnection).apply {
connectTimeout = timeoutMs
readTimeout = timeoutMs
requestMethod = "HEAD"
instanceFollowRedirects = false
}
val code = conn.responseCode
conn.disconnect()
outcome = "loaded"
reason = "http_$code"
} catch (_: UnknownHostException) {
outcome = "blocked"
reason = "dns_unresolved"
} catch (_: java.net.SocketTimeoutException) {
outcome = "timeout"
reason = "timeout"
} catch (e: Exception) {
outcome = "offline"
reason = e::class.java.simpleName
}
val elapsed = (System.currentTimeMillis() - started).toInt()
promise.resolve(
mapOf(
"outcome" to outcome,
"reason" to reason,
"durationMs" to elapsed,
"target" to target,
)
)
}
}
AsyncFunction("openSystemSettings") { target: String? ->
val ctx = requireContext()
val action = when (target) {
"vpn" -> Settings.ACTION_VPN_SETTINGS
"accessibility" -> Settings.ACTION_ACCESSIBILITY_SETTINGS
"notifications" -> Settings.ACTION_APP_NOTIFICATION_SETTINGS
else -> Settings.ACTION_SETTINGS
}
val intent = Intent(action).apply {
if (target == "notifications") {
putExtra(Settings.EXTRA_APP_PACKAGE, ctx.packageName)
}
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
ctx.startActivity(intent)
Unit
}
// ─── Layer 2: Accessibility + Tamper ──────────────────────────────────
AsyncFunction("isAccessibilityEnabled") {
mapOf("enabled" to isAccessibilityServiceEnabled(requireContext()))
}
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 {
flags = Intent.FLAG_ACTIVITY_NEW_TASK
},
)
for (intent in tries) {
try {
ctx.startActivity(intent)
promise.resolve(mapOf("opened" to true))
return@AsyncFunction
} catch (e: Exception) {
Log.w(TAG, "openAccessibilitySettings try failed: ${e.message}")
}
}
promise.reject(CodedException("open_failed", "no accessibility settings activity available", null))
}
AsyncFunction("armTamperLock") { promise: Promise ->
val ctx = requireContext()
val vpn = isVpnEffectivelyOn(ctx)
val a11y = isAccessibilityServiceEnabled(ctx)
if (!vpn || !a11y) {
promise.reject(
CodedException(
"preconditions_not_met",
"VPN and Accessibility must both be active before arming",
null,
)
)
return@AsyncFunction
}
val prefs = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
prefs.edit().putBoolean(KEY_TAMPER_ARMED, true).apply()
Log.i(TAG, "tamper-lock ARMED")
promise.resolve(mapOf("armed" to true))
sendLayerChange()
}
AsyncFunction("disarmTamperLock") {
val ctx = requireContext()
disarmTamperLock(ctx)
sendLayerChange()
mapOf("armed" to false)
}
AsyncFunction("getProtectionStatus") {
val ctx = requireContext()
val vpn = isVpnEffectivelyOn(ctx)
val a11y = isAccessibilityServiceEnabled(ctx)
val count = currentHashCount(ctx)
val armed = isTamperLockArmed(ctx)
Log.i(TAG, "getProtectionStatus: vpn=$vpn, a11y=$a11y, blocklist=$count, armed=$armed")
mapOf(
"vpnEnabled" to vpn,
"accessibilityEnabled" to a11y,
"blocklistCount" to count,
"tamperArmed" to armed,
)
}
}
// ─── Helpers ────────────────────────────────────────────────────────────
private fun requireContext(): Context =
appContext.reactContext
?: throw CodedException("no_context", "ReactContext nicht verfügbar", null)
private fun startVpnService() {
val ctx = requireContext()
val intent = Intent(ctx, RebreakVpnService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ctx.startForegroundService(intent)
} else {
ctx.startService(intent)
}
}
private fun buildDeviceState(ctx: Context): Map<String, Any?> {
val blocklist = File(ctx.filesDir, BLOCKLIST_FILENAME)
val count = if (blocklist.exists()) (blocklist.length() / 8).toInt() else 0
val lastSyncAt = prefs(ctx).getString(PREF_LAST_SYNC, null)
return mapOf(
"vpn" to isVpnEffectivelyOn(ctx),
"accessibility" to isAccessibilityServiceEnabled(ctx),
"tamperLock" to isTamperLockArmed(ctx),
"blocklistCount" to count,
"blocklistLastSyncAt" to lastSyncAt,
)
}
private fun activateSuccessResult(): Map<String, Any?> = mapOf(
"allLayersOn" to false,
"missingLayers" to listOf("accessibility", "tamperLock"),
"errors" to emptyList<String>(),
)
private fun sendLayerChange() {
val ctx = appContext.reactContext ?: return
sendEvent("onLayerChange", buildDeviceState(ctx))
}
private fun prefs(ctx: Context) =
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private fun saveEnabled(enabled: Boolean) {
prefs(requireContext()).edit().putBoolean(KEY_ENABLED, enabled).apply()
}
private fun isEnabledFlag(ctx: Context): Boolean =
prefs(ctx).getBoolean(KEY_ENABLED, false)
private fun isTamperLockArmed(ctx: Context): Boolean =
prefs(ctx).getBoolean(KEY_TAMPER_ARMED, false)
/** Cleart das Tamper-Armed-Flag. Geteilte Logik zwischen der
* `disarmTamperLock`-AsyncFunction und `disable()` robust für alle Caller. */
private fun disarmTamperLock(ctx: Context) {
prefs(ctx).edit().putBoolean(KEY_TAMPER_ARMED, false).apply()
Log.i(TAG, "tamper-lock DISARMED")
}
private fun currentHashCount(ctx: Context): Int {
val file = File(ctx.filesDir, BLOCKLIST_FILENAME)
return if (file.exists()) (file.length() / 8L).toInt() else 0
}
/**
* Live-Check: ist unser VPN tatsächlich aktiv?
* 1. VpnService.prepare(ctx) == null wir haben Permission
* 2. UND (companion-Live-Flag ODER Prefs-Flag)
*
* Wenn der User in System-Settings das VPN ausgeschaltet hat, returnt
* prepare() ein non-null Intent (= "musst Permission neu holen") UND
* unser onRevoke clear't bereits die Prefs-Flag. Doppelt abgesichert.
*/
private fun isVpnEffectivelyOn(ctx: Context): Boolean {
val live = RebreakVpnService.isRunning
val intent = try {
VpnService.prepare(ctx)
} catch (_: Exception) {
null
}
val flag = isEnabledFlag(ctx)
Log.d(TAG, "isVpnEffectivelyOn: live=$live, prepareIntent=${intent != null}, flag=$flag")
if (intent != null) {
// Permission entzogen → definitely off
if (flag) prefs(ctx).edit().putBoolean(KEY_ENABLED, false).apply()
return false
}
return live || flag
}
/**
* Liest Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES kommagetrennte
* Liste von ComponentName-Strings. Wir matchen exakt unseren Service.
*
* Vorteil ggü. AccessibilityManager.getEnabledAccessibilityServiceList:
* funktioniert auch wenn der Service gerade neu installiert wurde und
* der Cache noch nicht aktualisiert ist.
*/
private fun isAccessibilityServiceEnabled(ctx: Context): Boolean {
val pkg = ctx.packageName
val expectedClass = RebreakAccessibilityService::class.java.name
// Primary: AccessibilityManager API — funktioniert auf allen OEMs
// (Samsung One UI returnt manchmal null bei Settings.Secure).
try {
val am = ctx.getSystemService(Context.ACCESSIBILITY_SERVICE) as? AccessibilityManager
if (am != null) {
val list = am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK)
Log.d(TAG, "a11y check via AccessibilityManager: ${list?.size ?: 0} services")
for (info in list ?: emptyList()) {
val id = info.id ?: continue
if (id.contains(pkg) && id.contains("RebreakAccessibilityService")) {
Log.d(TAG, "a11y MATCH via AM: $id")
return true
}
}
}
} catch (e: Exception) {
Log.w(TAG, "AccessibilityManager check failed: ${e.message}")
}
// Fallback: Settings.Secure (Standard-Android)
val expected = ComponentName(ctx, RebreakAccessibilityService::class.java)
val expectedFlat = expected.flattenToString()
val enabled = try {
Settings.Secure.getString(
ctx.contentResolver,
Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES,
)
} catch (e: Exception) {
null
}
Log.d(TAG, "a11y check via Settings.Secure: expected=$expectedFlat, settings=$enabled")
if (!enabled.isNullOrBlank()) {
if (enabled.contains(expectedFlat)) return true
if (enabled.contains(expectedClass)) return true
}
return false
}
private fun downloadBlocklist(
ctx: Context, baseURL: String, authToken: String
): Map<String, Any?> {
val url = URL("$baseURL/api/url-filter/blocklist.bin")
val conn = (url.openConnection() as HttpURLConnection).apply {
setRequestProperty("Authorization", "Bearer $authToken")
connectTimeout = 10_000
readTimeout = 30_000
}
val p = prefs(ctx)
p.getString(PREF_ETAG, null)?.let { conn.setRequestProperty("If-None-Match", it) }
val now = isoTimestamp()
return try {
when (val code = conn.responseCode) {
304 -> {
val existing = File(ctx.filesDir, BLOCKLIST_FILENAME)
val count = (if (existing.exists()) existing.length() else 0L) / 8
mapOf("updated" to false, "count" to count.toInt())
}
200 -> {
val bytes = conn.inputStream.use { it.readBytes() }
writeBlocklistAtomic(ctx, bytes)
val edits = p.edit()
conn.getHeaderField("etag")?.let { edits.putString(PREF_ETAG, it) }
edits.putString(PREF_LAST_SYNC, now).apply()
val plan = conn.getHeaderField("x-rebreak-plan")
mapOf(
"updated" to true,
"count" to bytes.size / 8,
"plan" to plan,
)
}
else -> throw RuntimeException("HTTP $code")
}
} finally {
conn.disconnect()
}
}
private fun writeBlocklistAtomic(ctx: Context, bytes: ByteArray) {
val finalFile = File(ctx.filesDir, BLOCKLIST_FILENAME)
val tmpFile = File(ctx.filesDir, "$BLOCKLIST_FILENAME.tmp")
tmpFile.writeBytes(bytes)
if (!tmpFile.renameTo(finalFile)) {
finalFile.delete()
tmpFile.renameTo(finalFile)
}
}
private fun isoTimestamp(): String {
val df = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
df.timeZone = TimeZone.getTimeZone("UTC")
return df.format(Date())
}
companion object {
private const val TAG = "RebreakProtection"
private const val VPN_CONSENT_REQUEST_CODE = 42101
private const val BLOCKLIST_FILENAME = "blocklist.bin"
private const val PREFS = "rebreak_filter_prefs"
private const val KEY_ENABLED = "filter_enabled"
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"
}
}

View File

@ -0,0 +1,489 @@
package expo.modules.rebreakprotection.accessibility
import android.accessibilityservice.AccessibilityService
import android.accessibilityservice.AccessibilityServiceInfo
import android.content.Intent
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
import android.widget.Toast
import expo.modules.rebreakprotection.filter.HashList
import java.io.File
/**
* URL-Filter-Layer 2 parallel zum VpnService DNS-Filter.
*
* Hintergrund: Der VpnService kann vom User in System-Settings ausgeschaltet
* werden (Always-on VPN ist bei Free-Konsumenten-Devices nicht erzwingbar).
* Die Accessibility-Permission hingegen ist ihm "vertrauter" man gibt sie
* einmal und vergisst sie. Solange der User sie nicht explizit entzieht,
* läuft dieser Filter weiter.
*
* Funktionsweise (parallel zu iOS NEFilterBrowserFlow):
* - Service hört auf TYPE_WINDOW_CONTENT_CHANGED + TYPE_WINDOW_STATE_CHANGED
* - Filtert nach Browser-Packages (Chrome/Firefox/Edge/Samsung/Brave/Opera)
* - Liest die Adressleiste via AccessibilityNodeInfo
* - Hash-Match gegen blocklist.bin (gleiche Datei wie VpnService)
* - Bei Treffer: GLOBAL_ACTION_BACK + Toast
*
* Throttling: pro Browser nur alle 600ms eine URL-Prüfung sonst feuert
* Chrome bei jeder Frame-Änderung Hunderte Events.
*/
class RebreakAccessibilityService : AccessibilityService() {
private lateinit var hashList: HashList
private val mainHandler = Handler(Looper.getMainLooper())
private val lastCheck = HashMap<String, Long>()
private val lastBlockedUrl = HashMap<String, String>()
private var lastSettingsCheck: Long = 0L
/** Nach einem TAMPER-BLOCK: 3s lang keinen weiteren Block triggern.
* Verhindert Toast-Spam wenn User legitim in Settings navigieren will und
* Page-Transitions noch alte Keyword-Matches durchziehen. */
private var lastBlockAt: Long = 0L
private val POST_BLOCK_COOLDOWN_MS = 3000L
override fun onCreate() {
super.onCreate()
hashList = HashList(File(applicationContext.filesDir, "blocklist.bin"))
hashList.load()
Log.i(TAG, "service created — ${hashList.count()} hashes")
}
override fun onServiceConnected() {
super.onServiceConnected()
// Reload bei jedem Connect — User könnte zwischenzeitlich syncBlocklist
// gemacht haben.
hashList.load()
Log.i(TAG, "service connected — ${hashList.count()} hashes loaded")
}
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
if (event == null) return
// Globaler Kill-Switch: Wenn der User den Schutz NICHT will, tut dieser
// Service GAR NICHTS — kein Gambling-Block, kein Settings-Block. Der
// a11y-Service selbst kann sich nicht programmatisch deaktivieren, also
// ist das hier die einzige Stelle wo wir ihn vollständig stilllegen.
// "Schutz aktiv?" = Tamper-Lock armed (App-Lock opt-in) ODER der
// `filter_enabled`-Flag aus den SharedPrefs (den `disable()` auf false
// setzt). Wer den App-Lock nicht opt-in't, hat trotzdem den normalen
// Free-Blocker → dann greift `filter_enabled`.
if (!isTamperLockArmed() && !isProtectionEnabled()) return
val pkg = event.packageName?.toString() ?: return
// Settings-Watchdog: User versucht eine Schutz-relevante Settings-Page
// zu öffnen → Sofort BACK + Toast. Wir reagieren auf STATE_CHANGED
// (Activity-Wechsel) UND CONTENT_CHANGED (Dialog-Inhalt lädt nach) —
// weil bei manchen OEMs (Samsung) der Inhalt erst NACH der Activity
// gerendert wird und der erste Scan leer wäre.
if (pkg in WATCHED_SETTINGS_PACKAGES &&
(event.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED ||
event.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED)) {
if (handleProtectedSettingsBlock(pkg, event)) return
}
if (!BROWSER_PACKAGES.contains(pkg)) return
// Throttle pro Browser-Package
val now = System.currentTimeMillis()
val last = lastCheck[pkg] ?: 0L
if (now - last < THROTTLE_MS) return
lastCheck[pkg] = now
val root = rootInActiveWindow ?: return
val url = extractUrl(root, pkg) ?: return
val host = extractHost(url) ?: return
if (hashList.matchesAnySuffix(host)) {
// Vermeiden, beim selben URL ständig Back zu feuern
if (lastBlockedUrl[pkg] == url) return
lastBlockedUrl[pkg] = url
Log.i(TAG, "BLOCKED via accessibility: $host (in $pkg)")
performGlobalAction(GLOBAL_ACTION_BACK)
mainHandler.post {
Toast.makeText(
applicationContext,
"Rebreak hat diese Seite blockiert",
Toast.LENGTH_SHORT,
).show()
}
} else {
// URL nicht (mehr) blockiert → letzte-blockiert-Cache löschen
lastBlockedUrl.remove(pkg)
}
}
override fun onInterrupt() {
Log.w(TAG, "service interrupted")
}
/**
* Tamper-Protection: User versucht in System-Settings eine Schutz-relevante
* Seite zu öffnen (VPN deaktivieren, App löschen, A11y für Rebreak abschalten).
*
* Strategie: TYPE_WINDOW_STATE_CHANGED wenn package + className auf eine
* gefährliche Activity matchen GLOBAL_ACTION_BACK + Toast. User wird sofort
* rausgeworfen, kann nicht togglen.
*
* Bypass-Vektoren die wir damit NICHT abdecken:
* - Safe Mode (Reboot mit Power-Long-Press) Apps off A11y off freie Bahn
* - ADB / Root kein normaler User
*
* Aktiviert nur wenn User aktuell als "committed" gilt (rebreak_blocker == true
* in SharedPreferences). Sonst wäre der Lock auch beim Neuinstall-Onboarding
* aktiv und würde User aussperren.
*
* @return true wenn die Activity geblockt wurde
*/
private fun handleProtectedSettingsBlock(pkg: String, event: AccessibilityEvent): Boolean {
// Tamper-Lock ist nur aktiv wenn User EXPLIZIT verriegelt hat (über
// App-Button "Schutz fest verriegeln"). Sonst würde der Watchdog
// sofort die Setup-Seiten blockieren und User aussperren.
if (!isTamperLockArmed()) return false
if (!isUserCommittedToProtection()) return false
if (pkg !in WATCHED_SETTINGS_PACKAGES) return false
// Throttle: max alle 400ms eine Settings-Inspection (CONTENT_CHANGED
// feuert sonst hunderte Mal pro Sekunde während User scrollt)
val now = System.currentTimeMillis()
if (now - lastSettingsCheck < 400) return false
lastSettingsCheck = now
// Post-Block Cooldown: nach einem Block 3s lang keinen weiteren Block
// damit User legitim in Settings navigieren kann ohne dass alte
// Keyword-Matches in Page-Transitions repeatedly auslösen.
if (now - lastBlockAt < POST_BLOCK_COOLDOWN_MS) {
Log.d(TAG, "settings-watch: cooldown active, skipping block")
return false
}
val className = event.className?.toString() ?: return false
// DEBUG: alle Settings-Activities mitloggen damit wir OEM-Variationen sehen
Log.i(TAG, "settings-watch: $pkg / $className")
// Phase 1 — Class-Name-Match (ältere Stock-Android-Patterns)
val classMatchDangerous = DANGEROUS_ACTIVITY_PATTERNS.any { pattern ->
className.contains(pattern, ignoreCase = true)
}
// Phase 2 — Window-Content-Match: IMMER scannen (außer wir haben schon
// einen className-Match). OEMs benutzen für Dialoge oft className die
// weder in unseren Patterns noch als "generic container" erkannt werden
// (z.B. Samsung's "AppDialog", Stock-Android's "ManageDialog$2"). Der
// Keyword-Cluster-Scan ist unsere Safety-Net: 2 Keywords aus dem
// gleichen Cluster = Block. Default false-positive Risk durch Throttling
// (alle 400ms eine Inspection).
var contentReason: String? = null
if (!classMatchDangerous) {
contentReason = scanWindowForDangerousContent()
}
val isDangerous = classMatchDangerous || contentReason != null
if (!isDangerous) return false
Log.w(TAG, "TAMPER-BLOCK: $pkg / $className (reason=${contentReason ?: "class-match"})")
lastBlockAt = now // post-block cooldown startet jetzt
// Doppel-BACK: einmal um Activity zu schließen, einmal als Backup falls
// erste BACK nur einen Dialog dismissed.
performGlobalAction(GLOBAL_ACTION_BACK)
mainHandler.postDelayed({
performGlobalAction(GLOBAL_ACTION_BACK)
}, 200)
mainHandler.post {
Toast.makeText(
applicationContext,
"🛡 Diese Einstellung ist während des Schutzes gesperrt",
Toast.LENGTH_LONG,
).show()
}
return true
}
/**
* Scannt die aktuelle Window-Hierarchie nach Texten die auf eine
* VPN/A11y/App-Uninstall-Page hindeuten. Wird genutzt wenn die Activity
* generisch ist (z.B. Samsung's SubSettings) dann müssen wir den
* Inhalt selbst inspizieren.
*/
private fun scanWindowForDangerousContent(): String? {
val root = rootInActiveWindow ?: return null
val texts = mutableListOf<String>()
collectAllText(root, texts, depth = 0)
val joined = texts.joinToString(" | ").lowercase()
// DEBUG: was steht eigentlich auf der Page? Hilft beim Patterns-Tuning.
Log.d(TAG, "settings-content-text: ${joined.take(500)}")
// High-confidence Keywords: 1 Treffer reicht (sehr spezifisch zu uns)
for (keyword in HIGH_CONFIDENCE_KEYWORDS) {
if (joined.contains(keyword)) {
Log.d(TAG, "settings-watch: high-confidence keyword match: '$keyword'")
return "high-confidence:$keyword"
}
}
// Standard-Cluster: min 2 Keywords nötig (false-positive-Schutz)
for ((cluster, keywords) in DANGEROUS_TEXT_CLUSTERS) {
val matchCount = keywords.count { joined.contains(it) }
if (matchCount >= 2) {
Log.d(TAG, "settings-watch: cluster $cluster matched $matchCount keywords")
return cluster
}
}
return null
}
private fun collectAllText(node: AccessibilityNodeInfo?, sink: MutableList<String>, depth: Int) {
if (node == null || depth > 10) return
node.text?.toString()?.takeIf { it.isNotBlank() }?.let { sink.add(it) }
node.contentDescription?.toString()?.takeIf { it.isNotBlank() }?.let { sink.add(it) }
for (i in 0 until node.childCount) {
collectAllText(node.getChild(i), sink, depth + 1)
}
}
/** Liest den `filter_enabled`-Flag aus SharedPreferences (gleicher Storage +
* Key wie `RebreakProtectionModule.saveEnabled` / `KEY_ENABLED`). `disable()`
* setzt ihn auf false danach ist der Schutz aus und dieser Service passiv. */
private fun isProtectionEnabled(): Boolean {
return try {
val prefs = applicationContext.getSharedPreferences(
"rebreak_filter_prefs",
android.content.Context.MODE_PRIVATE,
)
prefs.getBoolean("filter_enabled", false)
} catch (_: Exception) {
false
}
}
/** Liest den Commitment-Flag aus SharedPreferences (gleicher Storage wie der
* Plugin nutzt). User ist "committed" wenn er Schutz mal aktiv aktiviert hat
* und ihn nicht legitim (= durch Cooldown-Ende) deaktiviert hat. */
private fun isUserCommittedToProtection(): Boolean = isProtectionEnabled()
/** "Verriegelt"-Flag User hat über App-Button "Schutz fest verriegeln"
* bestätigt dass Settings-Tampering blockiert werden soll. Default: false
* damit Onboarding nicht aussperrt. */
private fun isTamperLockArmed(): Boolean {
return try {
val prefs = applicationContext.getSharedPreferences(
"rebreak_filter_prefs",
android.content.Context.MODE_PRIVATE,
)
prefs.getBoolean("tamper_armed", false)
} catch (_: Exception) {
false
}
}
/**
* Liest die Adressleiste aus dem Browser-View-Tree.
*
* Browser benutzen unterschiedliche View-IDs für ihre URL-Bar wir
* probieren die gängigen durch. Fallback: alle EditText-Nodes nach
* URL-artigem Inhalt scannen.
*/
private fun extractUrl(root: AccessibilityNodeInfo, pkg: String): String? {
val candidateIds = URL_BAR_IDS[pkg] ?: emptyList()
for (id in candidateIds) {
val nodes = try {
root.findAccessibilityNodeInfosByViewId(id)
} catch (e: Exception) {
null
} ?: continue
for (node in nodes) {
val text = node.text?.toString() ?: continue
if (looksLikeUrl(text)) return text
}
}
// Fallback — scanne den ganzen Tree breadth-first nach URL-artigem Text
return scanTreeForUrl(root, depth = 0)
}
private fun scanTreeForUrl(node: AccessibilityNodeInfo, depth: Int): String? {
if (depth > MAX_TREE_DEPTH) return null
val text = node.text?.toString()
if (text != null && looksLikeUrl(text)) return text
for (i in 0 until node.childCount) {
val child = node.getChild(i) ?: continue
val found = scanTreeForUrl(child, depth + 1)
if (found != null) return found
}
return null
}
private fun looksLikeUrl(text: String): Boolean {
val t = text.trim()
if (t.length < 3 || t.length > 2048) return false
// Schnell-Filter: enthält Punkt, kein Whitespace
if (' ' in t || '\n' in t) return false
// Normalisiere — Browser zeigen oft "https://" weggekürzt
val normalized = if (t.startsWith("http://") || t.startsWith("https://")) t else "https://$t"
return try {
val u = java.net.URI(normalized)
val host = u.host
host != null && host.contains('.')
} catch (_: Exception) {
false
}
}
private fun extractHost(url: String): String? {
val normalized = if (url.startsWith("http://") || url.startsWith("https://")) url else "https://$url"
return try {
java.net.URI(normalized).host?.lowercase()
} catch (_: Exception) {
null
}
}
companion object {
private const val TAG = "RebreakA11y"
private const val THROTTLE_MS = 600L
private const val MAX_TREE_DEPTH = 12
// Erweitern wir, wenn User-Reports zeigen dass ihr Browser nicht erkannt wird.
val BROWSER_PACKAGES = setOf(
"com.android.chrome", // Chrome
"com.chrome.beta",
"com.chrome.dev",
"com.chrome.canary",
"org.mozilla.firefox", // Firefox
"org.mozilla.firefox_beta",
"org.mozilla.fenix", // Firefox Fenix
"com.microsoft.emmx", // Edge
"com.sec.android.app.sbrowser", // Samsung Internet
"com.brave.browser", // Brave
"com.opera.browser", // Opera
"com.opera.mini.native",
"com.duckduckgo.mobile.android", // DuckDuckGo
"com.vivaldi.browser", // Vivaldi
"org.torproject.torbrowser", // Tor
)
// View-IDs der Adressleiste pro Browser. Bekannte Stand 2026.
// findAccessibilityNodeInfosByViewId erwartet das volle ID-Tag,
// d.h. "<package>:id/<resourceName>".
private val URL_BAR_IDS = mapOf(
"com.android.chrome" to listOf("com.android.chrome:id/url_bar"),
"com.chrome.beta" to listOf("com.chrome.beta:id/url_bar"),
"com.chrome.dev" to listOf("com.chrome.dev:id/url_bar"),
"com.chrome.canary" to listOf("com.chrome.canary:id/url_bar"),
"org.mozilla.firefox" to listOf("org.mozilla.firefox:id/mozac_browser_toolbar_url_view"),
"org.mozilla.firefox_beta" to listOf("org.mozilla.firefox_beta:id/mozac_browser_toolbar_url_view"),
"org.mozilla.fenix" to listOf("org.mozilla.fenix:id/mozac_browser_toolbar_url_view"),
"com.microsoft.emmx" to listOf("com.microsoft.emmx:id/url_bar"),
"com.sec.android.app.sbrowser" to listOf("com.sec.android.app.sbrowser:id/location_bar_edit_text"),
"com.brave.browser" to listOf("com.brave.browser:id/url_bar"),
"com.opera.browser" to listOf("com.opera.browser:id/url_field"),
"com.opera.mini.native" to listOf("com.opera.mini.native:id/url_field"),
"com.duckduckgo.mobile.android" to listOf("com.duckduckgo.mobile.android:id/omnibarTextInput"),
"com.vivaldi.browser" to listOf("com.vivaldi.browser:id/url_bar"),
)
const val ACTION_RELOAD_BLOCKLIST = "expo.modules.rebreakprotection.action.A11Y_RELOAD"
// Settings-Apps die wir auf Tamper-Versuche überwachen.
// Stock + Samsung One UI haben unterschiedliche Package-Namen,
// wir whitelist'n alle bekannten.
val WATCHED_SETTINGS_PACKAGES = setOf(
"com.android.settings",
"com.android.vpndialogs",
"com.android.packageinstaller",
"com.google.android.packageinstaller",
"com.samsung.android.app.settings",
"com.samsung.accessibility",
// Play Store: User könnte hier auf "Deinstallieren" tippen für Rebreak
"com.android.vending",
)
// Activity-Class-Patterns die geblockt werden (Substring-Match, case-insensitive).
// Decken Stock-Android + Samsung One UI ab. Patterns sind bewusst breit
// damit OEM-Variationen mitgenommen werden.
/**
* High-confidence Keywords wenn EINER davon im Window-Content
* auftaucht, blocken wir sofort. Sind alle hochspezifisch und
* tauchen praktisch nur auf VPN/A11y/Uninstall-Detail-Pages auf.
*/
val HIGH_CONFIDENCE_KEYWORDS = listOf(
"rebreak filter", // VPN-Profil-Name aus Builder.setSession
"filtert glücksspielseiten", // A11y-Service-Summary
"rebreak deinstallieren",
"rebreak entfernen",
"rebreak löschen",
)
/**
* Standard-Cluster min 2 Keywords pro Cluster nötig damit
* harmlose Settings-Suche keine false-positives auslöst.
*/
val DANGEROUS_TEXT_CLUSTERS = mapOf(
"vpn-page" to listOf(
"vpn",
"rebreak filter", // unser Profil-Name (siehe Builder.setSession)
"always-on",
"always-on-vpn",
"verbindung trennen",
"trennen",
"verbindungen ohne vpn",
"block connections",
"vpn-profil",
"konto entfernen",
"vergessen",
"always-on vpn",
),
"a11y-page" to listOf(
"bedienungshilfe",
"eingabehilfe",
"accessibility",
"filtert glücksspiel", // unser A11y-Service-Summary
"rebreak filter",
"installierte apps",
"installed services",
"downloaded apps",
"berechtigung erteilen",
"service deaktivieren",
"service ausschalten",
),
"uninstall-page" to listOf(
"deinstallieren",
"uninstall",
"rebreak",
"möchten sie diese app",
"do you want to uninstall",
"app entfernen",
"force stop",
"stopp erzwingen",
"speicher",
"daten löschen",
"clear data",
"cache leeren",
),
)
val DANGEROUS_ACTIVITY_PATTERNS = listOf(
// VPN-Settings + VPN-Profil-Dialoge
"VpnSettings",
"VpnConfig",
"VpnDialog",
"ManageDialog", // com.android.vpndialogs.ManageDialog
"ConfirmAddOns", // com.android.vpndialogs.ConfirmAddOnsActivity
// App-Deinstallieren-Dialoge + App-Info-Pages
"Uninstaller", // com.android.packageinstaller.UninstallerActivity
"InstalledAppDetails", // App-Info-Page (kann zu uninstall führen)
"ApplicationDetails", // Variations
// Accessibility-Settings (paradox: A11y würde sich selbst aushebeln)
"AccessibilitySettings",
"AccessibilityDetails",
"InstalledServiceActivity", // Samsung
"AccessibilityShortcut",
)
}
}

View File

@ -0,0 +1,225 @@
package expo.modules.rebreakprotection.filter
import android.util.Log
import java.io.FileOutputStream
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetAddress
import java.util.concurrent.Executors
import java.util.concurrent.ThreadPoolExecutor
/**
* IPv4 + UDP + DNS Packet-Parsing und Response-Builder.
*
* Verarbeitungsmodell:
* - Block-Queries sofort NXDOMAIN-Response (synchron, returns ByteArray)
* - Allow-Queries async Thread-Pool: forwarded an Upstream, schreibt Response
* direkt ins TUN (synchronized auf outputLock).
*
* Damit blockt EINE langsame Forward-Query nicht den ganzen Filter-Loop.
*/
object DnsFilter {
private const val TAG = "RebreakDnsFilter"
private const val UPSTREAM_DNS = "1.1.1.1"
private const val UPSTREAM_PORT = 53
private const val DNS_PORT = 53
private val forwardPool: ThreadPoolExecutor =
Executors.newCachedThreadPool() as ThreadPoolExecutor
/**
* Verarbeitet ein Paket vom TUN.
* - Bei Block: schreibt synchron NXDOMAIN-Response in `output`.
* - Bei Allow: fired Thread für Forward + Async-Write.
* - Bei Drop (kein DNS, IPv6, etc.): nichts.
*
* Caller (VpnService) muss vor jedem `output.write(...)` den `outputLock`
* synchronisieren wir tun das hier intern.
*/
fun process(
packet: ByteArray,
length: Int,
hashList: HashList,
output: FileOutputStream,
outputLock: Any,
protectSocket: (DatagramSocket) -> Boolean,
) {
if (length < 28) return // min IPv4(20) + UDP(8)
// IPv4-Check
val version = (packet[0].toInt() ushr 4) and 0xF
if (version != 4) return
val ihl = (packet[0].toInt() and 0xF) * 4
if (length < ihl + 8) return
// UDP only
val protocol = packet[9].toInt() and 0xFF
if (protocol != 17) return
// UDP-Header
val udpStart = ihl
val srcPort = ((packet[udpStart].toInt() and 0xFF) shl 8) or (packet[udpStart + 1].toInt() and 0xFF)
val dstPort = ((packet[udpStart + 2].toInt() and 0xFF) shl 8) or (packet[udpStart + 3].toInt() and 0xFF)
if (dstPort != DNS_PORT) return
val dnsStart = udpStart + 8
val dnsLen = length - dnsStart
if (dnsLen < 12) return
val domain = parseQname(packet, dnsStart + 12) ?: return
val srcIp = packet.copyOfRange(12, 16)
val dstIp = packet.copyOfRange(16, 20)
if (hashList.matchesAnySuffix(domain)) {
Log.i(TAG, "BLOCKED: $domain")
val resp = buildNxDomainResponse(packet, length, ihl, srcIp, dstIp, srcPort, dstPort)
writeSynchronized(output, outputLock, resp)
return
}
// Async forward — kopiere Buffer-Slice damit nicht überschrieben wird beim
// nächsten read() im VpnService-Loop
val packetCopy = packet.copyOf(length)
forwardPool.execute {
forwardAndWrite(
packetCopy, length, ihl, srcIp, dstIp, srcPort, dstPort,
output, outputLock, protectSocket, domain,
)
}
}
private fun parseQname(packet: ByteArray, start: Int): String? {
val sb = StringBuilder()
var pos = start
while (pos < packet.size) {
val len = packet[pos].toInt() and 0xFF
if (len == 0) break
if ((len and 0xC0) != 0) return null
pos++
if (pos + len > packet.size) return null
if (sb.isNotEmpty()) sb.append('.')
sb.append(String(packet, pos, len, Charsets.US_ASCII))
pos += len
}
return if (sb.isEmpty()) null else sb.toString()
}
private fun buildNxDomainResponse(
request: ByteArray,
length: Int,
ihl: Int,
srcIp: ByteArray,
dstIp: ByteArray,
srcPort: Int,
dstPort: Int,
): ByteArray {
val resp = request.copyOf(length)
System.arraycopy(dstIp, 0, resp, 12, 4)
System.arraycopy(srcIp, 0, resp, 16, 4)
resp[10] = 0; resp[11] = 0
val udpStart = ihl
resp[udpStart] = (dstPort ushr 8).toByte()
resp[udpStart + 1] = (dstPort and 0xFF).toByte()
resp[udpStart + 2] = (srcPort ushr 8).toByte()
resp[udpStart + 3] = (srcPort and 0xFF).toByte()
resp[udpStart + 6] = 0; resp[udpStart + 7] = 0
val dnsStart = udpStart + 8
resp[dnsStart + 2] = 0x81.toByte()
resp[dnsStart + 3] = 0x83.toByte() // RCODE=3 (NXDOMAIN)
for (i in 6..11) resp[dnsStart + i] = 0
return recomputeIpChecksum(resp, ihl)
}
private fun forwardAndWrite(
request: ByteArray,
length: Int,
ihl: Int,
srcIp: ByteArray,
dstIp: ByteArray,
srcPort: Int,
dstPort: Int,
output: FileOutputStream,
outputLock: Any,
protectSocket: (DatagramSocket) -> Boolean,
domainForLog: String,
) {
var sock: DatagramSocket? = null
try {
sock = DatagramSocket()
if (!protectSocket(sock)) {
Log.w(TAG, "protect() failed for $domainForLog — DNS forward would loop, dropping")
return
}
sock.soTimeout = 4000
val dnsStart = ihl + 8
val dnsLen = length - dnsStart
val query = request.copyOfRange(dnsStart, dnsStart + dnsLen)
val upstreamAddr = InetAddress.getByName(UPSTREAM_DNS)
sock.send(DatagramPacket(query, query.size, upstreamAddr, UPSTREAM_PORT))
val buf = ByteArray(4096)
val pkt = DatagramPacket(buf, buf.size)
sock.receive(pkt)
val totalLen = ihl + 8 + pkt.length
val resp = ByteArray(totalLen)
System.arraycopy(request, 0, resp, 0, ihl)
resp[2] = (totalLen ushr 8).toByte()
resp[3] = (totalLen and 0xFF).toByte()
System.arraycopy(dstIp, 0, resp, 12, 4)
System.arraycopy(srcIp, 0, resp, 16, 4)
resp[10] = 0; resp[11] = 0
val udpStart = ihl
resp[udpStart] = (dstPort ushr 8).toByte()
resp[udpStart + 1] = (dstPort and 0xFF).toByte()
resp[udpStart + 2] = (srcPort ushr 8).toByte()
resp[udpStart + 3] = (srcPort and 0xFF).toByte()
val udpLen = 8 + pkt.length
resp[udpStart + 4] = (udpLen ushr 8).toByte()
resp[udpStart + 5] = (udpLen and 0xFF).toByte()
resp[udpStart + 6] = 0; resp[udpStart + 7] = 0
System.arraycopy(pkt.data, 0, resp, ihl + 8, pkt.length)
recomputeIpChecksum(resp, ihl)
writeSynchronized(output, outputLock, resp)
} catch (e: Exception) {
Log.w(TAG, "forward failed for $domainForLog: ${e.javaClass.simpleName}: ${e.message}")
} finally {
try { sock?.close() } catch (_: Exception) {}
}
}
private fun writeSynchronized(output: FileOutputStream, outputLock: Any, data: ByteArray) {
synchronized(outputLock) {
try {
output.write(data)
} catch (e: Exception) {
Log.w(TAG, "TUN write failed: ${e.message}")
}
}
}
private fun recomputeIpChecksum(packet: ByteArray, ihl: Int): ByteArray {
packet[10] = 0; packet[11] = 0
var sum = 0L
for (i in 0 until ihl step 2) {
val word = ((packet[i].toInt() and 0xFF) shl 8) or (packet[i + 1].toInt() and 0xFF)
sum += word
}
while (sum ushr 16 != 0L) sum = (sum and 0xFFFF) + (sum ushr 16)
val checksum = (sum.inv() and 0xFFFF).toInt()
packet[10] = (checksum ushr 8).toByte()
packet[11] = (checksum and 0xFF).toByte()
return packet
}
}

View File

@ -0,0 +1,43 @@
package expo.modules.rebreakprotection.filter
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.security.MessageDigest
/**
* Domain-Hashing IDENTISCH zu `server/utils/domainHash.ts` und iOS' `DomainHasher.swift`.
*
* Produziert die ersten 8 Bytes des SHA-256 als big-endian UInt64.
* Server liefert die Hash-Liste in dieser Form, iOS und Android lesen sie 1:1.
*
* Privacy: arbeitet rein lokal keine Klartext-Domain verlässt das Gerät.
*/
object DomainHasher {
/**
* Normalisiert einen Hostname analog zu Server + iOS.
* - lowercase
* - http(s):// strippen
* - Pfad/Query/Anchor (alles ab erstem `/`) abschneiden
* - leading "www." entfernen
*/
fun normalize(host: String): String {
var h = host.trim().lowercase()
if (h.startsWith("https://")) h = h.substring(8)
else if (h.startsWith("http://")) h = h.substring(7)
val slash = h.indexOf('/')
if (slash >= 0) h = h.substring(0, slash)
if (h.startsWith("www.")) h = h.substring(4)
return h
}
/**
* SHA-256(normalize(host)).first(8) als big-endian UInt64.
* Returnt Long (Kotlin hat kein UInt64) bit-pattern identisch zu UInt64-BE.
*/
fun hash(host: String): Long {
val normalized = normalize(host)
val digest = MessageDigest.getInstance("SHA-256").digest(normalized.toByteArray(Charsets.UTF_8))
return ByteBuffer.wrap(digest, 0, 8).order(ByteOrder.BIG_ENDIAN).long
}
}

View File

@ -0,0 +1,104 @@
package expo.modules.rebreakprotection.filter
import java.io.File
import java.io.RandomAccessFile
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.channels.FileChannel
import java.util.concurrent.atomic.AtomicReference
/**
* Memory-mapped binary Hash-Liste sortierte 64-bit big-endian UInt64s.
*
* Datei-Format identisch zu iOS und Server: jeder Hash 8 Bytes, sortiert.
* Reader macht Binary-Search O(log n) lookup.
*
* Reload-Strategie: bei jedem load() wird neu gemappt. Aufrufer muss load()
* triggern (z.B. nach syncBlocklist).
*
* Thread-safe: AtomicReference auf das aktuelle Snapshot Lesen ist lock-free.
*/
class HashList(private val file: File) {
private data class Snapshot(val buffer: ByteBuffer?, val count: Int)
private val state = AtomicReference(Snapshot(null, 0))
/**
* Lädt blocklist.bin neu (mmap). Idempotent kann zur Laufzeit mehrfach
* aufgerufen werden, alte mmap wird vom GC eingesammelt.
*/
@Synchronized
fun load() {
if (!file.exists()) {
state.set(Snapshot(null, 0))
return
}
try {
RandomAccessFile(file, "r").use { raf ->
val ch = raf.channel
val size = ch.size()
if (size <= 0 || size % 8L != 0L) {
state.set(Snapshot(null, 0))
return
}
val buf = ch.map(FileChannel.MapMode.READ_ONLY, 0, size)
.order(ByteOrder.BIG_ENDIAN)
state.set(Snapshot(buf, (size / 8L).toInt()))
}
} catch (e: Exception) {
state.set(Snapshot(null, 0))
}
}
/** Aktuelle Anzahl der Hashes (zum Logging). */
fun count(): Int = state.get().count
/** True wenn die Datei aktuell als blocklist gemmapt ist. */
fun isLoaded(): Boolean = state.get().buffer != null
/**
* Binary-Search auf sortierten 64-bit Hashes. O(log n).
* Long-Vergleich ist hier UNSIGNED-semantisch die Datei ist als unsigned
* sortiert. Wir machen unsigned-Comparison via Long.compareUnsigned.
*/
fun contains(hash: Long): Boolean {
val snap = state.get()
val buf = snap.buffer ?: return false
val count = snap.count
if (count == 0) return false
var lo = 0
var hi = count - 1
while (lo <= hi) {
val mid = (lo + hi) ushr 1
val v = buf.getLong(mid * 8)
val cmp = java.lang.Long.compareUnsigned(v, hash)
when {
cmp == 0 -> return true
cmp < 0 -> lo = mid + 1
else -> hi = mid - 1
}
}
return false
}
/**
* Subdomain-Match (max 5 Iterationen, parallel zu iOS).
* Für `evil.shop.bet365.com` testen wir der Reihe nach:
* evil.shop.bet365.com shop.bet365.com bet365.com com (TLD wird nie matchen).
*/
fun matchesAnySuffix(host: String): Boolean {
var current = DomainHasher.normalize(host)
var iter = 0
while (iter < 5) {
if (contains(DomainHasher.hash(current))) return true
val dot = current.indexOf('.')
if (dot < 0) return false
current = current.substring(dot + 1)
if (!current.contains('.')) return false
iter++
}
return false
}
}

View File

@ -0,0 +1,233 @@
package expo.modules.rebreakprotection.vpn
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.net.VpnService
import android.os.Build
import android.os.ParcelFileDescriptor
import android.util.Log
import androidx.core.app.NotificationCompat
import expo.modules.rebreakprotection.filter.DnsFilter
import expo.modules.rebreakprotection.filter.HashList
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.net.DatagramSocket
/**
* VpnService-Subclass Pendant zum iOS NEFilterDataProvider.
*
* Architektur:
* - TUN-Interface mit kleiner virtueller Subnet (10.0.0.0/24)
* - System-DNS auf 10.0.0.1 DNS-Queries kommen ins TUN
* - Worker-Thread liest Pakete, DnsFilter prüft + antwortet
* - Foreground-Service (mit Notification) damit Android den Service nicht
* aggressiv killt
*
* Service-Lifecycle:
* - START_FILTER onStartCommand startVpn return START_STICKY
* - STOP_FILTER onStartCommand stopVpn stopSelf
* - User schaltet VPN in System-Settings ab onRevoke cleanup
*/
class RebreakVpnService : VpnService() {
private var tun: ParcelFileDescriptor? = null
private var workerThread: Thread? = null
@Volatile private var running = false
private lateinit var hashList: HashList
override fun onCreate() {
super.onCreate()
hashList = HashList(File(applicationContext.filesDir, BLOCKLIST_FILENAME))
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP -> {
stopVpn()
stopSelf()
return START_NOT_STICKY
}
ACTION_RELOAD -> {
hashList.load()
Log.i(TAG, "blocklist reloaded — ${hashList.count()} hashes")
return START_STICKY
}
else -> {
startForeground(NOTIF_ID, buildNotification())
hashList.load()
Log.i(TAG, "blocklist loaded — ${hashList.count()} hashes")
startVpn()
return START_STICKY
}
}
}
private fun startVpn() {
if (running) return
try {
val builder = Builder()
.addAddress(LOCAL_TUN_ADDR, 24)
.addRoute(VIRTUAL_DNS_ADDR, 32) // nur DNS-Queries an unsere virtuelle DNS-IP routen
.addDnsServer(VIRTUAL_DNS_ADDR)
.setSession("Rebreak Filter")
.setBlocking(true)
// Eigene App vom TUN ausnehmen — sonst routen unsere eigenen
// Backend-Calls durch unser TUN und können das Internet nicht erreichen
try {
builder.addDisallowedApplication(packageName)
} catch (e: Exception) {
Log.w(TAG, "addDisallowedApplication failed: ${e.message}")
}
tun = builder.establish() ?: run {
Log.e(TAG, "TUN establish() returned null — wahrscheinlich VpnService-Permission entzogen")
stopSelf()
return
}
running = true
isRunning = true
// Prefs-Flag synchronisieren — auch wenn der VpnService extern gestartet
// wurde (z.B. wenn User in System-Settings VPN toggle on). Sonst denkt
// unser Plugin "VPN ist aus" obwohl Tunnel aktiv ist.
setEnabledFlag(true)
workerThread = Thread { runFilterLoop() }.also { it.start() }
Log.i(TAG, "VPN established — TUN-Interface aktiv")
} catch (e: Exception) {
Log.e(TAG, "VPN start failed: ${e.message}", e)
stopSelf()
}
}
private fun stopVpn() {
running = false
isRunning = false
try { tun?.close() } catch (_: Exception) {}
tun = null
try { workerThread?.interrupt() } catch (_: Exception) {}
workerThread = null
Log.i(TAG, "VPN stopped")
}
private fun runFilterLoop() {
val tunFd = tun?.fileDescriptor ?: return
val input = FileInputStream(tunFd)
val output = FileOutputStream(tunFd)
val outputLock = Any()
val buf = ByteArray(32_767)
while (running && !Thread.currentThread().isInterrupted) {
try {
val len = input.read(buf)
if (len <= 0) continue
// process() schreibt selbst ins TUN — entweder synchron (Block→
// NXDOMAIN) oder async via Thread-Pool (Forward→Upstream).
DnsFilter.process(
packet = buf,
length = len,
hashList = hashList,
output = output,
outputLock = outputLock,
protectSocket = { sock: DatagramSocket -> protect(sock) },
)
} catch (e: Exception) {
if (running) {
Log.w(TAG, "filter-loop error: ${e.message}")
}
try { Thread.sleep(50) } catch (_: InterruptedException) { break }
}
}
}
override fun onRevoke() {
Log.i(TAG, "onRevoke: User hat VPN in System-Settings deaktiviert")
clearEnabledFlag()
stopVpn()
stopSelf()
super.onRevoke()
}
override fun onDestroy() {
// KEIN clearEnabledFlag hier — onDestroy feuert auch wenn System uns
// wegen Low-Memory killt. Wir wollen dann beim nächsten Start wieder
// auto-restart (START_STICKY), nicht "user-hat-disabled" signalisieren.
// Nur onRevoke (= explizit User-Toggle in System-Settings) clear'd.
stopVpn()
super.onDestroy()
}
/** Persistierte Flag (gleicher Storage wie der Plugin) clearen nur
* bei explizitem User-Revoke (System-Settings-Toggle aus). */
private fun clearEnabledFlag() = setEnabledFlag(false)
/** Setzt die Plugin-Prefs-Flag synchron mit dem tatsächlichen Service-State. */
private fun setEnabledFlag(value: Boolean) {
try {
val prefs = applicationContext.getSharedPreferences(
"rebreak_filter_prefs",
Context.MODE_PRIVATE,
)
prefs.edit().putBoolean("filter_enabled", value).apply()
} catch (e: Exception) {
Log.w(TAG, "setEnabledFlag($value) failed: ${e.message}")
}
}
private fun buildNotification(): Notification {
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"Rebreak-Schutz",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "Aktiver Schutz vor Glücksspielseiten"
setShowBadge(false)
}
nm.createNotificationChannel(channel)
}
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
val pendingIntent = launchIntent?.let {
PendingIntent.getActivity(
this, 0, it,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Rebreak schützt dich")
.setContentText("Glücksspielseiten werden blockiert")
// Module-Context: kein App-eigenes Drawable verfügbar.
// System-Drawable als Fallback bis wir ein eigenes Icon im Module bündeln.
.setSmallIcon(android.R.drawable.ic_lock_lock)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setContentIntent(pendingIntent)
.build()
}
companion object {
private const val TAG = "RebreakVpn"
private const val CHANNEL_ID = "rebreak_filter_channel"
private const val NOTIF_ID = 4711
const val LOCAL_TUN_ADDR = "10.0.0.2"
const val VIRTUAL_DNS_ADDR = "10.0.0.1"
const val BLOCKLIST_FILENAME = "blocklist.bin"
const val ACTION_STOP = "expo.modules.rebreakprotection.action.STOP"
const val ACTION_START = "expo.modules.rebreakprotection.action.START"
const val ACTION_RELOAD = "expo.modules.rebreakprotection.action.RELOAD"
/** Process-lokale Live-Flag ist der TUN gerade etabliert?
* Plugin nutzt das als zweiten Indikator (zusätzlich zur Prefs-Flag). */
@Volatile var isRunning: Boolean = false
}
}

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="accessibility_service_description">Rebreak filters URLs in your browser to block gambling sites — even when the VPN is off. Without this permission the app cannot fully maintain protection.</string>
<string name="accessibility_service_summary">Filters gambling sites in the browser</string>
</resources>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="accessibility_service_description">Rebreak filtert URLs in deinem Browser, um Glücksspielseiten zu blockieren — auch wenn das VPN nicht aktiv ist. Ohne diese Berechtigung kann die App ihren Schutz nicht vollständig aufrechterhalten.</string>
<string name="accessibility_service_summary">Filtert Glücksspielseiten im Browser</string>
</resources>

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Accessibility-Service-Config für RebreakAccessibilityService.
packageNames listet die Browser-Apps, denen wir lauschen wollen — Android
ruft uns dann nur bei Events aus diesen Packages auf (System-seitiger
Filter, sehr CPU-effizient).
flags:
flagRetrieveInteractiveWindows → wir können auch Fenster lesen die nicht
Vollbild sind (Pop-up Tabs)
flagRequestEnhancedWebAccessibility → bessere DOM-Inspektion in Chrome
-->
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeWindowContentChanged|typeWindowStateChanged"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagDefault|flagRetrieveInteractiveWindows|flagRequestEnhancedWebAccessibility"
android:canRetrieveWindowContent="true"
android:notificationTimeout="100"
android:packageNames="com.android.chrome,com.chrome.beta,com.chrome.dev,com.chrome.canary,org.mozilla.firefox,org.mozilla.firefox_beta,org.mozilla.fenix,com.microsoft.emmx,com.sec.android.app.sbrowser,com.brave.browser,com.opera.browser,com.opera.mini.native,com.duckduckgo.mobile.android,com.vivaldi.browser,org.torproject.torbrowser,com.android.settings,com.android.vpndialogs,com.android.packageinstaller,com.google.android.packageinstaller,com.samsung.android.app.settings,com.samsung.accessibility,com.android.vending"
android:description="@string/accessibility_service_description"
android:summary="@string/accessibility_service_summary" />

View File

@ -0,0 +1,89 @@
package expo.modules.rebreakprotection.filter
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Verifiziert dass `DomainHasher` byte-genau die gleichen Hashes produziert wie:
* - Server (`apps/rebreak/server/utils/domainHash.ts`)
* - iOS (`apps/rebreak/ios/App/RebreakURLFilter/FilterControlProvider.swift`)
*
* Fixtures generiert via Node:
* const { createHash } = require("node:crypto");
* createHash("sha256").update(normalize("bet365.com")).digest().readBigUInt64BE(0);
*
* Wenn diese Tests rot werden, ist Cross-Platform-Sync kaputt der Filter
* würde Hashes vom Server nicht mehr finden.
*/
class DomainHasherTest {
private fun u(decimal: String): Long = java.lang.Long.parseUnsignedLong(decimal)
@Test
fun `hash bet365_com matches server fixture`() {
assertEquals(u("16386124564103164180"), DomainHasher.hash("bet365.com"))
}
@Test
fun `hash is case-insensitive`() {
assertEquals(
DomainHasher.hash("bet365.com"),
DomainHasher.hash("BET365.COM")
)
}
@Test
fun `hash strips https protocol`() {
assertEquals(
DomainHasher.hash("bet365.com"),
DomainHasher.hash("https://bet365.com")
)
}
@Test
fun `hash strips path and www-prefix`() {
assertEquals(
DomainHasher.hash("bet365.com"),
DomainHasher.hash("https://www.bet365.com/sport/football")
)
}
@Test
fun `tipico_de matches server fixture`() {
assertEquals(u("15748628718838809648"), DomainHasher.hash("tipico.de"))
}
@Test
fun `pokerstars_com matches server fixture`() {
assertEquals(u("10067937357410366326"), DomainHasher.hash("pokerstars.com"))
}
@Test
fun `casino_com matches server fixture`() {
assertEquals(u("6264962142342037058"), DomainHasher.hash("casino.com"))
}
@Test
fun `evil subdomain has different hash than parent`() {
// Subdomain-Match passiert in DnsFilter, nicht im Hasher.
// Hier nur prüfen dass evil.bet365.com !== bet365.com:
val parent = DomainHasher.hash("bet365.com")
val sub = DomainHasher.hash("evil.bet365.com")
assert(parent != sub)
assertEquals(u("3035823198435943299"), sub)
}
@Test
fun `whitespace is trimmed`() {
assertEquals(
DomainHasher.hash("bet365.com"),
DomainHasher.hash(" bet365.com ")
)
}
@Test
fun `non-blocked domain has its own hash`() {
assertEquals(u("9120123751810482011"), DomainHasher.hash("rebreak.org"))
assertEquals(u("15333025010904606490"), DomainHasher.hash("google.com"))
}
}

View File

@ -0,0 +1,26 @@
Pod::Spec.new do |s|
s.name = 'RebreakProtection'
s.version = '0.1.0'
s.summary = 'ReBreak unified protection — NEFilter + Family Controls.'
s.description = 'Single native module that wraps NEFilterDataProvider + AuthorizationCenter + ManagedSettings for the ReBreak gambling-recovery app.'
s.author = 'ReBreak'
s.homepage = 'https://rebreak.org'
s.license = { :type => 'Proprietary' }
s.platforms = { :ios => '15.1', :tvos => '15.1' }
s.swift_version = '5.9'
s.source = { :git => '' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule',
}
# NUR Top-Level-Sources ins Hauptmodul. RebreakURLFilter/ enthält Sources
# für die NetworkExtension — die werden vom Config-Plugin in ein separates
# Xcode-Target gepackt, NICHT in die Hauptmodul-Lib.
s.source_files = '*.{h,m,mm,swift,hpp,cpp}'
s.exclude_files = 'RebreakURLFilter/**/*'
end

View File

@ -0,0 +1,569 @@
import ExpoModulesCore
import Foundation
import NetworkExtension
import FamilyControls
import ManagedSettings
import WebKit
import UIKit
import UserNotifications
// Konstanten
private let APP_GROUP = "group.org.rebreak.app"
private let BLOCKLIST_FILENAME = "blocklist.bin"
private let ETAG_KEY = "blocklist_etag"
private let LAST_SYNC_KEY = "blocklist_last_sync_at"
private let DARWIN_NOTIF = "rebreak.blocklist.updated"
private let MS_STORE_NAME = "rebreak.shield"
// Shared Log-Store
fileprivate enum SharedLogStore {
static let logKey = "url_filter_logs"
static let maxEntries = 200
static func append(_ message: String) {
NSLog("REBREAK_PROTECTION %@", message)
guard let defaults = UserDefaults(suiteName: APP_GROUP) else { return }
let timestamp = ISO8601DateFormatter().string(from: Date())
let entry = "[\(timestamp)] \(message)"
var logs = defaults.stringArray(forKey: logKey) ?? []
logs.append(entry)
if logs.count > maxEntries { logs.removeFirst(logs.count - maxEntries) }
defaults.set(logs, forKey: logKey)
}
}
// Module
/// Vereinheitlichtes ReBreak-Schutz-Modul.
///
/// Wraps:
/// - NEFilterDataProvider Konfiguration (URL-Filter Layer)
/// - AuthorizationCenter + ManagedSettings (Family Controls denyAppRemoval)
/// - Blocklist-Sync mit Retry-Backoff
/// - End-to-End Health-Probe via hidden WKWebView
///
/// Single Source of Truth für Layer-State auf iOS. Cooldown-Logik bleibt
/// JS-seitig (`lib/protection.ts` Backend-API).
public class RebreakProtectionModule: Module {
// Health-Probe State (UI-Thread)
private var probeWebView: WKWebView?
private var probeDelegate: HealthProbeDelegate?
public func definition() -> ModuleDefinition {
Name("RebreakProtection")
Events("onLayerChange")
// activate: Family Controls + NEFilter + denyAppRemoval
// activateUrlFilter: NUR NEFilter
AsyncFunction("activateUrlFilter") { () async -> [String: Any] in
var error: String? = nil
var enabled = false
do {
let manager = NEFilterManager.shared()
SharedLogStore.append("📥 [activateUrlFilter] loadFromPreferences...")
try await manager.loadFromPreferences()
let config = NEFilterProviderConfiguration()
config.filterBrowsers = true
config.filterSockets = false
manager.providerConfiguration = config
manager.localizedDescription = "Rebreak URL Filter"
manager.isEnabled = true
SharedLogStore.append("💾 [activateUrlFilter] saveToPreferences (System-Dialog)...")
try await manager.saveToPreferences()
enabled = manager.isEnabled
SharedLogStore.append("✅ NEFilter enabled (isEnabled=\(enabled))")
} catch let e as NSError {
error = "\(e.domain):\(e.code) \(e.localizedDescription)"
SharedLogStore.append("❌ NEFilter enable failed: \(error!)")
}
var result: [String: Any] = ["enabled": enabled]
if let error = error { result["error"] = error }
return result
}
// activateFamilyControls: NUR FC + denyAppRemoval
AsyncFunction("activateFamilyControls") { () async -> [String: Any] in
var error: String? = nil
var enabled = false
if #available(iOS 16.0, *) {
do {
try await AuthorizationCenter.shared.requestAuthorization(for: .individual)
let authorized = AuthorizationCenter.shared.authorizationStatus == .approved
SharedLogStore.append("✅ FamilyControls authorized (\(authorized))")
if authorized {
let store = ManagedSettingsStore(named: ManagedSettingsStore.Name(rawValue: MS_STORE_NAME))
store.application.denyAppRemoval = true
store.application.denyAppInstallation = false
let lockActive = (store.application.denyAppRemoval as? Bool) == true
enabled = lockActive
SharedLogStore.append("🔒 denyAppRemoval = \(lockActive)")
if !lockActive {
error = "denyAppRemoval_not_active"
}
} else {
enabled = false
}
} catch let e as NSError {
error = "\(e.domain):\(e.code) \(e.localizedDescription)"
SharedLogStore.append("❌ FamilyControls auth failed: \(error!)")
}
} else {
error = "iOS 16+ required for FamilyControls"
}
var result: [String: Any] = ["enabled": enabled]
if let error = error { result["error"] = error }
return result
}
// activate (legacy, alle Layer in einem Call)
AsyncFunction("activate") { () async -> [String: Any] in
var missingLayers: [String] = []
var errors: [String] = []
// 1) Family Controls Authorization (iOS 16+)
var familyControlsApproved = false
if #available(iOS 16.0, *) {
do {
try await AuthorizationCenter.shared.requestAuthorization(for: .individual)
familyControlsApproved = AuthorizationCenter.shared.authorizationStatus == .approved
SharedLogStore.append("✅ FamilyControls authorized")
} catch let error as NSError {
let msg = "FamilyControls auth failed: \(error.domain):\(error.code) \(error.localizedDescription)"
SharedLogStore.append("\(msg)")
errors.append(msg)
}
} else {
errors.append("iOS 16+ required for FamilyControls")
}
if !familyControlsApproved {
missingLayers.append("familyControls")
}
// 2) NEFilter aktivieren
var urlFilterOn = false
do {
let manager = NEFilterManager.shared()
SharedLogStore.append("📥 [activate] loadFromPreferences...")
try await manager.loadFromPreferences()
let config = NEFilterProviderConfiguration()
config.filterBrowsers = true
config.filterSockets = false
manager.providerConfiguration = config
manager.localizedDescription = "Rebreak URL Filter"
manager.isEnabled = true
SharedLogStore.append("💾 [activate] saveToPreferences (System-Dialog)...")
try await manager.saveToPreferences()
urlFilterOn = manager.isEnabled
SharedLogStore.append("✅ NEFilter enabled (isEnabled=\(urlFilterOn))")
} catch let error as NSError {
let msg = "NEFilter enable failed: \(error.domain):\(error.code) \(error.localizedDescription)"
SharedLogStore.append("\(msg)")
errors.append(msg)
}
if !urlFilterOn {
missingLayers.append("urlFilter")
}
// 3) ManagedSettings denyAppRemoval (nur wenn FamilyControls approved)
if #available(iOS 16.0, *), familyControlsApproved {
let store = ManagedSettingsStore(named: ManagedSettingsStore.Name(rawValue: MS_STORE_NAME))
store.application.denyAppRemoval = true
store.application.denyAppInstallation = false
SharedLogStore.append("🔒 denyAppRemoval = true")
}
return [
"allLayersOn": missingLayers.isEmpty,
"missingLayers": missingLayers,
// Errors ans JS hochbubblen damit das UI wirklich anzeigen kann was failt
"errors": errors,
]
}
// disable: NUR aufrufen wenn JS-Cooldown abgelaufen!
AsyncFunction("disable") { () async -> [String: Any] in
// NEFilter
do {
let manager = NEFilterManager.shared()
try await manager.loadFromPreferences()
try await manager.removeFromPreferences()
SharedLogStore.append("✅ NEFilter disabled")
} catch {
SharedLogStore.append("⚠️ NEFilter disable: \(error.localizedDescription)")
}
// ManagedSettings (löst denyAppRemoval)
if #available(iOS 16.0, *) {
let store = ManagedSettingsStore(named: ManagedSettingsStore.Name(rawValue: MS_STORE_NAME))
store.clearAllSettings()
SharedLogStore.append("🔓 ManagedSettings cleared")
}
// Blocklist-Datei löschen
Self.clearBlocklistFile()
return ["allLayersOff": true]
}
// getDeviceState: aktueller Status aller Layer
AsyncFunction("getDeviceState") { () async -> [String: Any] in
// NEFilter
var urlFilter = false
do {
let manager = NEFilterManager.shared()
try await manager.loadFromPreferences()
urlFilter = manager.isEnabled
} catch {
// ignore
}
// FamilyControls
var familyControls = false
var appDeletionLock = false
if #available(iOS 16.0, *) {
familyControls = AuthorizationCenter.shared.authorizationStatus == .approved
let store = ManagedSettingsStore(named: ManagedSettingsStore.Name(rawValue: MS_STORE_NAME))
appDeletionLock = (store.application.denyAppRemoval as? Bool) == true
}
let count = Self.currentHashCount()
let lastSync = UserDefaults(suiteName: APP_GROUP)?.string(forKey: LAST_SYNC_KEY)
return [
"urlFilter": urlFilter,
"familyControls": familyControls,
"appDeletionLock": appDeletionLock,
"blocklistCount": count,
"blocklistLastSyncAt": lastSync ?? NSNull(),
]
}
// syncBlocklist: download + atomic write + DarwinNotif
AsyncFunction("syncBlocklist") { (opts: [String: String]) async throws -> [String: Any] in
guard let baseURL = opts["baseURL"], let authToken = opts["authToken"] else {
throw NSError(
domain: "RebreakProtection", code: 400,
userInfo: [NSLocalizedDescriptionKey: "missing baseURL or authToken"]
)
}
guard let endpoint = URL(string: "\(baseURL)/api/url-filter/blocklist.bin") else {
throw NSError(
domain: "RebreakProtection", code: 400,
userInfo: [NSLocalizedDescriptionKey: "invalid baseURL"]
)
}
// Retry mit Backoff (1s, 2s) fängt iOS NECP-Race nach saveToPreferences
let maxAttempts = 3
var attempt = 1
var lastError: Error?
while attempt <= maxAttempts {
do {
var request = URLRequest(url: endpoint)
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
if let lastEtag = UserDefaults(suiteName: APP_GROUP)?.string(forKey: ETAG_KEY) {
request.setValue(lastEtag, forHTTPHeaderField: "If-None-Match")
}
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NSError(
domain: "RebreakProtection", code: 500,
userInfo: [NSLocalizedDescriptionKey: "invalid response"]
)
}
if httpResponse.statusCode == 304 {
SharedLogStore.append("📡 sync 304 (cached)")
return ["updated": false, "count": Self.currentHashCount()]
}
guard httpResponse.statusCode == 200 else {
throw NSError(
domain: "RebreakProtection", code: httpResponse.statusCode,
userInfo: [NSLocalizedDescriptionKey: "HTTP \(httpResponse.statusCode)"]
)
}
guard let appGroupURL = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP)
else {
throw NSError(
domain: "RebreakProtection", code: 500,
userInfo: [NSLocalizedDescriptionKey: "App-Group container unavailable"]
)
}
let finalURL = appGroupURL.appendingPathComponent(BLOCKLIST_FILENAME)
let tmpURL = finalURL.appendingPathExtension("tmp")
try data.write(to: tmpURL, options: .atomic)
// DiGA-Hardening
try? (tmpURL as NSURL).setResourceValue(
URLFileProtection.complete,
forKey: .fileProtectionKey
)
var mut = tmpURL
var rv = URLResourceValues()
rv.isExcludedFromBackup = true
try? mut.setResourceValues(rv)
// Atomic replace
_ = try? FileManager.default.removeItem(at: finalURL)
try FileManager.default.moveItem(at: tmpURL, to: finalURL)
// ETag + lastSync persistieren
let defaults = UserDefaults(suiteName: APP_GROUP)
if let newEtag = httpResponse.value(forHTTPHeaderField: "etag") {
defaults?.set(newEtag, forKey: ETAG_KEY)
}
defaults?.set(ISO8601DateFormatter().string(from: Date()), forKey: LAST_SYNC_KEY)
// Extension benachrichtigen (cross-process)
CFNotificationCenterPostNotification(
CFNotificationCenterGetDarwinNotifyCenter(),
CFNotificationName(DARWIN_NOTIF as CFString),
nil, nil, true
)
let count = data.count / 8
let plan = httpResponse.value(forHTTPHeaderField: "x-rebreak-plan") ?? ""
SharedLogStore.append("📡 sync ok: \(count) hashes (plan=\(plan))")
var result: [String: Any] = ["updated": true, "count": count]
if !plan.isEmpty { result["plan"] = plan }
return result
} catch {
lastError = error
let nsError = error as NSError
let isTransient = nsError.domain == NSURLErrorDomain && (
nsError.code == NSURLErrorNotConnectedToInternet ||
nsError.code == NSURLErrorCannotConnectToHost ||
nsError.code == NSURLErrorTimedOut ||
nsError.code == NSURLErrorNetworkConnectionLost ||
nsError.code == NSURLErrorCannotFindHost
)
if isTransient && attempt < maxAttempts {
let delaySec = UInt64(attempt)
SharedLogStore.append("⏳ sync transient \(nsError.code), retry in \(delaySec)s")
try? await Task.sleep(nanoseconds: delaySec * 1_000_000_000)
attempt += 1
continue
}
SharedLogStore.append("❌ sync failed: \(error.localizedDescription)")
throw error
}
}
throw lastError ?? NSError(
domain: "RebreakProtection", code: 500,
userInfo: [NSLocalizedDescriptionKey: "max attempts exhausted"]
)
}
// runHealthProbe: hidden WKWebView gegen bet365
AsyncFunction("runHealthProbe") { (opts: [String: Any]?) async -> [String: Any] in
let target = (opts?["target"] as? String) ?? "https://bet365.com"
let timeoutSec = (opts?["timeoutSeconds"] as? Double) ?? 5.0
guard let url = URL(string: target) else {
return [
"outcome": "offline",
"reason": "invalid_target",
"durationMs": 0,
"target": target,
]
}
return await withCheckedContinuation { continuation in
DispatchQueue.main.async { [weak self] in
guard let self = self else {
continuation.resume(returning: [
"outcome": "offline",
"reason": "module_gone",
"durationMs": 0,
"target": target,
])
return
}
// Vorherige Probe abbrechen
self.probeWebView?.stopLoading()
self.probeWebView?.navigationDelegate = nil
self.probeWebView = nil
self.probeDelegate = nil
let config = WKWebViewConfiguration()
config.websiteDataStore = .nonPersistent()
let webView = WKWebView(
frame: CGRect(x: 0, y: 0, width: 1, height: 1),
configuration: config
)
let delegate = HealthProbeDelegate(timeoutSeconds: timeoutSec) { [weak self] result in
self?.probeWebView?.stopLoading()
self?.probeWebView?.navigationDelegate = nil
self?.probeWebView = nil
self?.probeDelegate = nil
switch result {
case .blocked(let reason, let durationMs):
SharedLogStore.append("🛡️ probe BLOCKED: \(target)\(reason)\(durationMs)ms")
continuation.resume(returning: [
"outcome": "blocked", "reason": reason,
"durationMs": durationMs, "target": target,
])
case .loaded(let durationMs):
SharedLogStore.append("🚨 probe LOADED \(target) (filter DEAD) — \(durationMs)ms")
continuation.resume(returning: [
"outcome": "loaded", "reason": "page_loaded",
"durationMs": durationMs, "target": target,
])
case .offline(let reason):
continuation.resume(returning: [
"outcome": "offline", "reason": reason,
"durationMs": 0, "target": target,
])
case .timeout:
continuation.resume(returning: [
"outcome": "timeout", "reason": "no_resolution",
"durationMs": Int(timeoutSec * 1000), "target": target,
])
}
}
self.probeWebView = webView
self.probeDelegate = delegate
webView.navigationDelegate = delegate
var request = URLRequest(url: url)
request.timeoutInterval = timeoutSec
request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData
webView.load(request)
}
}
}
// openSystemSettings
AsyncFunction("openSystemSettings") { (target: String?) -> Void in
// Auf iOS gibt's nur einen single-entry-point (Settings-App). Spezifische
// Tabs (Screen Time / Notifications) öffnen sich nicht zuverlässig per
// URL-Scheme Apple deny'd das seit iOS 13. Wir öffnen die App-Settings
// unserer App; der User navigiert von dort weiter.
DispatchQueue.main.async {
if let url = URL(string: UIApplication.openSettingsURLString),
UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url)
}
}
}
}
// Helpers
private static func currentHashCount() -> Int {
guard let url = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP)?
.appendingPathComponent(BLOCKLIST_FILENAME),
let attrs = try? FileManager.default.attributesOfItem(atPath: url.path),
let size = attrs[.size] as? Int
else { return 0 }
return size / 8
}
private static func clearBlocklistFile() {
if let url = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP)?
.appendingPathComponent(BLOCKLIST_FILENAME) {
try? FileManager.default.removeItem(at: url)
}
UserDefaults(suiteName: APP_GROUP)?.removeObject(forKey: ETAG_KEY)
UserDefaults(suiteName: APP_GROUP)?.removeObject(forKey: LAST_SYNC_KEY)
CFNotificationCenterPostNotification(
CFNotificationCenterGetDarwinNotifyCenter(),
CFNotificationName(DARWIN_NOTIF as CFString),
nil, nil, true
)
}
}
// HealthProbeDelegate
private final class HealthProbeDelegate: NSObject, WKNavigationDelegate {
enum Outcome {
case blocked(reason: String, durationMs: Int)
case loaded(durationMs: Int)
case offline(reason: String)
case timeout
}
private let onComplete: (Outcome) -> Void
private let startTime: Date
private var resolved = false
private var timeoutItem: DispatchWorkItem?
init(timeoutSeconds: Double, onComplete: @escaping (Outcome) -> Void) {
self.onComplete = onComplete
self.startTime = Date()
super.init()
let item = DispatchWorkItem { [weak self] in self?.resolveOnce(.timeout) }
self.timeoutItem = item
DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds, execute: item)
}
private func resolveOnce(_ outcome: Outcome) {
if resolved { return }
resolved = true
timeoutItem?.cancel()
timeoutItem = nil
onComplete(outcome)
}
private func durationMs() -> Int {
return Int(Date().timeIntervalSince(startTime) * 1000)
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
resolveOnce(.loaded(durationMs: durationMs()))
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
let nsError = error as NSError
let reasonStr = "\(nsError.domain):\(nsError.code)"
if nsError.domain == NSURLErrorDomain &&
(nsError.code == NSURLErrorNotConnectedToInternet ||
nsError.code == NSURLErrorCannotFindHost ||
nsError.code == NSURLErrorTimedOut) {
resolveOnce(.offline(reason: reasonStr))
} else {
resolveOnce(.blocked(reason: reasonStr, durationMs: durationMs()))
}
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
let nsError = error as NSError
let reasonStr = "\(nsError.domain):\(nsError.code)"
if nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorNotConnectedToInternet {
resolveOnce(.offline(reason: reasonStr))
} else {
resolveOnce(.blocked(reason: reasonStr, durationMs: durationMs()))
}
}
}

View File

@ -0,0 +1,228 @@
//
// FilterDataProvider.swift
// RebreakURLFilter NEFilterDataProvider mit memory-mapped Hash-Liste.
//
// Architektur:
// - Container-App lädt `blocklist.bin` vom Server runter (sortierte 64-bit Hashes)
// - File liegt in App-Group: group.org.rebreak.app/blocklist.bin
// - Diese Extension memory-mapped die Datei und macht Binary-Search pro Flow
// - Memory-Footprint: <1 MB (mmap working-set)
//
// Privacy:
// - Keine Klartext-Domains auf Disk (nur SHA-256/64-bit Hashes)
// - User-Browsing-URL verlässt das Gerät nie
//
import NetworkExtension
import Foundation
import CryptoKit
/// Shared Log-Store via App-Group UserDefaults (für Container-App-Debug-Page).
enum SharedLogStore {
static let appGroup = "group.org.rebreak.app"
static let logKey = "url_filter_logs"
static let maxEntries = 200
static func append(_ message: String) {
NSLog("REBREAK_URL_FILTER %@", message)
guard let defaults = UserDefaults(suiteName: appGroup) else { return }
let timestamp = ISO8601DateFormatter().string(from: Date())
let entry = "[\(timestamp)] \(message)"
var logs = defaults.stringArray(forKey: logKey) ?? []
logs.append(entry)
if logs.count > maxEntries { logs.removeFirst(logs.count - maxEntries) }
defaults.set(logs, forKey: logKey)
}
}
/// Domain-Hashing IDENTISCH zu `server/utils/domainHash.ts`.
/// Server schickt: SHA-256(salt:domain).first(8) als big-endian UInt64.
enum DomainHasher {
static func normalize(_ host: String) -> String {
var h = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if h.hasPrefix("https://") { h = String(h.dropFirst(8)) }
else if h.hasPrefix("http://") { h = String(h.dropFirst(7)) }
if let slash = h.firstIndex(of: "/") { h = String(h[..<slash]) }
if h.hasPrefix("www.") { h = String(h.dropFirst(4)) }
return h
}
static func hash(_ host: String, salt: String = "") -> UInt64 {
let normalized = normalize(host)
let input = salt.isEmpty ? normalized : "\(salt):\(normalized)"
guard let data = input.data(using: .utf8) else { return 0 }
let digest = SHA256.hash(data: data)
// First 8 bytes as big-endian UInt64 (matches Node's readBigUInt64BE)
var result: UInt64 = 0
for (i, byte) in digest.prefix(8).enumerated() {
result |= UInt64(byte) << UInt64((7 - i) * 8)
}
return result
}
}
/// Memory-mapped Binary-Hash-Liste. Lädt die Datei lazy beim ersten Zugriff,
/// reloaded bei DarwinNotification "rebreak.blocklist.updated".
final class HashListMmap {
static let shared = HashListMmap()
private static let appGroup = "group.org.rebreak.app"
private static let filename = "blocklist.bin"
private var data: Data?
private var hashCount: Int = 0
private var loadedMtime: Date?
private var lastMtimeCheck: Date = .distantPast
private let queue = DispatchQueue(label: "rebreak.hashlist.reload")
private init() {
load()
observeUpdates()
}
private static var fileURL: URL? {
FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: appGroup)?
.appendingPathComponent(filename)
}
private static func currentMtime() -> Date? {
guard let url = fileURL,
let attrs = try? FileManager.default.attributesOfItem(atPath: url.path),
let mtime = attrs[.modificationDate] as? Date
else { return nil }
return mtime
}
/// Polled die mtime von blocklist.bin (max. 1×/sec) und reloaded den mmap
/// wenn sich was geändert hat. Macht uns unabhängig von DarwinNotifications,
/// die verloren gehen können wenn die Extension idle ist.
private func refreshIfChanged() {
let needsReload: Bool = queue.sync {
let now = Date()
if now.timeIntervalSince(lastMtimeCheck) < 1.0 { return false }
lastMtimeCheck = now
return loadedMtime != Self.currentMtime()
}
if needsReload { load() }
}
private func load() {
queue.sync {
guard let url = Self.fileURL,
FileManager.default.fileExists(atPath: url.path),
let mmapped = try? Data(contentsOf: url, options: .alwaysMapped)
else {
self.data = nil
self.hashCount = 0
self.loadedMtime = nil
SharedLogStore.append(" blocklist.bin not present — block-set ist leer")
return
}
self.data = mmapped
self.hashCount = mmapped.count / 8
self.loadedMtime = Self.currentMtime()
SharedLogStore.append("📂 blocklist.bin loaded: \(self.hashCount) hashes (\(mmapped.count) bytes)")
}
}
private func observeUpdates() {
// Container-App feuert DarwinNotification nach Sync. Apple's
// CFNotificationCenterGetDarwinNotifyCenter erlaubt cross-process events
// ohne shared state.
let name = "rebreak.blocklist.updated" as CFString
let center = CFNotificationCenterGetDarwinNotifyCenter()
let observer = Unmanaged.passUnretained(self).toOpaque()
CFNotificationCenterAddObserver(
center,
observer,
{ _, observer, _, _, _ in
guard let observer = observer else { return }
let me = Unmanaged<HashListMmap>.fromOpaque(observer).takeUnretainedValue()
me.load()
},
name,
nil,
.deliverImmediately
)
}
/// Binary-search auf sortierten 64-bit Hashes. O(log n).
func contains(_ hash: UInt64) -> Bool {
// Cheap mtime-check (rate-limited 1×/sec) fängt verlorene
// DarwinNotifications und stale-mmap nach atomic-replace.
refreshIfChanged()
return queue.sync {
guard let data = self.data, self.hashCount > 0 else { return false }
var lo = 0
var hi = self.hashCount - 1
while lo <= hi {
let mid = (lo + hi) / 2
let offset = mid * 8
// Read big-endian UInt64 at offset
var value: UInt64 = 0
data.withUnsafeBytes { ptr in
let base = ptr.baseAddress!.advanced(by: offset)
for i in 0..<8 {
value = (value << 8) | UInt64(base.load(fromByteOffset: i, as: UInt8.self))
}
}
if value == hash { return true }
if value < hash { lo = mid + 1 }
else { hi = mid - 1 }
}
return false
}
}
}
class FilterDataProvider: NEFilterDataProvider {
override func startFilter(completionHandler: @escaping (Error?) -> Void) {
SharedLogStore.append("🚀 startFilter() called")
// Trigger initial load
_ = HashListMmap.shared
completionHandler(nil)
}
override func stopFilter(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) {
SharedLogStore.append("🛑 stopFilter() reason=\(reason.rawValue)")
completionHandler()
}
override func handleNewFlow(_ flow: NEFilterFlow) -> NEFilterNewFlowVerdict {
guard let browserFlow = flow as? NEFilterBrowserFlow,
let url = browserFlow.url,
let host = url.host
else {
return .allow()
}
let normalizedHost = DomainHasher.normalize(host)
let hashList = HashListMmap.shared
// Subdomain-Match: für `evil.shop.bet365.com` testen wir
// - evil.shop.bet365.com
// - shop.bet365.com
// - bet365.com
// - com (wird in der Praxis nie matchen TLD steht nicht in der Liste)
// Max 5 Iterationen (Cap zur Sicherheit).
var current = normalizedHost
var iter = 0
while iter < 5 {
let h = DomainHasher.hash(current)
if hashList.contains(h) {
SharedLogStore.append("🚫 BLOCKED: \(normalizedHost) (matched suffix: \(current))")
return .drop()
}
// Strippen bis zum nächsten Punkt
guard let dot = current.firstIndex(of: ".") else { break }
current = String(current[current.index(after: dot)...])
// Stoppen wenn kein Punkt mehr übrig (= TLD)
if !current.contains(".") { break }
iter += 1
}
return .allow()
}
}

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>RebreakURLFilter</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.networkextension.filter-data</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).FilterDataProvider</string>
</dict>
</dict>
</plist>

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.networking.networkextension</key>
<array>
<string>content-filter-provider</string>
</array>
<key>com.apple.security.application-groups</key>
<array>
<string>group.org.rebreak.app</string>
</array>
</dict>
</plist>