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:
parent
5291a8a95a
commit
a80cc8b08d
14
apps/rebreak-native/.gitignore
vendored
14
apps/rebreak-native/.gitignore
vendored
@ -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
|
||||
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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" />
|
||||
@ -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"))
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -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>
|
||||
Loading…
x
Reference in New Issue
Block a user