DataControl v0.1.0 : contrôle du forfait data mobile via pare-feu VPN local
Surveillance de la consommation de données mobiles avec coupure automatique en cas de dépassement de forfait, sans root ni impact sur le Wi-Fi. - Pare-feu local (VpnService) actif uniquement sur données mobiles - Liste blanche d'applications toujours autorisées - Coupure cyclique totale après dépassement (intervalle paramétrable) - Réglages complets (forfait, prix, date de début, reset auto/manuel) - Estimation du coût total en temps réel - Notification permanente colorée par état - UI Compose Material 3 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
a71026898b
commit
536a981f9e
@@ -0,0 +1,171 @@
|
||||
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 {
|
||||
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()
|
||||
startForeground(NOTIF_ID, buildNotification())
|
||||
establishTunnel(allowed)
|
||||
}
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
private fun establishTunnel(allowedPackages: Set<String>) {
|
||||
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<String>): 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user