package com.jttools.datacontrol.service 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.content.pm.PackageManager import android.net.VpnService import android.os.Build import android.os.ParcelFileDescriptor import android.util.Log import androidx.core.app.NotificationCompat import com.jttools.datacontrol.MainActivity import com.jttools.datacontrol.R import java.io.FileInputStream /** * Pare-feu local basé sur [VpnService], sans root. * * Ce service ne route ni ne transmet aucun paquet : les applications "autorisées" sont * exclues du tunnel via [Builder.addDisallowedApplication] (leur trafic continue de * transiter normalement par la vraie interface réseau). Toutes les autres applications * voient leur trafic capturé dans le tunnel VPN puis silencieusement ignoré ("trou noir"), * ce qui coupe leur accès Internet sans jamais toucher au Wi-Fi : ce service n'est démarré * par [DataMonitorService] que lorsque le réseau actif est la donnée mobile. */ class FirewallVpnService : VpnService() { private var tunInterface: ParcelFileDescriptor? = null private var readerThread: Thread? = null @Volatile private var running = false override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { // Le service est toujours lancé via startForegroundService() (y compris pour ACTION_STOP, // cf. DataMonitorService.applyVpnMode) : le système impose un appel à startForeground() // dans les 5 secondes qui suivent, sous peine de crash (ForegroundServiceDidNotStartInTimeException // sur Android 12+), même si le service s'arrête immédiatement après. startForeground(NOTIF_ID, buildNotification()) when (intent?.action) { ACTION_STOP -> { stopTunnel() stopSelf() return START_NOT_STICKY } ACTION_START_FILTER, ACTION_START_BLOCK_ALL -> { val allowed = intent.getStringArrayListExtra(EXTRA_ALLOWED_PACKAGES)?.toSet() ?: emptySet() establishTunnel(allowed) } } return START_STICKY } private fun establishTunnel(allowedPackages: Set) { stopTunnel(keepForeground = true) val builder = Builder() .setSession("DataControl") .addAddress(TUNNEL_ADDRESS, 32) .addRoute("0.0.0.0", 0) .setBlocking(true) // Notre propre app doit toujours pouvoir communiquer normalement. addDisallowed(builder, packageName) allowedPackages.forEach { addDisallowed(builder, it) } tunInterface = runCatching { builder.establish() }.getOrElse { Log.e(TAG, "Échec établissement du tunnel VPN", it) null } val fd = tunInterface ?: return running = true readerThread = Thread({ blackhole(fd) }, "datacontrol-vpn-blackhole").apply { isDaemon = true start() } } private fun addDisallowed(builder: Builder, packageName: String) { try { builder.addDisallowedApplication(packageName) } catch (e: PackageManager.NameNotFoundException) { // Application désinstallée entre-temps : rien à exclure. } } /** Lit et jette tout paquet appartenant aux applications non-autorisées : leur trafic n'aboutit jamais. */ private fun blackhole(fd: ParcelFileDescriptor) { val input = FileInputStream(fd.fileDescriptor) val buffer = ByteArray(32_767) try { while (running) { val read = input.read(buffer) if (read < 0) break } } catch (_ : Exception) { // Interface fermée depuis stopTunnel() : fin normale du thread. } } private fun stopTunnel(keepForeground: Boolean = false) { running = false readerThread?.interrupt() readerThread = null tunInterface?.let { runCatching { it.close() } } tunInterface = null if (!keepForeground) { stopForeground(STOP_FOREGROUND_REMOVE) } } override fun onRevoke() { stopTunnel() sendBroadcast(Intent(ACTION_VPN_REVOKED).setPackage(packageName)) super.onRevoke() } override fun onDestroy() { stopTunnel() super.onDestroy() } private fun buildNotification(): Notification { val nm = getSystemService(NotificationManager::class.java) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel( CHANNEL_ID, getString(R.string.notification_channel_name), NotificationManager.IMPORTANCE_MIN ) nm.createNotificationChannel(channel) } val openApp = PendingIntent.getActivity( this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE ) return NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle(getString(R.string.notification_title)) .setSmallIcon(R.drawable.ic_notification) .setPriority(NotificationCompat.PRIORITY_MIN) .setContentIntent(openApp) .setOngoing(true) .build() } companion object { private const val TAG = "FirewallVpnService" private const val CHANNEL_ID = "datacontrol_vpn" private const val NOTIF_ID = 2001 private const val TUNNEL_ADDRESS = "10.10.10.1" const val ACTION_START_FILTER = "com.jttools.datacontrol.action.VPN_START_FILTER" const val ACTION_START_BLOCK_ALL = "com.jttools.datacontrol.action.VPN_START_BLOCK_ALL" const val ACTION_STOP = "com.jttools.datacontrol.action.VPN_STOP" const val ACTION_VPN_REVOKED = "com.jttools.datacontrol.action.VPN_REVOKED" const val EXTRA_ALLOWED_PACKAGES = "allowed_packages" fun isPrepared(context: Context): Boolean = prepare(context) == null fun startFilterIntent(context: Context, allowedPackages: Collection): Intent = Intent(context, FirewallVpnService::class.java) .setAction(ACTION_START_FILTER) .putStringArrayListExtra(EXTRA_ALLOWED_PACKAGES, ArrayList(allowedPackages)) fun startBlockAllIntent(context: Context): Intent = Intent(context, FirewallVpnService::class.java) .setAction(ACTION_START_BLOCK_ALL) .putStringArrayListExtra(EXTRA_ALLOWED_PACKAGES, ArrayList()) fun stopIntent(context: Context): Intent = Intent(context, FirewallVpnService::class.java).setAction(ACTION_STOP) } }