release: v0.1.3 — correctif VPN, alerte pare-feu inactif, contrôle parental (FamilyControl), anti-désinstallation
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c63cc45c57
commit
82d47fcd72
25 files changed
+996
-16
No files matched your search
@@ -16,3 +16,6 @@ local.properties
|
||||
# Jeton Gitea (lecture/écriture) pour "make publish"/"make deploy" : local,
|
||||
# jamais versionné.
|
||||
gitea-token.properties
|
||||
|
||||
# Configuration Firebase locale (clés du projet).
|
||||
firebase.properties
|
||||
@@ -1,5 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## v0.1.3 – 2026-09-20
|
||||
|
||||
- **Correctif VPN** : le suivi du réseau ignore désormais le tunnel VPN lui-même (le pare-feu ne s'arrête plus tout seul), le trafic IPv6 est aussi filtré, un tunnel perdu est remonté automatiquement, et la notification signale « PARE-FEU INACTIF » si les données mobiles ne sont pas filtrées.
|
||||
- Forfait par défaut : 1 Go (1000 Mo).
|
||||
- Contrôle parental : appairage avec **FamilyControl** (Firebase), envoi de la batterie, de la position, de l'état du pare-feu et de l'historique de trafic ; les accès réseau par application se pilotent à distance.
|
||||
- Protection anti-désinstallation (administrateur d'appareil) ; sa désactivation depuis l'app demande le mot de passe.
|
||||
|
||||
## v0.1.2 – 2026-09-20
|
||||
|
||||
- Mise à jour depuis les Releases Gitea : bouton « Rechercher une mise à jour » dans l'onglet À propos (téléchargement + installation de l'APK).
|
||||
|
||||
@@ -9,6 +9,13 @@ plugins {
|
||||
alias(libs.plugins.kotlin.kapt)
|
||||
}
|
||||
|
||||
// Configuration Firebase (liaison distante avec FamilyControl) : fichier local non versionné.
|
||||
// Sans ce fichier l'app se compile normalement, mais l'appairage/le contrôle à distance sont désactivés.
|
||||
val firebaseProps = Properties().apply {
|
||||
val f = rootProject.file("firebase.properties")
|
||||
if (f.exists()) f.inputStream().use { load(it) }
|
||||
}
|
||||
|
||||
val versionProps = Properties().apply {
|
||||
load(rootProject.file("version.properties").inputStream())
|
||||
}
|
||||
@@ -29,6 +36,9 @@ android {
|
||||
useSupportLibrary = true
|
||||
}
|
||||
|
||||
buildConfigField("String", "FIREBASE_API_KEY", "\"${firebaseProps.getProperty("apiKey", "")}\"")
|
||||
buildConfigField("String", "FIREBASE_APP_ID", "\"${firebaseProps.getProperty("appId", "")}\"")
|
||||
buildConfigField("String", "FIREBASE_PROJECT_ID", "\"${firebaseProps.getProperty("projectId", "")}\"")
|
||||
buildConfigField("String", "BUILD_DATE", "\"${SimpleDateFormat("yyyy-MM-dd").format(Date())}\"")
|
||||
}
|
||||
|
||||
@@ -87,6 +97,10 @@ dependencies {
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.androidx.work.runtime.ktx)
|
||||
implementation(platform(libs.firebase.bom))
|
||||
implementation(libs.firebase.auth)
|
||||
implementation(libs.firebase.firestore)
|
||||
implementation(libs.kotlinx.coroutines.play.services)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<!-- Position de l'appareil, transmise au parent via FamilyControl (arrière-plan compris). -->
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||
<!-- Nécessaire pour lister TOUTES les apps installées dans l'écran "Liste blanche" -->
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||
tools:ignore="QueryAllPackagesPermission" />
|
||||
@@ -61,6 +65,19 @@
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<!-- Administrateur d'appareil : bloque la désinstallation tant qu'il est actif. -->
|
||||
<receiver
|
||||
android:name=".admin.AppDeviceAdminReceiver"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_DEVICE_ADMIN">
|
||||
<meta-data
|
||||
android:name="android.app.device_admin"
|
||||
android:resource="@xml/device_admin" />
|
||||
<intent-filter>
|
||||
<action android:name="android.app.action.DEVICE_ADMIN_ENABLED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".service.BootReceiver"
|
||||
android:exported="true"
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.jttools.datacontrol.admin
|
||||
|
||||
import android.app.admin.DevicePolicyManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
/** Protection anti-désinstallation : tant que DataControl est administrateur de l'appareil, Android refuse de le désinstaller. */
|
||||
object AdminController {
|
||||
fun component(context: Context) = ComponentName(context, AppDeviceAdminReceiver::class.java)
|
||||
|
||||
fun isActive(context: Context): Boolean =
|
||||
context.getSystemService(DevicePolicyManager::class.java).isAdminActive(component(context))
|
||||
|
||||
fun requestIntent(context: Context): Intent =
|
||||
Intent(DevicePolicyManager.ACTION_ADD_DEVICE_ADMIN)
|
||||
.putExtra(DevicePolicyManager.EXTRA_DEVICE_ADMIN, component(context))
|
||||
.putExtra(
|
||||
DevicePolicyManager.EXTRA_ADD_EXPLANATION,
|
||||
"Empêche la désinstallation de DataControl sans mot de passe."
|
||||
)
|
||||
|
||||
/** À n'appeler qu'après vérification du mot de passe. */
|
||||
fun deactivate(context: Context) {
|
||||
context.getSystemService(DevicePolicyManager::class.java).removeActiveAdmin(component(context))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.jttools.datacontrol.admin
|
||||
|
||||
import android.app.admin.DeviceAdminReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.jttools.datacontrol.remote.RemoteSync
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class AppDeviceAdminReceiver : DeviceAdminReceiver() {
|
||||
|
||||
@Inject lateinit var remoteSync: RemoteSync
|
||||
|
||||
override fun onDisableRequested(context: Context, intent: Intent): CharSequence =
|
||||
"Désactiver la protection permet de désinstaller DataControl. Cette action est signalée à FamilyControl."
|
||||
|
||||
override fun onDisabled(context: Context, intent: Intent) {
|
||||
// Prévient le parent : la protection anti-désinstallation vient d'être retirée.
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
remoteSync.pushEvent("admin_disabled", "Protection anti-désinstallation désactivée")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import java.time.LocalDate
|
||||
*/
|
||||
data class AppSettings(
|
||||
val controlEnabled: Boolean = false,
|
||||
val planLimitMo: Long = 5000L,
|
||||
val planLimitMo: Long = 1000L,
|
||||
val planPriceEuro: Double = 19.99,
|
||||
val overagePricePerMoEuro: Double = 0.01,
|
||||
val planStartDateEpochDay: Long = LocalDate.now().toEpochDay(),
|
||||
@@ -22,8 +22,15 @@ data class AppSettings(
|
||||
val passwordSalt: String? = null,
|
||||
val cutoffCycleAnchorMillis: Long? = null,
|
||||
val cutoffInPulse: Boolean = false,
|
||||
val cutoffPulseStartMillis: Long = 0L
|
||||
val cutoffPulseStartMillis: Long = 0L,
|
||||
/** Appairage avec FamilyControl (null tant que l'appareil n'est pas associé). */
|
||||
val familyId: String? = null,
|
||||
val deviceId: String? = null,
|
||||
/** Horodatage (ms) de la dernière configuration distante appliquée. */
|
||||
val remoteConfigVersion: Long = 0L
|
||||
) {
|
||||
val isPaired: Boolean get() = !familyId.isNullOrEmpty() && !deviceId.isNullOrEmpty()
|
||||
|
||||
val planStartDate: LocalDate get() = LocalDate.ofEpochDay(planStartDateEpochDay)
|
||||
|
||||
/** Un mot de passe de verrouillage protège la désactivation du contrôle et l'accès aux réglages. */
|
||||
@@ -59,6 +66,9 @@ data class UsageState(
|
||||
get() = if (planLimitMo <= 0) 0f else (consumedMo.toFloat() / planLimitMo.toFloat()).coerceAtLeast(0f)
|
||||
}
|
||||
|
||||
/** Trafic cumulé d'une journée, pour l'historique envoyé à FamilyControl. */
|
||||
data class TrafficDay(val date: String, val mobileBytes: Long, val wifiBytes: Long)
|
||||
|
||||
enum class DataState {
|
||||
OK, // sous le seuil d'alerte, liste blanche active, pas de coupure en cours
|
||||
WARNING, // proche ou au-delà du forfait (>= 80%), liste blanche active, pas de coupure en cours
|
||||
|
||||
@@ -14,7 +14,7 @@ class InstalledAppsRepository @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
@Suppress("DEPRECATION")
|
||||
suspend fun listInstalledApps(): List<AppInfo> = withContext(Dispatchers.Default) {
|
||||
suspend fun listInstalledApps(withIcons: Boolean = true): List<AppInfo> = withContext(Dispatchers.Default) {
|
||||
val pm = context.packageManager
|
||||
val ownPackage = context.packageName
|
||||
pm.getInstalledApplications(PackageManager.GET_META_DATA)
|
||||
@@ -24,7 +24,7 @@ class InstalledAppsRepository @Inject constructor(
|
||||
AppInfo(
|
||||
packageName = appInfo.packageName,
|
||||
label = pm.getApplicationLabel(appInfo).toString(),
|
||||
icon = runCatching { pm.getApplicationIcon(appInfo) }.getOrNull(),
|
||||
icon = if (withIcons) runCatching { pm.getApplicationIcon(appInfo) }.getOrNull() else null,
|
||||
isSystemApp = (appInfo.flags and ApplicationInfo.FLAG_SYSTEM) != 0
|
||||
)
|
||||
}
|
||||
|
||||
@@ -42,6 +42,14 @@ class SettingsRepository @Inject constructor(
|
||||
val CUTOFF_ANCHOR_MILLIS = longPreferencesKey("cutoff_anchor_millis")
|
||||
val CUTOFF_IN_PULSE = booleanPreferencesKey("cutoff_in_pulse")
|
||||
val CUTOFF_PULSE_START_MILLIS = longPreferencesKey("cutoff_pulse_start_millis")
|
||||
val FAMILY_ID = stringPreferencesKey("family_id")
|
||||
val DEVICE_ID = stringPreferencesKey("device_id")
|
||||
val REMOTE_CONFIG_VERSION = longPreferencesKey("remote_config_version")
|
||||
val TRAFFIC_DAY = stringPreferencesKey("traffic_day")
|
||||
val TRAFFIC_MOBILE_ACC = longPreferencesKey("traffic_mobile_acc")
|
||||
val TRAFFIC_WIFI_ACC = longPreferencesKey("traffic_wifi_acc")
|
||||
val TRAFFIC_LAST_MOBILE = longPreferencesKey("traffic_last_mobile")
|
||||
val TRAFFIC_LAST_OTHER = longPreferencesKey("traffic_last_other")
|
||||
}
|
||||
|
||||
val settingsFlow: Flow<AppSettings> = context.dataStore.data.map { prefs ->
|
||||
@@ -63,7 +71,10 @@ class SettingsRepository @Inject constructor(
|
||||
passwordSalt = prefs[Keys.PASSWORD_SALT],
|
||||
cutoffCycleAnchorMillis = prefs[Keys.CUTOFF_ANCHOR_MILLIS],
|
||||
cutoffInPulse = prefs[Keys.CUTOFF_IN_PULSE] ?: defaults.cutoffInPulse,
|
||||
cutoffPulseStartMillis = prefs[Keys.CUTOFF_PULSE_START_MILLIS] ?: defaults.cutoffPulseStartMillis
|
||||
cutoffPulseStartMillis = prefs[Keys.CUTOFF_PULSE_START_MILLIS] ?: defaults.cutoffPulseStartMillis,
|
||||
familyId = prefs[Keys.FAMILY_ID],
|
||||
deviceId = prefs[Keys.DEVICE_ID],
|
||||
remoteConfigVersion = prefs[Keys.REMOTE_CONFIG_VERSION] ?: defaults.remoteConfigVersion
|
||||
)
|
||||
}
|
||||
|
||||
@@ -187,4 +198,71 @@ class SettingsRepository @Inject constructor(
|
||||
prefs[Keys.CUTOFF_PULSE_START_MILLIS] = pulseStartMillis
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setPairing(familyId: String?, deviceId: String?) {
|
||||
context.dataStore.edit {
|
||||
if (familyId == null || deviceId == null) {
|
||||
it.remove(Keys.FAMILY_ID)
|
||||
it.remove(Keys.DEVICE_ID)
|
||||
it.remove(Keys.REMOTE_CONFIG_VERSION)
|
||||
} else {
|
||||
it[Keys.FAMILY_ID] = familyId
|
||||
it[Keys.DEVICE_ID] = deviceId
|
||||
it[Keys.REMOTE_CONFIG_VERSION] = 0L
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applique une configuration envoyée par FamilyControl. Chaque champ null est ignoré.
|
||||
* [version] (horodatage de l'écriture côté parent) évite de ré-appliquer une ancienne
|
||||
* configuration au redémarrage et d'écraser un réglage local plus récent.
|
||||
*/
|
||||
suspend fun applyRemoteConfig(
|
||||
version: Long,
|
||||
controlEnabled: Boolean?,
|
||||
whitelistedPackages: Set<String>?,
|
||||
whitelistedPackagesWifi: Set<String>?,
|
||||
wifiFilterEnabled: Boolean?,
|
||||
planLimitMo: Long?,
|
||||
cutoffIntervalMinutes: Int?
|
||||
) {
|
||||
context.dataStore.edit { prefs ->
|
||||
if ((prefs[Keys.REMOTE_CONFIG_VERSION] ?: 0L) >= version) return@edit
|
||||
prefs[Keys.REMOTE_CONFIG_VERSION] = version
|
||||
controlEnabled?.let { prefs[Keys.CONTROL_ENABLED] = it }
|
||||
whitelistedPackages?.let { prefs[Keys.WHITELISTED_PACKAGES] = it }
|
||||
whitelistedPackagesWifi?.let { prefs[Keys.WHITELISTED_PACKAGES_WIFI] = it }
|
||||
wifiFilterEnabled?.let { prefs[Keys.WIFI_FILTER_ENABLED] = it }
|
||||
planLimitMo?.takeIf { it > 0 }?.let { prefs[Keys.PLAN_LIMIT_MO] = it }
|
||||
cutoffIntervalMinutes?.takeIf { it > 0 }?.let { prefs[Keys.CUTOFF_INTERVAL_MINUTES] = it }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cumule le trafic de la journée à partir des compteurs système (qui repartent de zéro au
|
||||
* redémarrage : un compteur inférieur au précédent est traité comme un redémarrage).
|
||||
* [otherBytes] = trafic non mobile (Wi-Fi, etc.) = total - mobile.
|
||||
*/
|
||||
suspend fun accumulateTraffic(today: String, mobileBytes: Long, otherBytes: Long): TrafficDay {
|
||||
var result = TrafficDay(today, 0L, 0L)
|
||||
context.dataStore.edit { prefs ->
|
||||
val sameDay = prefs[Keys.TRAFFIC_DAY] == today
|
||||
var mobileAcc = if (sameDay) prefs[Keys.TRAFFIC_MOBILE_ACC] ?: 0L else 0L
|
||||
var wifiAcc = if (sameDay) prefs[Keys.TRAFFIC_WIFI_ACC] ?: 0L else 0L
|
||||
val lastMobile = prefs[Keys.TRAFFIC_LAST_MOBILE]
|
||||
val lastOther = prefs[Keys.TRAFFIC_LAST_OTHER]
|
||||
if (lastMobile != null && lastOther != null) {
|
||||
mobileAcc += if (mobileBytes >= lastMobile) mobileBytes - lastMobile else mobileBytes
|
||||
wifiAcc += if (otherBytes >= lastOther) otherBytes - lastOther else otherBytes
|
||||
}
|
||||
prefs[Keys.TRAFFIC_DAY] = today
|
||||
prefs[Keys.TRAFFIC_MOBILE_ACC] = mobileAcc
|
||||
prefs[Keys.TRAFFIC_WIFI_ACC] = wifiAcc
|
||||
prefs[Keys.TRAFFIC_LAST_MOBILE] = mobileBytes
|
||||
prefs[Keys.TRAFFIC_LAST_OTHER] = otherBytes
|
||||
result = TrafficDay(today, mobileAcc, wifiAcc)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package com.jttools.datacontrol.remote
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.Location
|
||||
import android.location.LocationListener
|
||||
import android.location.LocationManager
|
||||
import android.net.TrafficStats
|
||||
import android.os.BatteryManager
|
||||
import android.os.Looper
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.jttools.datacontrol.BuildConfig
|
||||
import com.jttools.datacontrol.admin.AdminController
|
||||
import com.jttools.datacontrol.data.SettingsRepository
|
||||
import com.jttools.datacontrol.service.FirewallVpnService
|
||||
import com.jttools.datacontrol.service.MonitorStateHolder
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.LocalDate
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Envoie à FamilyControl (via [RemoteSync]) l'état de l'appareil : batterie, position,
|
||||
* état du pare-feu, historique de trafic. Ne fait rien tant que l'appareil n'est pas associé.
|
||||
*/
|
||||
@Singleton
|
||||
class DeviceReporter @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val remote: RemoteSync,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val stateHolder: MonitorStateHolder
|
||||
) {
|
||||
private var started = false
|
||||
private var lastBatterySent = -1 to false
|
||||
private var lastBatterySentAt = 0L
|
||||
private var lastLocationSentAt = 0L
|
||||
private var lastStateSignature = ""
|
||||
private var lastStateSentAt = 0L
|
||||
private var locationRegistered = false
|
||||
|
||||
fun start(scope: CoroutineScope) {
|
||||
if (started || !remote.available) return
|
||||
started = true
|
||||
remote.start(scope)
|
||||
|
||||
val batteryReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(c: Context?, intent: Intent?) {
|
||||
if (intent != null) scope.launch { pushBattery(intent) }
|
||||
}
|
||||
}
|
||||
// Récepteur enregistré dynamiquement : ACTION_BATTERY_CHANGED n'est pas livrable via le manifeste.
|
||||
ContextCompat.registerReceiver(
|
||||
context, batteryReceiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED), ContextCompat.RECEIVER_NOT_EXPORTED
|
||||
)
|
||||
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
val s = settingsRepository.settingsFlow.first()
|
||||
if (s.isPaired) {
|
||||
registerLocationUpdates(scope)
|
||||
pushState()
|
||||
pushTraffic()
|
||||
}
|
||||
delay(REPORT_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pushBattery(intent: Intent) {
|
||||
if (!settingsRepository.settingsFlow.first().isPaired) return
|
||||
val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
|
||||
val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, 100)
|
||||
if (level < 0 || scale <= 0) return
|
||||
val percent = level * 100 / scale
|
||||
val status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1)
|
||||
val charging = status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL
|
||||
val now = System.currentTimeMillis()
|
||||
val changed = (percent to charging) != lastBatterySent
|
||||
if (!changed || now - lastBatterySentAt < BATTERY_MIN_INTERVAL_MS && percent > LOW_BATTERY) return
|
||||
lastBatterySent = percent to charging
|
||||
lastBatterySentAt = now
|
||||
remote.pushDevice(mapOf("battery" to mapOf("level" to percent, "charging" to charging, "at" to now)))
|
||||
}
|
||||
|
||||
private suspend fun pushState() {
|
||||
val s = settingsRepository.settingsFlow.first()
|
||||
val snap = stateHolder.snapshot.value
|
||||
val state = mapOf(
|
||||
"controlEnabled" to s.controlEnabled,
|
||||
"transport" to snap.transport.name,
|
||||
"firewallFault" to snap.firewallFault,
|
||||
"vpnPrepared" to FirewallVpnService.isPrepared(context),
|
||||
"tunnelActive" to FirewallVpnService.tunnelActive,
|
||||
"adminActive" to AdminController.isActive(context),
|
||||
"consumedMo" to snap.consumedMo,
|
||||
"planLimitMo" to s.planLimitMo,
|
||||
"cutoffIntervalMinutes" to s.cutoffIntervalMinutes,
|
||||
"wifiFilterEnabled" to s.wifiFilterEnabled,
|
||||
"whitelistedPackages" to s.whitelistedPackages.sorted(),
|
||||
"whitelistedPackagesWifi" to s.whitelistedPackagesWifi.sorted(),
|
||||
"appliedConfigAt" to s.remoteConfigVersion,
|
||||
"appVersion" to BuildConfig.VERSION_NAME
|
||||
)
|
||||
val signature = state.toString()
|
||||
val now = System.currentTimeMillis()
|
||||
// Évite les écritures inutiles ; un battement toutes les 5 min indique que l'appareil est vivant.
|
||||
if (signature == lastStateSignature && now - lastStateSentAt < HEARTBEAT_MS) return
|
||||
lastStateSignature = signature
|
||||
lastStateSentAt = now
|
||||
remote.pushDevice(mapOf("state" to (state + ("at" to now))))
|
||||
}
|
||||
|
||||
private suspend fun pushTraffic() {
|
||||
val mobile = TrafficStats.getMobileRxBytes() + TrafficStats.getMobileTxBytes()
|
||||
val total = TrafficStats.getTotalRxBytes() + TrafficStats.getTotalTxBytes()
|
||||
if (mobile < 0 || total < 0) return
|
||||
val day = settingsRepository.accumulateTraffic(LocalDate.now().toString(), mobile, (total - mobile).coerceAtLeast(0L))
|
||||
remote.pushTraffic(day)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun registerLocationUpdates(scope: CoroutineScope) {
|
||||
if (locationRegistered) return
|
||||
val fine = ContextCompat.checkSelfPermission(context, android.Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
val coarse = ContextCompat.checkSelfPermission(context, android.Manifest.permission.ACCESS_COARSE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (!fine && !coarse) return
|
||||
val lm = context.getSystemService(LocationManager::class.java)
|
||||
val listener = LocationListener { loc -> scope.launch { pushLocation(loc) } }
|
||||
var registered = false
|
||||
for (provider in listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER)) {
|
||||
if (provider == LocationManager.GPS_PROVIDER && !fine) continue
|
||||
if (!lm.isProviderEnabled(provider)) continue
|
||||
runCatching {
|
||||
lm.requestLocationUpdates(provider, LOCATION_INTERVAL_MS, LOCATION_MIN_DISTANCE_M, listener, Looper.getMainLooper())
|
||||
registered = true
|
||||
}
|
||||
}
|
||||
locationRegistered = registered
|
||||
}
|
||||
|
||||
private suspend fun pushLocation(loc: Location) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastLocationSentAt < LOCATION_MIN_SEND_MS) return
|
||||
lastLocationSentAt = now
|
||||
remote.pushDevice(
|
||||
mapOf(
|
||||
"location" to mapOf(
|
||||
"lat" to loc.latitude, "lng" to loc.longitude,
|
||||
"accuracy" to loc.accuracy.toDouble(), "at" to now
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val REPORT_INTERVAL_MS = 60_000L
|
||||
const val HEARTBEAT_MS = 5 * 60_000L
|
||||
const val BATTERY_MIN_INTERVAL_MS = 2 * 60_000L
|
||||
const val LOW_BATTERY = 20
|
||||
const val LOCATION_INTERVAL_MS = 5 * 60_000L
|
||||
const val LOCATION_MIN_DISTANCE_M = 50f
|
||||
const val LOCATION_MIN_SEND_MS = 60_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.jttools.datacontrol.remote
|
||||
|
||||
import android.content.Context
|
||||
import com.google.firebase.FirebaseApp
|
||||
import com.google.firebase.FirebaseOptions
|
||||
import com.google.firebase.auth.FirebaseAuth
|
||||
import com.google.firebase.firestore.FirebaseFirestore
|
||||
import com.jttools.datacontrol.BuildConfig
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.tasks.await
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Accès à Firebase (Auth anonyme + Firestore) initialisé depuis `firebase.properties`
|
||||
* (voir build.gradle.kts), sans plugin google-services : si la config est absente,
|
||||
* [enabled] est faux et toutes les fonctions distantes restent inertes.
|
||||
*/
|
||||
@Singleton
|
||||
class FirebaseGateway @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
val enabled: Boolean = BuildConfig.FIREBASE_API_KEY.isNotBlank() &&
|
||||
BuildConfig.FIREBASE_APP_ID.isNotBlank() &&
|
||||
BuildConfig.FIREBASE_PROJECT_ID.isNotBlank()
|
||||
|
||||
private val app: FirebaseApp? by lazy {
|
||||
if (!enabled) return@lazy null
|
||||
runCatching {
|
||||
FirebaseApp.getApps(context).firstOrNull()
|
||||
?: FirebaseApp.initializeApp(
|
||||
context,
|
||||
FirebaseOptions.Builder()
|
||||
.setApiKey(BuildConfig.FIREBASE_API_KEY)
|
||||
.setApplicationId(BuildConfig.FIREBASE_APP_ID)
|
||||
.setProjectId(BuildConfig.FIREBASE_PROJECT_ID)
|
||||
.build()
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
val db: FirebaseFirestore? get() = app?.let { FirebaseFirestore.getInstance(it) }
|
||||
private val auth: FirebaseAuth? get() = app?.let { FirebaseAuth.getInstance(it) }
|
||||
|
||||
/** Identifiant de l'appareil côté Firebase (connexion anonyme, persistante). Null si indisponible. */
|
||||
suspend fun ensureSignedIn(): String? {
|
||||
val a = auth ?: return null
|
||||
a.currentUser?.let { return it.uid }
|
||||
return runCatching { a.signInAnonymously().await().user?.uid }.getOrNull()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package com.jttools.datacontrol.remote
|
||||
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.google.firebase.firestore.DocumentReference
|
||||
import com.google.firebase.firestore.SetOptions
|
||||
import com.jttools.datacontrol.data.AppSettings
|
||||
import com.jttools.datacontrol.data.InstalledAppsRepository
|
||||
import com.jttools.datacontrol.data.SettingsRepository
|
||||
import com.jttools.datacontrol.data.TrafficDay
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.tasks.await
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Liaison avec FamilyControl via Firestore.
|
||||
*
|
||||
* Modèle (voir FamilyControl/firebase/firestore.rules) :
|
||||
* - pairings/{code} : créé par le parent, lu une fois par l'enfant
|
||||
* - families/{fid}/devices/{did} : état, batterie, position (écrit par l'appareil)
|
||||
* - families/{fid}/devices/{did}/config/remote : consignes du parent (lues par l'appareil)
|
||||
* - families/{fid}/devices/{did}/traffic/{yyyy-MM-dd}: historique de trafic
|
||||
* - families/{fid}/devices/{did}/events/{auto} : journal d'évènements
|
||||
*
|
||||
* L'appareil ne s'auto-bloque jamais : l'app DataControl est toujours exclue du tunnel VPN
|
||||
* (cf. FirewallVpnService), donc ces échanges passent même quand le réseau est coupé.
|
||||
*/
|
||||
@Singleton
|
||||
class RemoteSync @Inject constructor(
|
||||
private val gateway: FirebaseGateway,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val installedApps: InstalledAppsRepository
|
||||
) {
|
||||
val available: Boolean get() = gateway.enabled
|
||||
|
||||
private fun deviceDoc(s: AppSettings): DocumentReference? {
|
||||
val db = gateway.db ?: return null
|
||||
val fid = s.familyId ?: return null
|
||||
val did = s.deviceId ?: return null
|
||||
return db.collection("families").document(fid).collection("devices").document(did)
|
||||
}
|
||||
|
||||
/** Associe cet appareil à la famille correspondant au [code] affiché dans FamilyControl. */
|
||||
suspend fun pair(code: String, deviceName: String): Result<Unit> = runCatching {
|
||||
check(gateway.enabled) { "Firebase non configuré dans cette version" }
|
||||
val db = gateway.db!!
|
||||
val uid = gateway.ensureSignedIn() ?: error("Connexion Firebase impossible (réseau ?)")
|
||||
val clean = code.trim().uppercase()
|
||||
val pairing = db.collection("pairings").document(clean).get().await()
|
||||
check(pairing.exists()) { "Code inconnu ou expiré" }
|
||||
val familyId = pairing.getString("familyId") ?: error("Code invalide")
|
||||
check((pairing.getLong("expiresAt") ?: 0L) > System.currentTimeMillis()) { "Code expiré" }
|
||||
val deviceId = UUID.randomUUID().toString()
|
||||
db.collection("families").document(familyId).collection("devices").document(deviceId).set(
|
||||
mapOf(
|
||||
"deviceUid" to uid,
|
||||
"name" to deviceName.ifBlank { Build.MODEL },
|
||||
"model" to "${Build.MANUFACTURER} ${Build.MODEL}",
|
||||
"pairingCode" to clean,
|
||||
"pairedAt" to System.currentTimeMillis()
|
||||
)
|
||||
).await()
|
||||
settingsRepository.setPairing(familyId, deviceId)
|
||||
}
|
||||
|
||||
suspend fun unpair() {
|
||||
val s = settingsRepository.settingsFlow.first()
|
||||
pushEvent("unpaired", "Appareil dissocié depuis DataControl")
|
||||
deviceDoc(s)?.let { runCatching { it.update("unpairedAt", System.currentTimeMillis()) } }
|
||||
settingsRepository.setPairing(null, null)
|
||||
}
|
||||
|
||||
/** Écoute les consignes du parent et les applique aux réglages locaux. */
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun start(scope: CoroutineScope) {
|
||||
if (!gateway.enabled) return
|
||||
scope.launch {
|
||||
settingsRepository.settingsFlow
|
||||
.map { it.familyId to it.deviceId }
|
||||
.distinctUntilChanged()
|
||||
.flatMapLatest { (fid, did) ->
|
||||
if (fid == null || did == null) flowOf(null) else configFlow(fid, did)
|
||||
}
|
||||
.collect { cfg -> if (cfg != null) apply(cfg) }
|
||||
}
|
||||
scope.launch {
|
||||
// Liste des applications installées, pour que le parent puisse choisir lesquelles autoriser.
|
||||
settingsRepository.settingsFlow.map { it.isPaired }.distinctUntilChanged().collect { paired ->
|
||||
if (paired) pushApps()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class Config(val map: Map<String, Any?>)
|
||||
|
||||
private fun configFlow(fid: String, did: String) = callbackFlow<Config?> {
|
||||
val db = gateway.db
|
||||
if (db == null || gateway.ensureSignedIn() == null) { trySend(null); close(); return@callbackFlow }
|
||||
val reg = db.collection("families").document(fid).collection("devices").document(did)
|
||||
.collection("config").document("remote")
|
||||
.addSnapshotListener { snap, err ->
|
||||
if (err != null) { Log.w(TAG, "config listener", err); return@addSnapshotListener }
|
||||
if (snap != null && snap.exists()) trySend(Config(snap.data.orEmpty()))
|
||||
}
|
||||
awaitClose { reg.remove() }
|
||||
}
|
||||
|
||||
private suspend fun apply(cfg: Config) {
|
||||
val m = cfg.map
|
||||
val version = (m["updatedAt"] as? Number)?.toLong() ?: return
|
||||
fun strings(key: String) = (m[key] as? List<*>)?.filterIsInstance<String>()?.toSet()
|
||||
settingsRepository.applyRemoteConfig(
|
||||
version = version,
|
||||
controlEnabled = m["controlEnabled"] as? Boolean,
|
||||
whitelistedPackages = strings("whitelistedPackages"),
|
||||
whitelistedPackagesWifi = strings("whitelistedPackagesWifi"),
|
||||
wifiFilterEnabled = m["wifiFilterEnabled"] as? Boolean,
|
||||
planLimitMo = (m["planLimitMo"] as? Number)?.toLong(),
|
||||
cutoffIntervalMinutes = (m["cutoffIntervalMinutes"] as? Number)?.toInt()
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun pushApps() {
|
||||
val s = settingsRepository.settingsFlow.first()
|
||||
val ref = deviceDoc(s) ?: return
|
||||
val apps = installedApps.listInstalledApps(withIcons = false).map {
|
||||
mapOf("pkg" to it.packageName, "label" to it.label, "system" to it.isSystemApp)
|
||||
}
|
||||
write(ref.collection("meta").document("apps"), mapOf("apps" to apps, "at" to System.currentTimeMillis()))
|
||||
}
|
||||
|
||||
/** Fusionne [data] dans le document de l'appareil (état, batterie, position…). */
|
||||
suspend fun pushDevice(data: Map<String, Any?>) {
|
||||
val s = settingsRepository.settingsFlow.first()
|
||||
val ref = deviceDoc(s) ?: return
|
||||
write(ref, data + ("lastSeen" to System.currentTimeMillis()))
|
||||
}
|
||||
|
||||
suspend fun pushTraffic(day: TrafficDay) {
|
||||
val s = settingsRepository.settingsFlow.first()
|
||||
val ref = deviceDoc(s) ?: return
|
||||
write(
|
||||
ref.collection("traffic").document(day.date),
|
||||
mapOf("date" to day.date, "mobileBytes" to day.mobileBytes, "wifiBytes" to day.wifiBytes,
|
||||
"at" to System.currentTimeMillis())
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun pushEvent(type: String, detail: String) {
|
||||
val s = settingsRepository.settingsFlow.first()
|
||||
val ref = deviceDoc(s) ?: return
|
||||
runCatching {
|
||||
ref.collection("events").add(
|
||||
mapOf("type" to type, "detail" to detail, "at" to System.currentTimeMillis())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun write(ref: DocumentReference, data: Map<String, Any?>) {
|
||||
if (gateway.ensureSignedIn() == null) return
|
||||
// Les écritures sont mises en file par le SDK hors-ligne : pas d'attente bloquante du réseau.
|
||||
ref.set(data, SetOptions.merge())
|
||||
}
|
||||
|
||||
companion object { private const val TAG = "RemoteSync" }
|
||||
}
|
||||
@@ -34,13 +34,34 @@ class ConnectivityObserver @Inject constructor(
|
||||
else -> NetworkTransport.OTHER
|
||||
}
|
||||
|
||||
// Réseaux PHYSIQUES connectés. Le tunnel VPN du pare-feu est lui-même un réseau : s'il était
|
||||
// pris en compte, son apparition (transport VPN ou hérité) ferait croire à un changement de
|
||||
// réseau, le pare-feu s'arrêterait aussitôt et le VPN ne resterait jamais monté.
|
||||
val physical = mutableMapOf<Network, NetworkTransport>()
|
||||
|
||||
fun publish() {
|
||||
val values = synchronized(physical) { physical.values.toList() }
|
||||
// Android privilégie le Wi-Fi quand les deux sont connectés.
|
||||
trySend(
|
||||
when {
|
||||
NetworkTransport.WIFI in values -> NetworkTransport.WIFI
|
||||
NetworkTransport.CELLULAR in values -> NetworkTransport.CELLULAR
|
||||
values.isNotEmpty() -> NetworkTransport.OTHER
|
||||
else -> NetworkTransport.NONE
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
|
||||
trySend(transportOf(caps))
|
||||
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) return
|
||||
synchronized(physical) { physical[network] = transportOf(caps) }
|
||||
publish()
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
trySend(NetworkTransport.NONE)
|
||||
val removed = synchronized(physical) { physical.remove(network) != null }
|
||||
if (removed) publish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +69,6 @@ class ConnectivityObserver @Inject constructor(
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build()
|
||||
|
||||
trySend(transportOf(cm.getNetworkCapabilities(cm.activeNetwork)))
|
||||
cm.registerNetworkCallback(request, callback)
|
||||
|
||||
awaitClose { cm.unregisterNetworkCallback(callback) }
|
||||
|
||||
@@ -20,6 +20,8 @@ import com.jttools.datacontrol.data.AppSettings
|
||||
import com.jttools.datacontrol.data.DataState
|
||||
import com.jttools.datacontrol.data.SettingsRepository
|
||||
import com.jttools.datacontrol.data.UsageRepository
|
||||
import com.jttools.datacontrol.remote.DeviceReporter
|
||||
import com.jttools.datacontrol.remote.RemoteSync
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -45,6 +47,8 @@ class DataMonitorService : LifecycleService() {
|
||||
@Inject lateinit var usageRepository: UsageRepository
|
||||
@Inject lateinit var connectivityObserver: ConnectivityObserver
|
||||
@Inject lateinit var stateHolder: MonitorStateHolder
|
||||
@Inject lateinit var deviceReporter: DeviceReporter
|
||||
@Inject lateinit var remoteSync: RemoteSync
|
||||
|
||||
private var latestSettings: AppSettings = AppSettings()
|
||||
private var latestTransport: NetworkTransport = NetworkTransport.NONE
|
||||
@@ -52,16 +56,21 @@ class DataMonitorService : LifecycleService() {
|
||||
|
||||
private var lastAppliedMode: VpnMode? = null
|
||||
private var lastAppliedWhitelist: Set<String> = emptySet()
|
||||
private var lastApplyMillis = 0L
|
||||
|
||||
private var cutoffCycleAnchorMillis: Long? = null
|
||||
private var inPulse = false
|
||||
private var pulseStartMillis: Long = 0L
|
||||
private var settingsLoaded = false
|
||||
private var lastControlEnabled: Boolean? = null
|
||||
|
||||
private val vpnRevokedReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
vpnRevoked = true
|
||||
lifecycleScope.launch { settingsRepository.setControlEnabled(false) }
|
||||
lifecycleScope.launch {
|
||||
settingsRepository.setControlEnabled(false)
|
||||
remoteSync.pushEvent("vpn_revoked", "Autorisation VPN retirée : le filtrage est désactivé")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,10 +103,20 @@ class DataMonitorService : LifecycleService() {
|
||||
settingsLoaded = true
|
||||
}
|
||||
latestSettings = it
|
||||
if (lastControlEnabled != null && lastControlEnabled != it.controlEnabled && it.isPaired) {
|
||||
launch {
|
||||
remoteSync.pushEvent(
|
||||
"control_changed",
|
||||
if (it.controlEnabled) "Contrôle activé" else "Contrôle désactivé"
|
||||
)
|
||||
}
|
||||
}
|
||||
lastControlEnabled = it.controlEnabled
|
||||
if (!it.controlEnabled) vpnRevoked = false
|
||||
tick()
|
||||
}
|
||||
}
|
||||
deviceReporter.start(lifecycleScope)
|
||||
lifecycleScope.launch {
|
||||
connectivityObserver.observe().collect {
|
||||
latestTransport = it
|
||||
@@ -137,7 +156,9 @@ class DataMonitorService : LifecycleService() {
|
||||
estimatedTotalPriceEuro = estimatedTotal,
|
||||
dataState = if (percent >= WARNING_THRESHOLD) DataState.WARNING else DataState.OK,
|
||||
nextAutoResetDate = settings.nextAutoResetDate(),
|
||||
serviceRunning = false
|
||||
serviceRunning = false,
|
||||
firewallFault = false,
|
||||
transport = latestTransport
|
||||
)
|
||||
}
|
||||
updateNotification(stateHolder.snapshot.value)
|
||||
@@ -148,6 +169,7 @@ class DataMonitorService : LifecycleService() {
|
||||
|
||||
val dataState: DataState
|
||||
var nextCutoffAt: Long? = null
|
||||
var firewallFault = false
|
||||
|
||||
when {
|
||||
latestTransport == NetworkTransport.WIFI -> {
|
||||
@@ -188,12 +210,16 @@ class DataMonitorService : LifecycleService() {
|
||||
applyVpnMode(VpnMode.FILTER, settings.whitelistedPackages)
|
||||
}
|
||||
nextCutoffAt = if (inPulse) pulseStartMillis + PULSE_DURATION_MS else anchor + intervalMs
|
||||
firewallFault = !FirewallVpnService.tunnelActive &&
|
||||
System.currentTimeMillis() - lastApplyMillis > TUNNEL_RETRY_DELAY_MS
|
||||
dataState = if (inPulse) DataState.CUT else if (percent >= WARNING_THRESHOLD) DataState.WARNING else DataState.OK
|
||||
}
|
||||
else -> {
|
||||
// Pas de réseau, transport inconnu, ou VPN non préparé/révoqué : rien à filtrer.
|
||||
applyVpnMode(VpnMode.STOPPED, emptySet())
|
||||
resetCutoffCycle()
|
||||
// Sur données mobiles sans autorisation VPN valide, rien n'est filtré : à signaler.
|
||||
firewallFault = latestTransport == NetworkTransport.CELLULAR
|
||||
dataState = if (percent >= WARNING_THRESHOLD) DataState.WARNING else DataState.OK
|
||||
}
|
||||
}
|
||||
@@ -207,7 +233,9 @@ class DataMonitorService : LifecycleService() {
|
||||
dataState = dataState,
|
||||
nextAutoResetDate = settings.nextAutoResetDate(),
|
||||
nextCutoffAtMillis = nextCutoffAt,
|
||||
serviceRunning = true
|
||||
serviceRunning = true,
|
||||
firewallFault = firewallFault,
|
||||
transport = latestTransport
|
||||
)
|
||||
}
|
||||
updateNotification(stateHolder.snapshot.value)
|
||||
@@ -236,9 +264,14 @@ class DataMonitorService : LifecycleService() {
|
||||
}
|
||||
|
||||
private fun applyVpnMode(mode: VpnMode, whitelist: Set<String>) {
|
||||
if (mode == lastAppliedMode && (mode != VpnMode.FILTER || whitelist == lastAppliedWhitelist)) return
|
||||
// Si le tunnel a disparu (service VPN tué/relancé, établissement échoué), on le remonte au
|
||||
// tick suivant au lieu de croire qu'il est toujours actif.
|
||||
val tunnelLost = mode != VpnMode.STOPPED && !FirewallVpnService.tunnelActive &&
|
||||
System.currentTimeMillis() - lastApplyMillis > TUNNEL_RETRY_DELAY_MS
|
||||
if (!tunnelLost && mode == lastAppliedMode && (mode != VpnMode.FILTER || whitelist == lastAppliedWhitelist)) return
|
||||
lastAppliedMode = mode
|
||||
lastAppliedWhitelist = whitelist
|
||||
lastApplyMillis = System.currentTimeMillis()
|
||||
when (mode) {
|
||||
VpnMode.STOPPED -> ContextCompat.startForegroundService(this, FirewallVpnService.stopIntent(this))
|
||||
VpnMode.FILTER -> ContextCompat.startForegroundService(
|
||||
@@ -262,6 +295,7 @@ class DataMonitorService : LifecycleService() {
|
||||
}
|
||||
|
||||
val (emoji, stateLabel, color) = when {
|
||||
snapshot.firewallFault -> Triple("⚠️", getString(R.string.dashboard_state_warning), Color.parseColor("#C62828"))
|
||||
!snapshot.serviceRunning -> Triple("⚪", getString(R.string.dashboard_control_disabled), Color.parseColor("#757575"))
|
||||
snapshot.dataState == DataState.OK -> Triple("🟢", getString(R.string.dashboard_state_ok), Color.parseColor("#2E7D32"))
|
||||
snapshot.dataState == DataState.WARNING -> Triple("🟠", getString(R.string.dashboard_state_warning), Color.parseColor("#EF6C00"))
|
||||
@@ -274,7 +308,8 @@ class DataMonitorService : LifecycleService() {
|
||||
} else "—"
|
||||
val priceText = String.format("%.2f", snapshot.estimatedTotalPriceEuro)
|
||||
|
||||
val usageText = "$emoji $stateLabel · ${snapshot.consumedMo}/${snapshot.planLimitMo} Mo ($percentText)"
|
||||
val warn = if (snapshot.firewallFault) "⚠️ PARE-FEU INACTIF — données non filtrées · " else ""
|
||||
val usageText = "$warn$emoji $stateLabel · ${snapshot.consumedMo}/${snapshot.planLimitMo} Mo ($percentText)"
|
||||
val costText = "${getString(R.string.dashboard_cost_total)} : $priceText €"
|
||||
// Le total est aussi dans la vue repliée : sinon il n'est visible qu'une fois la notification dépliée.
|
||||
val contentText = "$usageText · $priceText €"
|
||||
@@ -313,6 +348,7 @@ class DataMonitorService : LifecycleService() {
|
||||
private const val NOTIF_ID = 1001
|
||||
private const val TICK_INTERVAL_MS = 20_000L
|
||||
private const val PULSE_DURATION_MS = 30_000L
|
||||
private const val TUNNEL_RETRY_DELAY_MS = 5_000L
|
||||
private const val WARNING_THRESHOLD = 0.8f
|
||||
|
||||
fun start(context: Context) {
|
||||
|
||||
@@ -59,6 +59,9 @@ class FirewallVpnService : VpnService() {
|
||||
.setSession("DataControl")
|
||||
.addAddress(TUNNEL_ADDRESS, 32)
|
||||
.addRoute("0.0.0.0", 0)
|
||||
// Sans route IPv6, le trafic IPv6 des applications bloquées contournerait le pare-feu.
|
||||
.addAddress(TUNNEL_ADDRESS_V6, 128)
|
||||
.addRoute("::", 0)
|
||||
.setBlocking(true)
|
||||
|
||||
// Notre propre app doit toujours pouvoir communiquer normalement.
|
||||
@@ -72,6 +75,7 @@ class FirewallVpnService : VpnService() {
|
||||
|
||||
val fd = tunInterface ?: return
|
||||
running = true
|
||||
tunnelActive = true
|
||||
readerThread = Thread({ blackhole(fd) }, "datacontrol-vpn-blackhole").apply {
|
||||
isDaemon = true
|
||||
start()
|
||||
@@ -102,6 +106,7 @@ class FirewallVpnService : VpnService() {
|
||||
|
||||
private fun stopTunnel(keepForeground: Boolean = false) {
|
||||
running = false
|
||||
tunnelActive = false
|
||||
readerThread?.interrupt()
|
||||
readerThread = null
|
||||
tunInterface?.let { runCatching { it.close() } }
|
||||
@@ -150,6 +155,11 @@ class FirewallVpnService : VpnService() {
|
||||
private const val CHANNEL_ID = "datacontrol_vpn"
|
||||
private const val NOTIF_ID = 2001
|
||||
private const val TUNNEL_ADDRESS = "10.10.10.1"
|
||||
private const val TUNNEL_ADDRESS_V6 = "fd00:10:10::1"
|
||||
|
||||
/** Vrai tant que le tunnel est réellement établi (faux si le service a été tué/relancé sans intent). */
|
||||
@Volatile var tunnelActive = false
|
||||
private set
|
||||
|
||||
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"
|
||||
|
||||
@@ -15,7 +15,10 @@ data class MonitorSnapshot(
|
||||
val dataState: DataState = DataState.OK,
|
||||
val nextAutoResetDate: LocalDate = LocalDate.now(),
|
||||
val nextCutoffAtMillis: Long? = null,
|
||||
val serviceRunning: Boolean = false
|
||||
val serviceRunning: Boolean = false,
|
||||
/** Contrôle activé sur données mobiles mais tunnel VPN non monté : les données ne sont PAS filtrées. */
|
||||
val firewallFault: Boolean = false,
|
||||
val transport: NetworkTransport = NetworkTransport.NONE
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,6 +34,54 @@ class SystemActions(private val activity: ComponentActivity) {
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted -> onNotificationResult?.invoke(granted) }
|
||||
|
||||
private var onLocationResult: ((Boolean) -> Unit)? = null
|
||||
private var onBackgroundLocationResult: ((Boolean) -> Unit)? = null
|
||||
private var onAdminResult: ((Boolean) -> Unit)? = null
|
||||
|
||||
private val locationLauncher: ActivityResultLauncher<Array<String>> = activity.registerForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { granted -> onLocationResult?.invoke(granted.values.any { it }) }
|
||||
|
||||
private val backgroundLocationLauncher: ActivityResultLauncher<String> = activity.registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted -> onBackgroundLocationResult?.invoke(granted) }
|
||||
|
||||
private val adminLauncher: ActivityResultLauncher<Intent> = activity.registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result -> onAdminResult?.invoke(result.resultCode == android.app.Activity.RESULT_OK) }
|
||||
|
||||
fun hasLocationPermission(): Boolean = ContextCompat.checkSelfPermission(
|
||||
activity, android.Manifest.permission.ACCESS_FINE_LOCATION
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
|
||||
fun hasBackgroundLocationPermission(): Boolean =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || ContextCompat.checkSelfPermission(
|
||||
activity, android.Manifest.permission.ACCESS_BACKGROUND_LOCATION
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
|
||||
fun requestLocationPermission(onResult: (Boolean) -> Unit) {
|
||||
onLocationResult = onResult
|
||||
locationLauncher.launch(
|
||||
arrayOf(android.Manifest.permission.ACCESS_FINE_LOCATION, android.Manifest.permission.ACCESS_COARSE_LOCATION)
|
||||
)
|
||||
}
|
||||
|
||||
/** À demander APRÈS la position au premier plan (exigence Android 11+). */
|
||||
fun requestBackgroundLocationPermission(onResult: (Boolean) -> Unit) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { onResult(true); return }
|
||||
onBackgroundLocationResult = onResult
|
||||
backgroundLocationLauncher.launch(android.Manifest.permission.ACCESS_BACKGROUND_LOCATION)
|
||||
}
|
||||
|
||||
fun isAdminActive(): Boolean = com.jttools.datacontrol.admin.AdminController.isActive(activity)
|
||||
|
||||
fun requestAdmin(onResult: (Boolean) -> Unit) {
|
||||
onAdminResult = onResult
|
||||
adminLauncher.launch(com.jttools.datacontrol.admin.AdminController.requestIntent(activity))
|
||||
}
|
||||
|
||||
fun deactivateAdmin() = com.jttools.datacontrol.admin.AdminController.deactivate(activity)
|
||||
|
||||
fun isVpnPrepared(): Boolean = FirewallVpnService.isPrepared(activity)
|
||||
|
||||
fun requestVpnPermission(onResult: (Boolean) -> Unit) {
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package com.jttools.datacontrol.ui.family
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.jttools.datacontrol.R
|
||||
import com.jttools.datacontrol.ui.SystemActions
|
||||
import com.jttools.datacontrol.ui.theme.StateOk
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Carte « Contrôle parental » des réglages : appairage FamilyControl, anti-désinstallation, position. */
|
||||
@Composable
|
||||
fun FamilySection(systemActions: SystemActions) {
|
||||
val vm: FamilyViewModel = hiltViewModel()
|
||||
val settings by vm.settings.collectAsStateWithLifecycle()
|
||||
val pairing by vm.pairing.collectAsStateWithLifecycle()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var adminActive by remember { mutableStateOf(systemActions.isAdminActive()) }
|
||||
var locationGranted by remember { mutableStateOf(systemActions.hasLocationPermission()) }
|
||||
var backgroundGranted by remember { mutableStateOf(systemActions.hasBackgroundLocationPermission()) }
|
||||
var code by remember { mutableStateOf("") }
|
||||
var deviceName by remember { mutableStateOf(android.os.Build.MODEL) }
|
||||
|
||||
// Action sensible en attente de confirmation par mot de passe : "admin" ou "unpair".
|
||||
var pendingAction by remember { mutableStateOf<String?>(null) }
|
||||
var pwd by remember { mutableStateOf("") }
|
||||
var pwdError by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
adminActive = systemActions.isAdminActive()
|
||||
locationGranted = systemActions.hasLocationPermission()
|
||||
backgroundGranted = systemActions.hasBackgroundLocationPermission()
|
||||
}
|
||||
|
||||
fun runProtected(action: String, block: () -> Unit) {
|
||||
if (settings.hasPassword) { pendingAction = action; pwd = ""; pwdError = false } else block()
|
||||
}
|
||||
|
||||
fun execute(action: String) {
|
||||
when (action) {
|
||||
"admin" -> { systemActions.deactivateAdmin(); adminActive = systemActions.isAdminActive() }
|
||||
"unpair" -> vm.unpair()
|
||||
}
|
||||
}
|
||||
|
||||
Card(shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(2.dp)) {
|
||||
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.family_title),
|
||||
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
HorizontalDivider()
|
||||
|
||||
// --- Appairage ---
|
||||
when {
|
||||
!vm.remoteAvailable -> Text(
|
||||
stringResource(R.string.family_unavailable),
|
||||
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
settings.isPaired -> {
|
||||
Text(stringResource(R.string.family_paired), color = StateOk, style = MaterialTheme.typography.bodyMedium)
|
||||
OutlinedButton(onClick = { runProtected("unpair") { execute("unpair") } }) {
|
||||
Text(stringResource(R.string.family_unpair))
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Text(stringResource(R.string.family_pair_help), style = MaterialTheme.typography.bodyMedium)
|
||||
OutlinedTextField(
|
||||
value = code, onValueChange = { code = it.uppercase().take(8) },
|
||||
label = { Text(stringResource(R.string.family_code)) },
|
||||
singleLine = true, modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = deviceName, onValueChange = { deviceName = it },
|
||||
label = { Text(stringResource(R.string.family_device_name)) },
|
||||
singleLine = true, modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
pairing.error?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Button(
|
||||
enabled = code.length >= 6 && !pairing.busy,
|
||||
onClick = { vm.pair(code, deviceName) }
|
||||
) { Text(stringResource(R.string.family_pair)) }
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// --- Anti-désinstallation ---
|
||||
Text(stringResource(R.string.family_admin_title), fontWeight = FontWeight.Medium)
|
||||
Text(
|
||||
stringResource(if (adminActive) R.string.family_admin_on else R.string.family_admin_off),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (adminActive) StateOk else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
if (adminActive) {
|
||||
OutlinedButton(onClick = { runProtected("admin") { execute("admin") } }) {
|
||||
Text(stringResource(R.string.family_admin_disable))
|
||||
}
|
||||
} else {
|
||||
Button(onClick = { systemActions.requestAdmin { adminActive = systemActions.isAdminActive() } }) {
|
||||
Text(stringResource(R.string.family_admin_enable))
|
||||
}
|
||||
}
|
||||
if (!settings.hasPassword) {
|
||||
Text(
|
||||
stringResource(R.string.family_admin_needs_password),
|
||||
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// --- Position ---
|
||||
Text(stringResource(R.string.family_location_title), fontWeight = FontWeight.Medium)
|
||||
Text(
|
||||
stringResource(R.string.family_location_desc),
|
||||
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
when {
|
||||
!locationGranted -> Button(onClick = {
|
||||
systemActions.requestLocationPermission { locationGranted = it }
|
||||
}) { Text(stringResource(R.string.family_location_grant)) }
|
||||
!backgroundGranted -> Button(onClick = {
|
||||
systemActions.requestBackgroundLocationPermission {
|
||||
backgroundGranted = systemActions.hasBackgroundLocationPermission()
|
||||
}
|
||||
}) { Text(stringResource(R.string.family_location_background)) }
|
||||
else -> Text(stringResource(R.string.settings_perm_granted), color = StateOk)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pendingAction?.let { action ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { pendingAction = null },
|
||||
title = { Text(stringResource(R.string.password_gate_title)) },
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = pwd, onValueChange = { pwd = it; pwdError = false },
|
||||
label = { Text(stringResource(R.string.password_field_label)) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
isError = pwdError, singleLine = true, modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
if (pwdError) Text(
|
||||
stringResource(R.string.password_incorrect),
|
||||
color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = {
|
||||
scope.launch {
|
||||
if (vm.verifyPassword(pwd)) { pendingAction = null; execute(action) } else pwdError = true
|
||||
}
|
||||
}) { Text(stringResource(R.string.common_confirm)) }
|
||||
},
|
||||
dismissButton = {
|
||||
OutlinedButton(onClick = { pendingAction = null }) { Text(stringResource(R.string.common_cancel)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.jttools.datacontrol.ui.family
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.jttools.datacontrol.data.AppSettings
|
||||
import com.jttools.datacontrol.data.SettingsRepository
|
||||
import com.jttools.datacontrol.remote.RemoteSync
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class PairingUiState(val busy: Boolean = false, val error: String? = null)
|
||||
|
||||
@HiltViewModel
|
||||
class FamilyViewModel @Inject constructor(
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val remoteSync: RemoteSync
|
||||
) : ViewModel() {
|
||||
|
||||
val settings = settingsRepository.settingsFlow.stateIn(
|
||||
viewModelScope, SharingStarted.WhileSubscribed(5_000), AppSettings()
|
||||
)
|
||||
|
||||
val remoteAvailable: Boolean get() = remoteSync.available
|
||||
|
||||
private val _pairing = MutableStateFlow(PairingUiState())
|
||||
val pairing: StateFlow<PairingUiState> = _pairing.asStateFlow()
|
||||
|
||||
fun pair(code: String, deviceName: String) {
|
||||
_pairing.value = PairingUiState(busy = true)
|
||||
viewModelScope.launch {
|
||||
val result = remoteSync.pair(code, deviceName)
|
||||
_pairing.value = PairingUiState(error = result.exceptionOrNull()?.message)
|
||||
}
|
||||
}
|
||||
|
||||
fun unpair() {
|
||||
viewModelScope.launch { remoteSync.unpair() }
|
||||
}
|
||||
|
||||
suspend fun verifyPassword(password: String): Boolean = settingsRepository.verifyPassword(password)
|
||||
}
|
||||
@@ -222,6 +222,8 @@ private fun SettingsContent(
|
||||
}
|
||||
}
|
||||
|
||||
com.jttools.datacontrol.ui.family.FamilySection(systemActions)
|
||||
|
||||
Card(shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(2.dp)) {
|
||||
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(stringResource(R.string.settings_section_security), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
|
||||
@@ -123,4 +123,23 @@
|
||||
<string name="unit_euro">€</string>
|
||||
<string name="common_cancel">Annuler</string>
|
||||
<string name="common_confirm">Confirmer</string>
|
||||
|
||||
<string name="family_title">Contrôle parental (FamilyControl)</string>
|
||||
<string name="family_unavailable">Liaison FamilyControl non configurée dans cette version (fichier firebase.properties absent à la compilation).</string>
|
||||
<string name="family_paired">Appareil associé à FamilyControl : le parent peut gérer les accès réseau, voir la batterie, la position et l\'historique de trafic.</string>
|
||||
<string name="family_unpair">Dissocier cet appareil</string>
|
||||
<string name="family_pair_help">Saisissez le code d\'appairage affiché dans FamilyControl pour associer cet appareil.</string>
|
||||
<string name="family_code">Code d\'appairage</string>
|
||||
<string name="family_device_name">Nom de l\'appareil</string>
|
||||
<string name="family_pair">Associer</string>
|
||||
<string name="family_admin_title">Protection anti-désinstallation</string>
|
||||
<string name="family_admin_on">Active : DataControl ne peut pas être désinstallé sans désactiver d\'abord cette protection (mot de passe demandé ici).</string>
|
||||
<string name="family_admin_off">Inactive : l\'application peut être désinstallée librement.</string>
|
||||
<string name="family_admin_enable">Activer la protection</string>
|
||||
<string name="family_admin_disable">Désactiver la protection</string>
|
||||
<string name="family_admin_needs_password">Définissez un mot de passe (section Sécurité) : sans lui, la protection peut être retirée librement.</string>
|
||||
<string name="family_location_title">Position de l\'appareil</string>
|
||||
<string name="family_location_desc">Partagée avec le parent via FamilyControl, y compris en arrière-plan (choisir « Toujours autoriser »).</string>
|
||||
<string name="family_location_grant">Autoriser la position</string>
|
||||
<string name="family_location_background">Autoriser en arrière-plan</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<device-admin>
|
||||
<uses-policies />
|
||||
</device-admin>
|
||||
@@ -0,0 +1,5 @@
|
||||
# Copier vers firebase.properties (ignoré par git) et renseigner les valeurs de l'app Android
|
||||
# déclarée dans la console Firebase (Paramètres du projet > Vos applications).
|
||||
apiKey=
|
||||
appId=
|
||||
projectId=
|
||||
@@ -16,6 +16,8 @@ hiltNavigationCompose = "1.2.0"
|
||||
datastore = "1.1.1"
|
||||
coroutines = "1.8.1"
|
||||
workManager = "2.9.1"
|
||||
firebaseBom = "33.1.2"
|
||||
coroutinesPlayServices = "1.8.1"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -42,6 +44,10 @@ androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navig
|
||||
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||
androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" }
|
||||
firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
|
||||
firebase-auth = { group = "com.google.firebase", name = "firebase-auth" }
|
||||
firebase-firestore = { group = "com.google.firebase", name = "firebase-firestore" }
|
||||
kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "coroutinesPlayServices" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
versionName=0.1.2
|
||||
versionCode=3
|
||||
versionName=0.1.3
|
||||
versionCode=4
|
||||
Reference in new issue
Block a user