first version of firewall for android
This commit is contained in:
+14
@@ -0,0 +1,14 @@
|
||||
.gradle/
|
||||
.kotlin/
|
||||
.idea/
|
||||
build/
|
||||
local.properties
|
||||
*.iml
|
||||
captures/
|
||||
.externalNativeBuild/
|
||||
.cxx/
|
||||
.project
|
||||
.classpath
|
||||
.settings/
|
||||
bin/
|
||||
.DS_Store
|
||||
@@ -0,0 +1,78 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.krrishg.kwall"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.krrishg.kwall"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
aidl = true
|
||||
compose = true
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.material.icons.core)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.androidx.biometric)
|
||||
implementation(libs.androidx.fragment)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.shizuku.api)
|
||||
implementation(libs.shizuku.provider)
|
||||
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission
|
||||
android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||
tools:ignore="QueryAllPackagesPermission" />
|
||||
<uses-permission
|
||||
android:name="android.permission.PACKAGE_USAGE_STATS"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||
<uses-permission android:name="moe.shizuku.manager.permission.API_V23" />
|
||||
|
||||
<queries>
|
||||
<package android:name="moe.shizuku.privileged.api" />
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:name=".KwallApplication"
|
||||
android:allowBackup="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Kwall">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="rikka.shizuku.ShizukuProvider"
|
||||
android:authorities="${applicationId}.shizuku"
|
||||
android:enabled="true"
|
||||
android:exported="true"
|
||||
android:multiprocess="false"
|
||||
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
|
||||
|
||||
<receiver
|
||||
android:name=".receivers.BootReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".receivers.FirewallNotificationReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".receivers.ScheduleAlarmReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".receivers.ScheduleMaintenanceReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.TIME_SET" />
|
||||
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||
<action android:name="android.intent.action.DATE_CHANGED" />
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
<action android:name="android.app.action.SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".receivers.PackageAddedReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.PACKAGE_ADDED" />
|
||||
<data android:scheme="package" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.krrishg.kwall.firewall;
|
||||
|
||||
interface IFirewallService {
|
||||
String probe() = 1;
|
||||
String setUidFirewallRule(int chain, int uid, int rule) = 2;
|
||||
String[] setUidFirewallRules(int chain, in int[] uids, in int[] rules) = 3;
|
||||
String setFirewallChainEnabled(int chain, boolean enabled) = 4;
|
||||
String replaceUidFirewallChain(int chain, in int[] uids) = 5;
|
||||
int getUidFirewallRule(int chain, int uid) = 6;
|
||||
int getFirewallChainEnabled(int chain) = 7;
|
||||
void destroy() = 16777114;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.krrishg.kwall
|
||||
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
|
||||
class AppChangeNotifier {
|
||||
|
||||
private val _events = MutableSharedFlow<Unit>(
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
|
||||
val events: SharedFlow<Unit> = _events.asSharedFlow()
|
||||
|
||||
fun notifyChange() {
|
||||
_events.tryEmit(Unit)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package com.krrishg.kwall
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.LauncherApps
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.UserHandle
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.krrishg.kwall.data.AppRepository
|
||||
import com.krrishg.kwall.data.PackageManagerAppSource
|
||||
import com.krrishg.kwall.data.ProfileAwareAppRepository
|
||||
import com.krrishg.kwall.data.WorkProfileAppSource
|
||||
import com.krrishg.kwall.firewall.AppKey
|
||||
import com.krrishg.kwall.firewall.AppUidResolver
|
||||
import com.krrishg.kwall.firewall.AutoBlockNewApps
|
||||
import com.krrishg.kwall.firewall.BinderFirewallClient
|
||||
import com.krrishg.kwall.firewall.DataStoreFirewallStateStore
|
||||
import com.krrishg.kwall.firewall.FirewallBackend
|
||||
import com.krrishg.kwall.firewall.FirewallController
|
||||
import com.krrishg.kwall.firewall.FirewallManager
|
||||
import com.krrishg.kwall.firewall.FirewallStateStore
|
||||
import com.krrishg.kwall.firewall.ShizukuFirewallBackend
|
||||
import com.krrishg.kwall.network.ConnectionInspector
|
||||
import com.krrishg.kwall.network.NetworkStatsUsageSource
|
||||
import com.krrishg.kwall.network.NetworkUsageSource
|
||||
import com.krrishg.kwall.network.ProcNetConnectionInspector
|
||||
import com.krrishg.kwall.notifications.FirewallNotifications
|
||||
import com.krrishg.kwall.notifications.FirewallStatusNotifier
|
||||
import com.krrishg.kwall.scheduler.AlarmManagerScheduleAlarms
|
||||
import com.krrishg.kwall.scheduler.DataStoreScheduleStore
|
||||
import com.krrishg.kwall.scheduler.ProfileScheduler
|
||||
import com.krrishg.kwall.scheduler.ScheduleAlarms
|
||||
import com.krrishg.kwall.scheduler.ScheduleStore
|
||||
import com.krrishg.kwall.settings.AppSettingsStore
|
||||
import com.krrishg.kwall.settings.DataStoreAppSettingsStore
|
||||
import com.krrishg.kwall.shizuku.ShizukuShellExecutor
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class AppContainer(
|
||||
context: Context,
|
||||
private val applicationScope: CoroutineScope,
|
||||
) {
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
private val launcherApps: LauncherApps? = appContext.getSystemService(LauncherApps::class.java)
|
||||
val appChangeNotifier = AppChangeNotifier()
|
||||
|
||||
val appRepository: AppRepository by lazy {
|
||||
ProfileAwareAppRepository(
|
||||
sources = listOf(
|
||||
PackageManagerAppSource(
|
||||
packageManager = appContext.packageManager,
|
||||
),
|
||||
WorkProfileAppSource(
|
||||
launcherApps = launcherApps,
|
||||
density = appContext.resources.displayMetrics.densityDpi,
|
||||
),
|
||||
),
|
||||
ownPackageName = appContext.packageName,
|
||||
)
|
||||
}
|
||||
|
||||
val firewallStateStore: FirewallStateStore by lazy {
|
||||
DataStoreFirewallStateStore(appContext)
|
||||
}
|
||||
|
||||
val settingsStore: AppSettingsStore by lazy {
|
||||
DataStoreAppSettingsStore(appContext)
|
||||
}
|
||||
|
||||
val firewallStatusNotifier: FirewallStatusNotifier by lazy {
|
||||
FirewallStatusNotifier(appContext, settingsStore, applicationScope)
|
||||
}
|
||||
|
||||
private val shellExecutor by lazy { ShizukuShellExecutor() }
|
||||
|
||||
val connectionInspector: ConnectionInspector by lazy {
|
||||
ProcNetConnectionInspector(shellExecutor)
|
||||
}
|
||||
|
||||
val networkUsageSource: NetworkUsageSource by lazy {
|
||||
NetworkStatsUsageSource(appContext)
|
||||
}
|
||||
|
||||
private val firewallBackend: FirewallBackend by lazy {
|
||||
ShizukuFirewallBackend(
|
||||
context = appContext,
|
||||
shell = shellExecutor,
|
||||
binder = BinderFirewallClient(appContext),
|
||||
scope = applicationScope,
|
||||
)
|
||||
}
|
||||
|
||||
val firewallManager: FirewallController by lazy {
|
||||
FirewallManager(
|
||||
store = firewallStateStore,
|
||||
backend = firewallBackend,
|
||||
uidResolver = AppUidResolver(appContext.packageManager, launcherApps),
|
||||
scope = applicationScope,
|
||||
)
|
||||
}
|
||||
|
||||
val scheduleStore: ScheduleStore by lazy {
|
||||
DataStoreScheduleStore(appContext)
|
||||
}
|
||||
|
||||
val scheduleAlarms: ScheduleAlarms by lazy {
|
||||
AlarmManagerScheduleAlarms(appContext)
|
||||
}
|
||||
|
||||
val profileScheduler: ProfileScheduler by lazy {
|
||||
ProfileScheduler(
|
||||
scheduleStore = scheduleStore,
|
||||
firewallController = firewallManager,
|
||||
alarms = scheduleAlarms,
|
||||
)
|
||||
}
|
||||
|
||||
val autoBlockNewApps: AutoBlockNewApps by lazy {
|
||||
AutoBlockNewApps(
|
||||
settingsStore = settingsStore,
|
||||
repository = appRepository,
|
||||
firewallController = firewallManager,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun autoBlockNewlyInstalled(packageName: String, userId: Int) {
|
||||
val app = runCatching {
|
||||
autoBlockNewApps.onPackageAdded(packageName, userId)
|
||||
}.getOrNull() ?: return
|
||||
FirewallNotifications.notifyAutoBlocked(appContext, app.label)
|
||||
}
|
||||
|
||||
private val launcherCallback = object : LauncherApps.Callback() {
|
||||
override fun onPackageRemoved(packageName: String, user: UserHandle) {
|
||||
appChangeNotifier.notifyChange()
|
||||
applicationScope.launch {
|
||||
runCatching {
|
||||
firewallManager.removeBlockedEverywhere(AppKey(user.userId(), packageName))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPackageAdded(packageName: String, user: UserHandle) {
|
||||
appChangeNotifier.notifyChange()
|
||||
applicationScope.launch {
|
||||
runCatching {
|
||||
autoBlockNewlyInstalled(packageName, user.userId())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPackageChanged(packageName: String, user: UserHandle) {
|
||||
appChangeNotifier.notifyChange()
|
||||
}
|
||||
|
||||
override fun onPackagesAvailable(
|
||||
packageNames: Array<String>,
|
||||
user: UserHandle,
|
||||
replacing: Boolean,
|
||||
) {
|
||||
appChangeNotifier.notifyChange()
|
||||
}
|
||||
|
||||
override fun onPackagesUnavailable(
|
||||
packageNames: Array<String>,
|
||||
user: UserHandle,
|
||||
replacing: Boolean,
|
||||
) {
|
||||
appChangeNotifier.notifyChange()
|
||||
}
|
||||
}
|
||||
|
||||
private val profileReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent?) {
|
||||
val action = intent?.action ?: return
|
||||
appChangeNotifier.notifyChange()
|
||||
if (action == Intent.ACTION_MANAGED_PROFILE_ADDED ||
|
||||
action == Intent.ACTION_MANAGED_PROFILE_UNLOCKED
|
||||
) {
|
||||
applicationScope.launch {
|
||||
runCatching { firewallManager.restore() }
|
||||
}
|
||||
return
|
||||
}
|
||||
if (action != Intent.ACTION_MANAGED_PROFILE_REMOVED &&
|
||||
action != Intent.ACTION_PROFILE_REMOVED
|
||||
) {
|
||||
return
|
||||
}
|
||||
val removedUser = intent.userHandleExtra() ?: return
|
||||
applicationScope.launch {
|
||||
runCatching {
|
||||
firewallManager.removeBlockedForUserEverywhere(removedUser.userId())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
runCatching {
|
||||
launcherApps?.registerCallback(launcherCallback, Handler(Looper.getMainLooper()))
|
||||
}
|
||||
val profileFilter = IntentFilter().apply {
|
||||
addAction(Intent.ACTION_MANAGED_PROFILE_ADDED)
|
||||
addAction(Intent.ACTION_MANAGED_PROFILE_REMOVED)
|
||||
addAction(Intent.ACTION_MANAGED_PROFILE_UNLOCKED)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
addAction(Intent.ACTION_PROFILE_ADDED)
|
||||
addAction(Intent.ACTION_PROFILE_REMOVED)
|
||||
}
|
||||
}
|
||||
runCatching {
|
||||
ContextCompat.registerReceiver(
|
||||
appContext,
|
||||
profileReceiver,
|
||||
profileFilter,
|
||||
ContextCompat.RECEIVER_EXPORTED,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Intent.userHandleExtra(): UserHandle? =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
getParcelableExtra(Intent.EXTRA_USER, UserHandle::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
getParcelableExtra(Intent.EXTRA_USER)
|
||||
}
|
||||
|
||||
private fun UserHandle.userId(): Int = hashCode()
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.krrishg.kwall
|
||||
|
||||
import android.app.Application
|
||||
import com.krrishg.kwall.notifications.FirewallNotifications
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
|
||||
class KwallApplication : Application() {
|
||||
|
||||
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
val container: AppContainer by lazy { AppContainer(this, applicationScope) }
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
FirewallNotifications.createChannel(this)
|
||||
val container = container
|
||||
container.firewallStatusNotifier.start(container.firewallManager)
|
||||
container.profileScheduler.start(applicationScope)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package com.krrishg.kwall
|
||||
|
||||
import android.Manifest
|
||||
import android.app.KeyguardManager
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.krrishg.kwall.notifications.FirewallNotifications
|
||||
import com.krrishg.kwall.settings.AppSettingsStore
|
||||
import com.krrishg.kwall.ui.apps.AppListRoute
|
||||
import com.krrishg.kwall.ui.apps.AppListViewModel
|
||||
import com.krrishg.kwall.ui.detail.AppDetailViewModel
|
||||
import com.krrishg.kwall.ui.theme.KwallTheme
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity : FragmentActivity() {
|
||||
|
||||
private val lockViewModel: LockViewModel by viewModels()
|
||||
private lateinit var container: AppContainer
|
||||
private lateinit var settingsStore: AppSettingsStore
|
||||
private var settingsLoaded by mutableStateOf(false)
|
||||
private var appLockEnabled by mutableStateOf(true)
|
||||
private var autoBlockNewApps by mutableStateOf(false)
|
||||
private var deviceLockConfigured by mutableStateOf(false)
|
||||
private var biometricPromptActive = false
|
||||
private var credentialIntentPending = false
|
||||
private var pendingFirewallToggle: Boolean? = null
|
||||
|
||||
private val notificationPermissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
if (granted) {
|
||||
container.firewallStatusNotifier.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
private val credentialLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult(),
|
||||
) { result ->
|
||||
credentialIntentPending = false
|
||||
if (result.resultCode == RESULT_OK) {
|
||||
unlock()
|
||||
} else {
|
||||
pendingFirewallToggle = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
deviceLockConfigured = isDeviceSecured()
|
||||
container = (application as KwallApplication).container
|
||||
settingsStore = container.settingsStore
|
||||
if (savedInstanceState?.containsKey(STATE_PENDING_FIREWALL_TOGGLE) == true) {
|
||||
pendingFirewallToggle = savedInstanceState.getBoolean(STATE_PENDING_FIREWALL_TOGGLE)
|
||||
}
|
||||
handleFirewallToggleIntent(intent)
|
||||
setContent {
|
||||
KwallTheme {
|
||||
when {
|
||||
!settingsLoaded -> Surface(modifier = Modifier.fillMaxSize()) {}
|
||||
appLockEnabled && !lockViewModel.isUnlocked -> LockScreen(
|
||||
deviceLockConfigured = deviceLockConfigured,
|
||||
onUnlock = ::authenticate,
|
||||
onOpenSecuritySettings = ::openSecuritySettings,
|
||||
onDisableAppLock = { updateAppLockEnabled(false) },
|
||||
)
|
||||
else -> AppListRoute(
|
||||
viewModel = viewModel(
|
||||
factory = AppListViewModel.factory(
|
||||
repository = container.appRepository,
|
||||
firewallController = container.firewallManager,
|
||||
appChangeNotifier = container.appChangeNotifier,
|
||||
scheduleStore = container.scheduleStore,
|
||||
),
|
||||
),
|
||||
detailViewModelFactory = AppDetailViewModel.factory(
|
||||
firewallController = container.firewallManager,
|
||||
connectionInspector = container.connectionInspector,
|
||||
usageSource = container.networkUsageSource,
|
||||
),
|
||||
appLockEnabled = appLockEnabled,
|
||||
onAppLockEnabledChange = ::updateAppLockEnabled,
|
||||
autoBlockNewApps = autoBlockNewApps,
|
||||
onAutoBlockNewAppsChange = ::updateAutoBlockNewApps,
|
||||
scheduleStore = container.scheduleStore,
|
||||
scheduleAlarms = container.scheduleAlarms,
|
||||
firewallController = container.firewallManager,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
settingsStore.appLockEnabled.collect { enabled ->
|
||||
val firstLoad = !settingsLoaded
|
||||
appLockEnabled = enabled
|
||||
if (firstLoad) {
|
||||
settingsLoaded = true
|
||||
if (enabled) {
|
||||
authenticate()
|
||||
} else {
|
||||
lockViewModel.isUnlocked = true
|
||||
requestNotificationPermission()
|
||||
}
|
||||
}
|
||||
applyPendingFirewallToggleIfAllowed()
|
||||
}
|
||||
}
|
||||
}
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
settingsStore.autoBlockNewApps.collect { enabled ->
|
||||
autoBlockNewApps = enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
container.firewallStatusNotifier.refresh()
|
||||
if (!settingsLoaded) return
|
||||
if (!appLockEnabled) {
|
||||
lockViewModel.isUnlocked = true
|
||||
applyPendingFirewallToggleIfAllowed()
|
||||
return
|
||||
}
|
||||
if (lockViewModel.isUnlocked) {
|
||||
applyPendingFirewallToggleIfAllowed()
|
||||
return
|
||||
}
|
||||
deviceLockConfigured = isDeviceSecured()
|
||||
if (deviceLockConfigured && !biometricPromptActive && !credentialIntentPending) {
|
||||
authenticate()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
handleFirewallToggleIntent(intent)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
if (!isChangingConfigurations) {
|
||||
lockViewModel.isUnlocked = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
pendingFirewallToggle?.let { target ->
|
||||
outState.putBoolean(STATE_PENDING_FIREWALL_TOGGLE, target)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isDeviceSecured(): Boolean =
|
||||
getSystemService(KeyguardManager::class.java)?.isDeviceSecure == true
|
||||
|
||||
private fun authenticate() {
|
||||
if (lockViewModel.isUnlocked || biometricPromptActive || credentialIntentPending) return
|
||||
if (!isDeviceSecured()) {
|
||||
deviceLockConfigured = false
|
||||
return
|
||||
}
|
||||
deviceLockConfigured = true
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
showBiometricPrompt()
|
||||
} else {
|
||||
showDeviceCredentialPrompt()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showBiometricPrompt() {
|
||||
biometricPromptActive = true
|
||||
val prompt = BiometricPrompt(
|
||||
this,
|
||||
ContextCompat.getMainExecutor(this),
|
||||
object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(
|
||||
result: BiometricPrompt.AuthenticationResult,
|
||||
) {
|
||||
biometricPromptActive = false
|
||||
unlock()
|
||||
}
|
||||
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
biometricPromptActive = false
|
||||
pendingFirewallToggle = null
|
||||
}
|
||||
|
||||
override fun onAuthenticationFailed() = Unit
|
||||
},
|
||||
)
|
||||
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle(getString(R.string.lock_title))
|
||||
.setSubtitle(getString(R.string.lock_subtitle))
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
promptInfo.setAllowedAuthenticators(
|
||||
BiometricManager.Authenticators.BIOMETRIC_STRONG or
|
||||
BiometricManager.Authenticators.DEVICE_CREDENTIAL,
|
||||
)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
promptInfo.setDeviceCredentialAllowed(true)
|
||||
}
|
||||
prompt.authenticate(promptInfo.build())
|
||||
}
|
||||
|
||||
private fun showDeviceCredentialPrompt() {
|
||||
val keyguardManager = getSystemService(KeyguardManager::class.java) ?: return
|
||||
val intent = keyguardManager.createConfirmDeviceCredentialIntent(
|
||||
getString(R.string.lock_title),
|
||||
getString(R.string.lock_subtitle),
|
||||
) ?: return
|
||||
credentialIntentPending = true
|
||||
credentialLauncher.launch(intent)
|
||||
}
|
||||
|
||||
private fun unlock() {
|
||||
if (lockViewModel.isUnlocked) {
|
||||
applyPendingFirewallToggleIfAllowed()
|
||||
return
|
||||
}
|
||||
lockViewModel.isUnlocked = true
|
||||
requestNotificationPermission()
|
||||
applyPendingFirewallToggleIfAllowed()
|
||||
}
|
||||
|
||||
private fun updateAppLockEnabled(enabled: Boolean) {
|
||||
appLockEnabled = enabled
|
||||
if (!enabled) {
|
||||
lockViewModel.isUnlocked = true
|
||||
requestNotificationPermission()
|
||||
}
|
||||
applyPendingFirewallToggleIfAllowed()
|
||||
lifecycleScope.launch {
|
||||
settingsStore.setAppLockEnabled(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateAutoBlockNewApps(enabled: Boolean) {
|
||||
autoBlockNewApps = enabled
|
||||
lifecycleScope.launch {
|
||||
settingsStore.setAutoBlockNewApps(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleFirewallToggleIntent(intent: Intent) {
|
||||
if (!intent.hasExtra(FirewallNotifications.EXTRA_TOGGLE_TARGET)) return
|
||||
pendingFirewallToggle = intent.getBooleanExtra(
|
||||
FirewallNotifications.EXTRA_TOGGLE_TARGET,
|
||||
false,
|
||||
)
|
||||
intent.removeExtra(FirewallNotifications.EXTRA_TOGGLE_TARGET)
|
||||
applyPendingFirewallToggleIfAllowed()
|
||||
}
|
||||
|
||||
private fun applyPendingFirewallToggleIfAllowed() {
|
||||
val target = pendingFirewallToggle ?: return
|
||||
if (!settingsLoaded) return
|
||||
if (appLockEnabled && !lockViewModel.isUnlocked) return
|
||||
pendingFirewallToggle = null
|
||||
lifecycleScope.launch {
|
||||
container.firewallManager.setEnabled(target)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openSecuritySettings() {
|
||||
val intent = Intent(Settings.ACTION_SECURITY_SETTINGS)
|
||||
runCatching { startActivity(intent) }
|
||||
.onFailure { runCatching { startActivity(Intent(Settings.ACTION_SETTINGS)) } }
|
||||
}
|
||||
|
||||
private fun requestNotificationPermission() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
|
||||
if (ContextCompat.checkSelfPermission(
|
||||
this,
|
||||
Manifest.permission.POST_NOTIFICATIONS,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
return
|
||||
}
|
||||
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val STATE_PENDING_FIREWALL_TOGGLE = "pending_firewall_toggle"
|
||||
}
|
||||
}
|
||||
|
||||
internal class LockViewModel : ViewModel() {
|
||||
var isUnlocked by mutableStateOf(false)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LockScreen(
|
||||
deviceLockConfigured: Boolean,
|
||||
onUnlock: () -> Unit,
|
||||
onOpenSecuritySettings: () -> Unit,
|
||||
onDisableAppLock: () -> Unit,
|
||||
) {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Lock,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (deviceLockConfigured) {
|
||||
R.string.lock_prompt_title
|
||||
} else {
|
||||
R.string.lock_no_device_lock_title
|
||||
},
|
||||
),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (deviceLockConfigured) {
|
||||
R.string.lock_prompt_description
|
||||
} else {
|
||||
R.string.lock_no_device_lock_description
|
||||
},
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Button(
|
||||
onClick = if (deviceLockConfigured) onUnlock else onOpenSecuritySettings,
|
||||
) {
|
||||
Text(
|
||||
stringResource(
|
||||
if (deviceLockConfigured) {
|
||||
R.string.lock_unlock
|
||||
} else {
|
||||
R.string.lock_open_settings
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
if (!deviceLockConfigured) {
|
||||
TextButton(onClick = onDisableAppLock) {
|
||||
Text(stringResource(R.string.lock_disable_app_lock))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.krrishg.kwall.data
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.LauncherActivityInfo
|
||||
import android.content.pm.LauncherApps
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Process
|
||||
import android.os.UserHandle
|
||||
import java.text.Collator
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
interface AppRepository {
|
||||
suspend fun loadInstalledApps(): List<InstalledApp>
|
||||
}
|
||||
|
||||
interface AppSource {
|
||||
suspend fun loadInstalledApps(): List<InstalledApp>
|
||||
}
|
||||
|
||||
class ProfileAwareAppRepository(
|
||||
private val sources: List<AppSource>,
|
||||
private val ownPackageName: String,
|
||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) : AppRepository {
|
||||
|
||||
override suspend fun loadInstalledApps(): List<InstalledApp> = withContext(ioDispatcher) {
|
||||
val collator = Collator.getInstance()
|
||||
val loaded = mutableListOf<InstalledApp>()
|
||||
for (source in sources) {
|
||||
loaded += source.loadInstalledApps()
|
||||
}
|
||||
loaded.asSequence()
|
||||
.filter { it.packageName != ownPackageName }
|
||||
.distinctBy { it.key }
|
||||
.sortedWith { first, second ->
|
||||
val labelOrder = collator.compare(first.label, second.label)
|
||||
when {
|
||||
labelOrder != 0 -> labelOrder
|
||||
first.userId != second.userId -> first.userId.compareTo(second.userId)
|
||||
else -> first.packageName.compareTo(second.packageName)
|
||||
}
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
|
||||
class PackageManagerAppSource(
|
||||
private val packageManager: PackageManager,
|
||||
) : AppSource {
|
||||
|
||||
override suspend fun loadInstalledApps(): List<InstalledApp> =
|
||||
packageManager.getInstalledApplications(0)
|
||||
.asSequence()
|
||||
.map { appInfo ->
|
||||
InstalledApp(
|
||||
packageName = appInfo.packageName,
|
||||
label = appInfo.loadDisplayLabel(packageManager),
|
||||
isSystem = appInfo.isSystemApp,
|
||||
icon = runCatching { appInfo.loadIcon(packageManager) }.getOrNull(),
|
||||
uid = appInfo.uid,
|
||||
userId = appInfo.uid / PER_USER_RANGE,
|
||||
isWorkProfile = false,
|
||||
hasInternetPermission = packageManager.checkPermission(
|
||||
Manifest.permission.INTERNET,
|
||||
appInfo.packageName,
|
||||
) == PackageManager.PERMISSION_GRANTED,
|
||||
)
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
|
||||
class WorkProfileAppSource(
|
||||
private val launcherApps: LauncherApps?,
|
||||
private val density: Int,
|
||||
) : AppSource {
|
||||
|
||||
override suspend fun loadInstalledApps(): List<InstalledApp> {
|
||||
val service = launcherApps ?: return emptyList()
|
||||
val profiles = runCatching { service.profiles }.getOrElse { return emptyList() }
|
||||
val currentUser = Process.myUserHandle()
|
||||
return profiles
|
||||
.filter { it != currentUser }
|
||||
.flatMap { user -> loadProfileApps(service, user) }
|
||||
}
|
||||
|
||||
private fun loadProfileApps(service: LauncherApps, user: UserHandle): List<InstalledApp> {
|
||||
val activities = runCatching { service.getActivityList(null, user) }.getOrElse { return emptyList() }
|
||||
return activities
|
||||
.groupBy { it.applicationInfo.packageName }
|
||||
.mapNotNull { (packageName, entries) ->
|
||||
val entry = entries.firstOrNull() ?: return@mapNotNull null
|
||||
val appInfo = entry.applicationInfo
|
||||
if (appInfo.uid <= 0) return@mapNotNull null
|
||||
InstalledApp(
|
||||
packageName = packageName,
|
||||
label = entry.loadLabel(packageName),
|
||||
isSystem = appInfo.isSystemApp,
|
||||
icon = runCatching { entry.getBadgedIcon(density) }.getOrNull(),
|
||||
uid = appInfo.uid,
|
||||
userId = appInfo.uid / PER_USER_RANGE,
|
||||
isWorkProfile = true,
|
||||
hasInternetPermission = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun LauncherActivityInfo.loadLabel(packageName: String): String =
|
||||
runCatching { label?.toString() }
|
||||
.getOrNull()
|
||||
.orEmpty()
|
||||
.ifBlank { packageName }
|
||||
}
|
||||
|
||||
private fun ApplicationInfo.loadDisplayLabel(packageManager: PackageManager): String =
|
||||
runCatching { loadLabel(packageManager).toString() }
|
||||
.getOrDefault("")
|
||||
.ifBlank { packageName }
|
||||
|
||||
private val ApplicationInfo.isSystemApp: Boolean
|
||||
get() = flags and (ApplicationInfo.FLAG_SYSTEM or ApplicationInfo.FLAG_UPDATED_SYSTEM_APP) != 0
|
||||
|
||||
private const val PER_USER_RANGE = 100_000
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.krrishg.kwall.data
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import com.krrishg.kwall.firewall.AppKey
|
||||
|
||||
data class InstalledApp(
|
||||
val packageName: String,
|
||||
val label: String,
|
||||
val isSystem: Boolean,
|
||||
val icon: Drawable?,
|
||||
val uid: Int,
|
||||
val userId: Int,
|
||||
val isWorkProfile: Boolean,
|
||||
val hasInternetPermission: Boolean,
|
||||
) {
|
||||
val key: AppKey get() = AppKey(userId, packageName)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.krrishg.kwall.firewall
|
||||
|
||||
data class AppKey(
|
||||
val userId: Int,
|
||||
val packageName: String,
|
||||
) {
|
||||
override fun toString(): String = "$userId:$packageName"
|
||||
|
||||
companion object {
|
||||
fun parse(value: String): AppKey? {
|
||||
val separator = value.indexOf(':')
|
||||
if (separator <= 0 || separator == value.lastIndex) return null
|
||||
val userId = value.substring(0, separator).toIntOrNull() ?: return null
|
||||
return AppKey(userId, value.substring(separator + 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.krrishg.kwall.firewall
|
||||
|
||||
import android.content.pm.LauncherApps
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Process
|
||||
import android.os.UserHandle
|
||||
|
||||
interface UidResolver {
|
||||
fun resolve(key: AppKey): Int?
|
||||
}
|
||||
|
||||
class AppUidResolver(
|
||||
private val packageManager: PackageManager,
|
||||
private val launcherApps: LauncherApps?,
|
||||
) : UidResolver {
|
||||
|
||||
override fun resolve(key: AppKey): Int? = runCatching {
|
||||
if (key.userId == Process.myUid() / PER_USER_RANGE) {
|
||||
@Suppress("DEPRECATION")
|
||||
packageManager.getApplicationInfo(key.packageName, 0).uid
|
||||
} else {
|
||||
launcherApps
|
||||
?.getApplicationInfo(
|
||||
key.packageName,
|
||||
0,
|
||||
UserHandle.getUserHandleForUid(key.userId * PER_USER_RANGE),
|
||||
)
|
||||
?.uid
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
private companion object {
|
||||
const val PER_USER_RANGE = 100_000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.krrishg.kwall.firewall
|
||||
|
||||
import com.krrishg.kwall.data.AppRepository
|
||||
import com.krrishg.kwall.data.InstalledApp
|
||||
import com.krrishg.kwall.settings.AppSettingsStore
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
class AutoBlockNewApps(
|
||||
private val settingsStore: AppSettingsStore,
|
||||
private val repository: AppRepository,
|
||||
private val firewallController: FirewallController,
|
||||
) {
|
||||
|
||||
private val operationMutex = Mutex()
|
||||
|
||||
suspend fun onPackageAdded(packageName: String, userId: Int): InstalledApp? =
|
||||
operationMutex.withLock {
|
||||
if (!settingsStore.autoBlockNewApps.first()) return@withLock null
|
||||
val apps = repository.loadInstalledApps()
|
||||
val app = apps.firstOrNull {
|
||||
it.packageName == packageName && it.userId == userId
|
||||
} ?: return@withLock null
|
||||
if (app.isSystem) return@withLock null
|
||||
|
||||
val packageCounts = apps.groupingBy { it.packageName }.eachCount()
|
||||
val rulesTransport = (firewallController.availability.value as? FirewallAvailability.Ready)
|
||||
?.rulesTransport
|
||||
val isWorkProfileOnly = app.isWorkProfile && (packageCounts[app.packageName] ?: 1) == 1
|
||||
if (isWorkProfileOnly && rulesTransport != RulesTransport.BINDER) return@withLock null
|
||||
|
||||
val assessment = blockabilityFor(
|
||||
app = app,
|
||||
uidCounts = apps.groupingBy { it.uid }.eachCount(),
|
||||
packageCounts = packageCounts,
|
||||
rulesTransport = rulesTransport,
|
||||
firewallPackages = FIREWALL_PACKAGES,
|
||||
)
|
||||
if (assessment.blockability != AppBlockability.BLOCKABLE) return@withLock null
|
||||
|
||||
if (firewallController.addBlockedSelection(app)) app else null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.krrishg.kwall.firewall
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.ServiceConnection
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.os.IBinder
|
||||
import androidx.core.content.pm.PackageInfoCompat
|
||||
import kotlin.coroutines.resume
|
||||
import kotlinx.coroutines.CancellableContinuation
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import rikka.shizuku.Shizuku
|
||||
|
||||
class BinderFirewallClient(context: Context) {
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
private val lock = Mutex()
|
||||
|
||||
@Volatile
|
||||
private var service: IFirewallService? = null
|
||||
|
||||
@Volatile
|
||||
private var pending: CancellableContinuation<IFirewallService?>? = null
|
||||
|
||||
private val serviceArgs by lazy {
|
||||
val debuggable = appContext.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0
|
||||
Shizuku.UserServiceArgs(
|
||||
ComponentName(appContext.packageName, FirewallUserService::class.java.name),
|
||||
)
|
||||
.daemon(true)
|
||||
.processNameSuffix("firewall")
|
||||
.version(userServiceVersion())
|
||||
.debuggable(debuggable)
|
||||
}
|
||||
|
||||
private fun userServiceVersion(): Int {
|
||||
val versionCode = runCatching {
|
||||
val packageInfo = appContext.packageManager.getPackageInfo(appContext.packageName, 0)
|
||||
PackageInfoCompat.getLongVersionCode(packageInfo)
|
||||
}.getOrDefault(1L)
|
||||
return (((versionCode and 0x7fffffffL) * 100) + USER_SERVICE_REVISION).toInt()
|
||||
}
|
||||
|
||||
suspend fun probe(): Boolean {
|
||||
if (!runCatching { Shizuku.pingBinder() }.getOrDefault(false)) return false
|
||||
val binderService = runCatching { obtain() }.getOrNull() ?: return false
|
||||
val result = runCatching { binderService.probe() }
|
||||
return result.isSuccess && result.getOrNull().isNullOrBlank()
|
||||
}
|
||||
|
||||
suspend fun setUidFirewallRule(chain: Int, uid: Int, rule: Int) {
|
||||
val binderService = obtain() ?: throw FirewallException("Shizuku firewall service is unavailable")
|
||||
val error = binderService.setUidFirewallRule(chain, uid, rule)
|
||||
if (!error.isNullOrBlank()) throw FirewallException(error)
|
||||
}
|
||||
|
||||
suspend fun setUidFirewallRules(chain: Int, uids: IntArray, rules: IntArray): List<String?> {
|
||||
val binderService = obtain() ?: throw FirewallException("Shizuku firewall service is unavailable")
|
||||
return binderService.setUidFirewallRules(chain, uids, rules).toList()
|
||||
}
|
||||
|
||||
suspend fun setFirewallChainEnabled(chain: Int, enabled: Boolean) {
|
||||
val binderService = obtain() ?: throw FirewallException("Shizuku firewall service is unavailable")
|
||||
val error = binderService.setFirewallChainEnabled(chain, enabled)
|
||||
if (!error.isNullOrBlank()) throw FirewallException(error)
|
||||
}
|
||||
|
||||
suspend fun replaceUidFirewallChain(chain: Int, uids: IntArray) {
|
||||
val binderService = obtain() ?: throw FirewallException("Shizuku firewall service is unavailable")
|
||||
val error = binderService.replaceUidFirewallChain(chain, uids)
|
||||
if (!error.isNullOrBlank()) throw FirewallException(error)
|
||||
}
|
||||
|
||||
suspend fun getUidFirewallRule(chain: Int, uid: Int): Int? {
|
||||
val binderService = obtain() ?: return null
|
||||
return runCatching { binderService.getUidFirewallRule(chain, uid) }
|
||||
.getOrNull()
|
||||
?.takeUnless { it == FirewallCommands.RULE_UNKNOWN }
|
||||
}
|
||||
|
||||
suspend fun getFirewallChainEnabled(chain: Int): Boolean? {
|
||||
val binderService = obtain() ?: return null
|
||||
return when (runCatching { binderService.getFirewallChainEnabled(chain) }.getOrNull()) {
|
||||
FirewallCommands.CHAIN_ENABLED -> true
|
||||
FirewallCommands.CHAIN_DISABLED -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun obtain(): IFirewallService? {
|
||||
alive()?.let { return it }
|
||||
return lock.withLock {
|
||||
alive() ?: bind().also { service = it }
|
||||
}
|
||||
}
|
||||
|
||||
private fun alive(): IFirewallService? =
|
||||
service?.takeIf { runCatching { it.asBinder().pingBinder() }.getOrDefault(false) }
|
||||
|
||||
private suspend fun bind(): IFirewallService? {
|
||||
val connection = object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
|
||||
val bound = binder
|
||||
?.takeIf { runCatching { it.pingBinder() }.getOrDefault(false) }
|
||||
?.let { IFirewallService.Stub.asInterface(it) }
|
||||
service = bound
|
||||
pending?.takeIf { it.isActive }?.resume(bound)
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
service = null
|
||||
}
|
||||
}
|
||||
|
||||
val bound = withTimeoutOrNull(BIND_TIMEOUT_MS) {
|
||||
suspendCancellableCoroutine<IFirewallService?> { continuation ->
|
||||
pending = continuation
|
||||
try {
|
||||
Shizuku.bindUserService(serviceArgs, connection)
|
||||
} catch (error: Throwable) {
|
||||
if (continuation.isActive) continuation.resume(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
pending = null
|
||||
|
||||
if (bound == null) {
|
||||
runCatching { Shizuku.unbindUserService(serviceArgs, connection, false) }
|
||||
}
|
||||
return bound
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val BIND_TIMEOUT_MS = 4_000L
|
||||
const val USER_SERVICE_REVISION = 3
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.krrishg.kwall.firewall
|
||||
|
||||
import com.krrishg.kwall.data.InstalledApp
|
||||
|
||||
enum class AppBlockability {
|
||||
BLOCKABLE,
|
||||
SYSTEM_UID,
|
||||
SHARED_UID,
|
||||
NO_INTERNET,
|
||||
FIREWALL_APP,
|
||||
WORK_PROFILE_ONLY,
|
||||
UNRESOLVED,
|
||||
}
|
||||
|
||||
data class BlockabilityAssessment(
|
||||
val blockability: AppBlockability,
|
||||
val warnAllProfiles: Boolean,
|
||||
)
|
||||
|
||||
const val SHIZUKU_PACKAGE = "moe.shizuku.privileged.api"
|
||||
|
||||
val FIREWALL_PACKAGES: Set<String> = setOf(SHIZUKU_PACKAGE)
|
||||
|
||||
fun blockabilityOf(
|
||||
app: InstalledApp,
|
||||
uidCounts: Map<Int, Int>,
|
||||
firewallPackages: Set<String>,
|
||||
): AppBlockability = when {
|
||||
app.packageName in firewallPackages -> AppBlockability.FIREWALL_APP
|
||||
app.uid <= 0 -> AppBlockability.UNRESOLVED
|
||||
app.uid % PER_USER_RANGE < FIRST_APPLICATION_UID -> AppBlockability.SYSTEM_UID
|
||||
!app.hasInternetPermission -> AppBlockability.NO_INTERNET
|
||||
(uidCounts[app.uid] ?: 0) > 1 -> AppBlockability.SHARED_UID
|
||||
else -> AppBlockability.BLOCKABLE
|
||||
}
|
||||
|
||||
fun blockabilityFor(
|
||||
app: InstalledApp,
|
||||
uidCounts: Map<Int, Int>,
|
||||
packageCounts: Map<String, Int>,
|
||||
rulesTransport: RulesTransport?,
|
||||
firewallPackages: Set<String>,
|
||||
): BlockabilityAssessment {
|
||||
val baseBlockability = blockabilityOf(app, uidCounts, firewallPackages)
|
||||
val profileCopies = packageCounts[app.packageName] ?: 1
|
||||
val workProfileOnly = baseBlockability == AppBlockability.BLOCKABLE &&
|
||||
rulesTransport == RulesTransport.SHELL &&
|
||||
app.isWorkProfile &&
|
||||
profileCopies == 1
|
||||
return BlockabilityAssessment(
|
||||
blockability = if (workProfileOnly) {
|
||||
AppBlockability.WORK_PROFILE_ONLY
|
||||
} else {
|
||||
baseBlockability
|
||||
},
|
||||
warnAllProfiles = rulesTransport == RulesTransport.SHELL && profileCopies > 1,
|
||||
)
|
||||
}
|
||||
|
||||
private const val PER_USER_RANGE = 100_000
|
||||
private const val FIRST_APPLICATION_UID = 10_000
|
||||
@@ -0,0 +1,266 @@
|
||||
package com.krrishg.kwall.firewall
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import java.io.IOException
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private val Context.firewallDataStore by preferencesDataStore(name = "firewall_state")
|
||||
|
||||
class DataStoreFirewallStateStore(context: Context) : FirewallStateStore {
|
||||
|
||||
private val dataStore = context.applicationContext.firewallDataStore
|
||||
|
||||
override val state: Flow<FirewallState> = flow {
|
||||
migrateIfNeeded()
|
||||
emitAll(
|
||||
dataStore.data
|
||||
.catch { error ->
|
||||
if (error is IOException) {
|
||||
emit(emptyPreferences())
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
.map(::toState),
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun setEnabled(enabled: Boolean) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences.ensureProfiles()
|
||||
preferences[ENABLED_KEY] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setBlocked(key: AppKey, blocked: Boolean) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences.ensureProfiles()
|
||||
val activeId = preferences.activeProfileId() ?: return@edit
|
||||
val blockedApps = preferences[profileAppsKey(activeId)].orEmpty().toMutableSet()
|
||||
if (blocked) {
|
||||
blockedApps.add(key.toString())
|
||||
} else {
|
||||
blockedApps.remove(key.toString())
|
||||
}
|
||||
preferences[profileAppsKey(activeId)] = blockedApps
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun createProfile(name: String): RuleProfile {
|
||||
var created: RuleProfile? = null
|
||||
dataStore.edit { preferences ->
|
||||
preferences.ensureProfiles()
|
||||
val profile = RuleProfile(
|
||||
id = UUID.randomUUID().toString(),
|
||||
name = name.trim(),
|
||||
blockedApps = emptySet(),
|
||||
createdAtMillis = System.currentTimeMillis(),
|
||||
)
|
||||
preferences.addProfile(profile)
|
||||
created = profile
|
||||
}
|
||||
return checkNotNull(created)
|
||||
}
|
||||
|
||||
override suspend fun duplicateProfile(sourceId: String, name: String): RuleProfile? {
|
||||
var created: RuleProfile? = null
|
||||
dataStore.edit { preferences ->
|
||||
preferences.ensureProfiles()
|
||||
val source = preferences.readProfile(sourceId) ?: return@edit
|
||||
val profile = RuleProfile(
|
||||
id = UUID.randomUUID().toString(),
|
||||
name = name.trim(),
|
||||
blockedApps = source.blockedApps,
|
||||
createdAtMillis = System.currentTimeMillis(),
|
||||
)
|
||||
preferences.addProfile(profile)
|
||||
created = profile
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
override suspend fun renameProfile(id: String, name: String): Boolean {
|
||||
var renamed = false
|
||||
dataStore.edit { preferences ->
|
||||
preferences.ensureProfiles()
|
||||
if (preferences.readProfile(id) == null) return@edit
|
||||
preferences[profileNameKey(id)] = name.trim()
|
||||
renamed = true
|
||||
}
|
||||
return renamed
|
||||
}
|
||||
|
||||
override suspend fun deleteProfile(id: String): Boolean {
|
||||
var deleted = false
|
||||
dataStore.edit { preferences ->
|
||||
preferences.ensureProfiles()
|
||||
val profileIds = preferences[PROFILE_IDS_KEY].orEmpty()
|
||||
if (id !in profileIds) return@edit
|
||||
val remainingIds = profileIds - id
|
||||
if (remainingIds.isEmpty()) return@edit
|
||||
preferences.remove(profileNameKey(id))
|
||||
preferences.remove(profileAppsKey(id))
|
||||
preferences.remove(profileCreatedKey(id))
|
||||
preferences[PROFILE_IDS_KEY] = remainingIds
|
||||
deleted = true
|
||||
val activeId = preferences[ACTIVE_PROFILE_ID_KEY]
|
||||
if (activeId == id || activeId == null) {
|
||||
preferences[ACTIVE_PROFILE_ID_KEY] = preferences.oldestProfileId(remainingIds)
|
||||
}
|
||||
}
|
||||
return deleted
|
||||
}
|
||||
|
||||
override suspend fun setActiveProfile(id: String): Boolean {
|
||||
var activated = false
|
||||
dataStore.edit { preferences ->
|
||||
preferences.ensureProfiles()
|
||||
if (preferences.readProfile(id) == null) return@edit
|
||||
preferences[ACTIVE_PROFILE_ID_KEY] = id
|
||||
activated = true
|
||||
}
|
||||
return activated
|
||||
}
|
||||
|
||||
override suspend fun removeBlockedEverywhere(key: AppKey) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences.ensureProfiles()
|
||||
preferences.profileIds().forEach { id ->
|
||||
val blockedApps = preferences[profileAppsKey(id)].orEmpty()
|
||||
if (key.toString() in blockedApps) {
|
||||
preferences[profileAppsKey(id)] = blockedApps - key.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun removeBlockedForUserEverywhere(userId: Int) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences.ensureProfiles()
|
||||
preferences.profileIds().forEach { id ->
|
||||
val blockedApps = preferences[profileAppsKey(id)].orEmpty()
|
||||
val filtered = blockedApps
|
||||
.mapNotNull(AppKey::parse)
|
||||
.filterNot { it.userId == userId }
|
||||
.map(AppKey::toString)
|
||||
.toSet()
|
||||
if (filtered.size != blockedApps.size) {
|
||||
preferences[profileAppsKey(id)] = filtered
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun migrateIfNeeded() {
|
||||
val preferences = dataStore.data
|
||||
.catch { error ->
|
||||
if (error is IOException) {
|
||||
emit(emptyPreferences())
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
.first()
|
||||
if (preferences.profileIds().isNotEmpty()) return
|
||||
dataStore.edit { mutablePreferences ->
|
||||
mutablePreferences.ensureProfiles()
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutablePreferences.ensureProfiles() {
|
||||
if (profileIds().isNotEmpty()) return
|
||||
val legacyBlockedApps = this[LEGACY_BLOCKED_APPS_KEY]
|
||||
.orEmpty()
|
||||
.mapNotNull(AppKey::parse)
|
||||
.toSet()
|
||||
val profile = defaultRuleProfile(
|
||||
blockedApps = legacyBlockedApps,
|
||||
createdAtMillis = System.currentTimeMillis(),
|
||||
)
|
||||
addProfile(profile)
|
||||
this[ACTIVE_PROFILE_ID_KEY] = profile.id
|
||||
remove(LEGACY_BLOCKED_APPS_KEY)
|
||||
}
|
||||
|
||||
private fun MutablePreferences.addProfile(profile: RuleProfile) {
|
||||
this[PROFILE_IDS_KEY] = profileIds() + profile.id
|
||||
this[profileNameKey(profile.id)] = profile.name
|
||||
this[profileAppsKey(profile.id)] = profile.blockedApps.map(AppKey::toString).toSet()
|
||||
this[profileCreatedKey(profile.id)] = profile.createdAtMillis
|
||||
}
|
||||
|
||||
private fun Preferences.profileIds(): Set<String> =
|
||||
this[PROFILE_IDS_KEY].orEmpty()
|
||||
|
||||
private fun Preferences.activeProfileId(): String? {
|
||||
val activeId = this[ACTIVE_PROFILE_ID_KEY]
|
||||
val ids = profileIds()
|
||||
return activeId?.takeIf { it in ids } ?: oldestProfileId(ids)
|
||||
}
|
||||
|
||||
private fun Preferences.oldestProfileId(ids: Set<String>): String {
|
||||
val profiles = ids.mapNotNull { id -> readProfile(id) }
|
||||
return profiles.minWithOrNull(
|
||||
compareBy<RuleProfile> { it.createdAtMillis }.thenBy { it.id },
|
||||
)?.id ?: defaultRuleProfile().id
|
||||
}
|
||||
|
||||
private fun Preferences.readProfile(id: String): RuleProfile? {
|
||||
if (id !in profileIds()) return null
|
||||
val name = this[profileNameKey(id)]?.takeIf { it.isNotBlank() } ?: id
|
||||
val blockedApps = this[profileAppsKey(id)]
|
||||
.orEmpty()
|
||||
.mapNotNull(AppKey::parse)
|
||||
.toSet()
|
||||
val createdAtMillis = this[profileCreatedKey(id)] ?: 0L
|
||||
return RuleProfile(
|
||||
id = id,
|
||||
name = name,
|
||||
blockedApps = blockedApps,
|
||||
createdAtMillis = createdAtMillis,
|
||||
)
|
||||
}
|
||||
|
||||
private fun toState(preferences: Preferences): FirewallState {
|
||||
val profiles = preferences.profileIds()
|
||||
.mapNotNull { id -> preferences.readProfile(id) }
|
||||
.sortedWith(compareBy<RuleProfile> { it.createdAtMillis }.thenBy { it.id })
|
||||
.ifEmpty { listOf(defaultRuleProfile()) }
|
||||
val activeId = preferences[ACTIVE_PROFILE_ID_KEY]
|
||||
val activeProfileId = activeId?.takeIf { id -> profiles.any { it.id == id } }
|
||||
?: profiles.first().id
|
||||
return FirewallState(
|
||||
enabled = preferences[ENABLED_KEY] ?: false,
|
||||
profiles = profiles,
|
||||
activeProfileId = activeProfileId,
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val ENABLED_KEY = booleanPreferencesKey("enabled")
|
||||
val LEGACY_BLOCKED_APPS_KEY = stringSetPreferencesKey("blocked_apps")
|
||||
val PROFILE_IDS_KEY = stringSetPreferencesKey("profile_ids")
|
||||
val ACTIVE_PROFILE_ID_KEY = stringPreferencesKey("active_profile_id")
|
||||
|
||||
fun profileNameKey(id: String) = stringPreferencesKey("profile_name_$id")
|
||||
|
||||
fun profileAppsKey(id: String) = stringSetPreferencesKey("profile_apps_$id")
|
||||
|
||||
fun profileCreatedKey(id: String) = longPreferencesKey("profile_created_$id")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.krrishg.kwall.firewall
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
sealed interface FirewallAvailability {
|
||||
data object Checking : FirewallAvailability
|
||||
data object UnsupportedAndroid : FirewallAvailability
|
||||
data object ShizukuNotInstalled : FirewallAvailability
|
||||
data object ShizukuNotRunning : FirewallAvailability
|
||||
data object PermissionDenied : FirewallAvailability
|
||||
data object CommandUnavailable : FirewallAvailability
|
||||
data class Ready(
|
||||
val rulesTransport: RulesTransport,
|
||||
val chainTransport: ChainTransport,
|
||||
) : FirewallAvailability
|
||||
}
|
||||
|
||||
enum class RulesTransport { BINDER, SHELL }
|
||||
|
||||
enum class ChainTransport { SHELL, BINDER }
|
||||
|
||||
data class BlockedApp(
|
||||
val key: AppKey,
|
||||
val uid: Int,
|
||||
)
|
||||
|
||||
interface FirewallBackend {
|
||||
val availability: StateFlow<FirewallAvailability>
|
||||
|
||||
suspend fun refreshAvailability()
|
||||
|
||||
suspend fun setChainEnabled(enabled: Boolean)
|
||||
|
||||
suspend fun block(app: BlockedApp)
|
||||
|
||||
suspend fun unblock(app: BlockedApp)
|
||||
|
||||
suspend fun reapply(apps: Set<BlockedApp>)
|
||||
|
||||
suspend fun clearRules()
|
||||
|
||||
suspend fun unblockAll(apps: Set<BlockedApp>)
|
||||
|
||||
suspend fun observe(apps: Set<BlockedApp>): FirewallObservation
|
||||
|
||||
fun requestPermission()
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.krrishg.kwall.firewall
|
||||
|
||||
object FirewallCommands {
|
||||
|
||||
const val CHAIN = 9
|
||||
const val RULE_DEFAULT = 0
|
||||
const val RULE_ALLOW = 1
|
||||
const val RULE_DENY = 2
|
||||
const val RULE_UNKNOWN = -1
|
||||
const val CHAIN_DISABLED = 0
|
||||
const val CHAIN_ENABLED = 1
|
||||
const val CHAIN_UNKNOWN = -1
|
||||
|
||||
private const val CHAIN_COMMAND = "cmd connectivity set-chain3-enabled"
|
||||
private const val PACKAGE_COMMAND = "cmd connectivity set-package-networking-enabled"
|
||||
private const val GET_CHAIN_COMMAND = "cmd connectivity get-chain3-enabled"
|
||||
private const val GET_PACKAGE_COMMAND = "cmd connectivity get-package-networking-enabled"
|
||||
private val PACKAGE_NAME = Regex("[A-Za-z0-9_.]+")
|
||||
|
||||
fun chainEnabled(enabled: Boolean): String = "$CHAIN_COMMAND $enabled"
|
||||
|
||||
fun packageNetworking(packageName: String, enabled: Boolean): String {
|
||||
require(PACKAGE_NAME.matches(packageName)) { "Invalid package name: $packageName" }
|
||||
return "$PACKAGE_COMMAND $enabled $packageName"
|
||||
}
|
||||
|
||||
fun chainState(): String = GET_CHAIN_COMMAND
|
||||
|
||||
fun packageState(packageName: String): String {
|
||||
require(PACKAGE_NAME.matches(packageName)) { "Invalid package name: $packageName" }
|
||||
return "$GET_PACKAGE_COMMAND $packageName"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.krrishg.kwall.firewall
|
||||
|
||||
import com.krrishg.kwall.data.InstalledApp
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
interface FirewallController {
|
||||
val state: StateFlow<FirewallState>
|
||||
val availability: StateFlow<FirewallAvailability>
|
||||
val error: StateFlow<String?>
|
||||
val drift: StateFlow<FirewallDriftState>
|
||||
|
||||
suspend fun setBlocked(app: InstalledApp, blocked: Boolean)
|
||||
|
||||
suspend fun addBlockedSelection(app: InstalledApp): Boolean
|
||||
|
||||
suspend fun setEnabled(enabled: Boolean)
|
||||
|
||||
suspend fun applyScheduledProfile(profileId: String, enableFirewall: Boolean?): Boolean
|
||||
|
||||
suspend fun restore()
|
||||
|
||||
suspend fun checkForDrift()
|
||||
|
||||
suspend fun refreshAvailability()
|
||||
|
||||
fun requestPermission()
|
||||
|
||||
suspend fun removeBlocked(key: AppKey)
|
||||
|
||||
suspend fun removeBlockedForUser(userId: Int)
|
||||
|
||||
suspend fun switchProfile(id: String)
|
||||
|
||||
suspend fun createProfile(name: String): Boolean
|
||||
|
||||
suspend fun duplicateProfile(sourceId: String, name: String): Boolean
|
||||
|
||||
suspend fun renameProfile(id: String, name: String): Boolean
|
||||
|
||||
suspend fun deleteProfile(id: String): Boolean
|
||||
|
||||
suspend fun removeBlockedEverywhere(key: AppKey)
|
||||
|
||||
suspend fun removeBlockedForUserEverywhere(userId: Int)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.krrishg.kwall.firewall
|
||||
|
||||
data class FirewallObservation(
|
||||
val chainEnabled: Boolean? = null,
|
||||
val appStates: Map<AppKey, Boolean> = emptyMap(),
|
||||
) {
|
||||
val hasAnyData: Boolean get() = chainEnabled != null || appStates.isNotEmpty()
|
||||
|
||||
fun covers(expected: Set<BlockedApp>): Boolean =
|
||||
chainEnabled != null && expected.all { it.key in appStates }
|
||||
}
|
||||
|
||||
data class FirewallDrift(
|
||||
val chainMismatch: Boolean = false,
|
||||
val missingRules: Set<AppKey> = emptySet(),
|
||||
val staleRules: Set<AppKey> = emptySet(),
|
||||
) {
|
||||
val detected: Boolean
|
||||
get() = chainMismatch || missingRules.isNotEmpty() || staleRules.isNotEmpty()
|
||||
}
|
||||
|
||||
enum class FirewallDriftStatus {
|
||||
IDLE,
|
||||
CHECKING,
|
||||
IN_SYNC,
|
||||
REPAIRED,
|
||||
DRIFT_DETECTED,
|
||||
UNAVAILABLE,
|
||||
}
|
||||
|
||||
data class FirewallDriftState(
|
||||
val status: FirewallDriftStatus = FirewallDriftStatus.IDLE,
|
||||
val drift: FirewallDrift? = null,
|
||||
)
|
||||
|
||||
object FirewallDriftDetector {
|
||||
|
||||
fun detect(
|
||||
expectedEnabled: Boolean,
|
||||
expectedBlocked: Set<BlockedApp>,
|
||||
candidates: Set<BlockedApp>,
|
||||
observation: FirewallObservation,
|
||||
): FirewallDrift {
|
||||
val expectedKeys = expectedBlocked.mapTo(HashSet()) { it.key }
|
||||
val missingRules = expectedBlocked
|
||||
.asSequence()
|
||||
.map { it.key }
|
||||
.filter { key -> observation.appStates[key] == false }
|
||||
.toSet()
|
||||
val staleRules = candidates
|
||||
.asSequence()
|
||||
.map { it.key }
|
||||
.filter { key -> key !in expectedKeys && observation.appStates[key] == true }
|
||||
.toSet()
|
||||
return FirewallDrift(
|
||||
chainMismatch = observation.chainEnabled != null &&
|
||||
observation.chainEnabled != expectedEnabled,
|
||||
missingRules = missingRules,
|
||||
staleRules = staleRules,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.krrishg.kwall.firewall
|
||||
|
||||
class FirewallException(
|
||||
message: String,
|
||||
cause: Throwable? = null,
|
||||
) : Exception(message, cause)
|
||||
@@ -0,0 +1,507 @@
|
||||
package com.krrishg.kwall.firewall
|
||||
|
||||
import com.krrishg.kwall.data.InstalledApp
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class FirewallManager(
|
||||
private val store: FirewallStateStore,
|
||||
private val backend: FirewallBackend,
|
||||
private val uidResolver: UidResolver,
|
||||
scope: CoroutineScope,
|
||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
private val driftCheckIntervalMillis: Long = DEFAULT_DRIFT_CHECK_INTERVAL_MS,
|
||||
) : FirewallController {
|
||||
|
||||
private val operationMutex = Mutex()
|
||||
private val _error = MutableStateFlow<String?>(null)
|
||||
private val _drift = MutableStateFlow(FirewallDriftState())
|
||||
|
||||
override val error: StateFlow<String?> = _error.asStateFlow()
|
||||
override val drift: StateFlow<FirewallDriftState> = _drift.asStateFlow()
|
||||
override val state: StateFlow<FirewallState> = store.state
|
||||
.stateIn(scope, SharingStarted.Eagerly, FirewallState())
|
||||
override val availability: StateFlow<FirewallAvailability> = backend.availability
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
availability.collect { value ->
|
||||
if (value is FirewallAvailability.Ready) {
|
||||
restore()
|
||||
}
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
delay(driftCheckIntervalMillis)
|
||||
val current = state.value
|
||||
if (!current.enabled && current.blockedApps.isEmpty()) continue
|
||||
if (availability.value !is FirewallAvailability.Ready) continue
|
||||
checkForDrift()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setBlocked(app: InstalledApp, blocked: Boolean) {
|
||||
operationMutex.withLock {
|
||||
_error.value = null
|
||||
val current = store.state.first()
|
||||
try {
|
||||
val blockedApp = BlockedApp(app.key, app.uid)
|
||||
if (blocked) {
|
||||
if (current.enabled) {
|
||||
backend.setChainEnabled(true)
|
||||
backend.block(blockedApp)
|
||||
}
|
||||
store.setBlocked(app.key, true)
|
||||
} else {
|
||||
if (current.enabled) {
|
||||
backend.unblock(blockedApp)
|
||||
}
|
||||
store.setBlocked(app.key, false)
|
||||
}
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (error: Throwable) {
|
||||
_error.value = error.message ?: error.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun addBlockedSelection(app: InstalledApp): Boolean = operationMutex.withLock {
|
||||
_error.value = null
|
||||
val current = store.state.first()
|
||||
if (app.key in current.blockedApps) return@withLock false
|
||||
try {
|
||||
if (current.enabled) {
|
||||
backend.setChainEnabled(true)
|
||||
backend.block(BlockedApp(app.key, app.uid))
|
||||
}
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (error: Throwable) {
|
||||
_error.value = error.message ?: error.toString()
|
||||
}
|
||||
store.setBlocked(app.key, true)
|
||||
true
|
||||
}
|
||||
|
||||
override suspend fun setEnabled(enabled: Boolean) {
|
||||
operationMutex.withLock {
|
||||
_error.value = null
|
||||
val current = store.state.first()
|
||||
try {
|
||||
if (enabled) {
|
||||
backend.reapply(resolve(current.blockedApps))
|
||||
store.setEnabled(true)
|
||||
} else {
|
||||
val apps = resolve(current.blockedApps)
|
||||
if (apps.isNotEmpty()) backend.unblockAll(apps)
|
||||
backend.setChainEnabled(false)
|
||||
try {
|
||||
backend.clearRules()
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (error: Throwable) {
|
||||
_error.value = error.message ?: error.toString()
|
||||
}
|
||||
store.setEnabled(false)
|
||||
}
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (error: Throwable) {
|
||||
_error.value = error.message ?: error.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun applyScheduledProfile(
|
||||
profileId: String,
|
||||
enableFirewall: Boolean?,
|
||||
): Boolean = operationMutex.withLock {
|
||||
_error.value = null
|
||||
val current = store.state.first()
|
||||
val target = current.profiles.firstOrNull { it.id == profileId } ?: return@withLock false
|
||||
val desiredEnabled = enableFirewall ?: current.enabled
|
||||
val targetApps = resolve(target.blockedApps)
|
||||
val currentApps = resolve(current.blockedApps)
|
||||
try {
|
||||
if (desiredEnabled != current.enabled) {
|
||||
store.setEnabled(desiredEnabled)
|
||||
}
|
||||
if (target.id != current.activeProfileId && !store.setActiveProfile(target.id)) {
|
||||
return@withLock false
|
||||
}
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (error: Throwable) {
|
||||
_error.value = error.message ?: error.toString()
|
||||
return@withLock false
|
||||
}
|
||||
try {
|
||||
if (desiredEnabled) {
|
||||
val removed = currentApps - targetApps
|
||||
if (removed.isNotEmpty()) backend.unblockAll(removed)
|
||||
backend.reapply(targetApps)
|
||||
} else {
|
||||
if (currentApps.isNotEmpty()) backend.unblockAll(currentApps)
|
||||
backend.setChainEnabled(false)
|
||||
try {
|
||||
backend.clearRules()
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (error: Throwable) {
|
||||
_error.value = error.message ?: error.toString()
|
||||
}
|
||||
}
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (error: Throwable) {
|
||||
_error.value = error.message ?: error.toString()
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
override suspend fun restore() {
|
||||
operationMutex.withLock {
|
||||
val current = store.state.first()
|
||||
if (!current.enabled) {
|
||||
_error.value = null
|
||||
return
|
||||
}
|
||||
try {
|
||||
backend.reapply(resolve(current.blockedApps))
|
||||
_error.value = null
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (error: Throwable) {
|
||||
_error.value = error.message ?: error.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun checkForDrift() {
|
||||
operationMutex.withLock {
|
||||
if (availability.value !is FirewallAvailability.Ready) {
|
||||
_drift.value = FirewallDriftState(FirewallDriftStatus.UNAVAILABLE)
|
||||
return
|
||||
}
|
||||
_drift.value = FirewallDriftState(FirewallDriftStatus.CHECKING)
|
||||
val current = store.state.first()
|
||||
val candidates = resolve(current.blockedApps)
|
||||
val expected = if (current.enabled) candidates else emptySet()
|
||||
try {
|
||||
val observation = backend.observe(candidates)
|
||||
if (!observation.covers(expected)) {
|
||||
if (!observation.hasAnyData && current.enabled) {
|
||||
_error.value = null
|
||||
backend.reapply(expected)
|
||||
}
|
||||
_drift.value = FirewallDriftState(FirewallDriftStatus.UNAVAILABLE)
|
||||
return
|
||||
}
|
||||
val drift = FirewallDriftDetector.detect(
|
||||
expectedEnabled = current.enabled,
|
||||
expectedBlocked = expected,
|
||||
candidates = candidates,
|
||||
observation = observation,
|
||||
)
|
||||
if (!drift.detected) {
|
||||
_drift.value = FirewallDriftState(FirewallDriftStatus.IN_SYNC, drift)
|
||||
return
|
||||
}
|
||||
_error.value = null
|
||||
repairDrift(current.enabled, expected, candidates)
|
||||
_drift.value = FirewallDriftState(FirewallDriftStatus.REPAIRED, drift)
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (error: Throwable) {
|
||||
_drift.value = FirewallDriftState(FirewallDriftStatus.DRIFT_DETECTED)
|
||||
_error.value = error.message ?: error.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun repairDrift(
|
||||
enabled: Boolean,
|
||||
expected: Set<BlockedApp>,
|
||||
candidates: Set<BlockedApp>,
|
||||
) {
|
||||
if (enabled) {
|
||||
backend.reapply(expected)
|
||||
return
|
||||
}
|
||||
if (candidates.isNotEmpty()) backend.unblockAll(candidates)
|
||||
backend.setChainEnabled(false)
|
||||
try {
|
||||
backend.clearRules()
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (error: Throwable) {
|
||||
_error.value = error.message ?: error.toString()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun refreshAvailability() {
|
||||
try {
|
||||
backend.refreshAvailability()
|
||||
_error.value = null
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (error: Throwable) {
|
||||
_error.value = error.message ?: error.toString()
|
||||
}
|
||||
}
|
||||
|
||||
override fun requestPermission() {
|
||||
backend.requestPermission()
|
||||
}
|
||||
|
||||
override suspend fun removeBlocked(key: AppKey) {
|
||||
operationMutex.withLock {
|
||||
val current = store.state.first()
|
||||
if (key !in current.blockedApps) return
|
||||
if (current.enabled) {
|
||||
replaceWith(current.blockedApps - key)
|
||||
}
|
||||
store.setBlocked(key, false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override suspend fun removeBlockedForUser(userId: Int) {
|
||||
operationMutex.withLock {
|
||||
val current = store.state.first()
|
||||
val keys = current.blockedApps.filter { it.userId == userId }
|
||||
if (keys.isEmpty()) return
|
||||
if (current.enabled) {
|
||||
replaceWith(current.blockedApps - keys.toSet())
|
||||
}
|
||||
keys.forEach { key -> store.setBlocked(key, false) }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun switchProfile(id: String) {
|
||||
operationMutex.withLock {
|
||||
_error.value = null
|
||||
val current = store.state.first()
|
||||
if (current.activeProfileId == id) return@withLock
|
||||
val target = current.profiles.firstOrNull { it.id == id } ?: return@withLock
|
||||
try {
|
||||
applyProfileChange(
|
||||
oldBlockedApps = current.blockedApps,
|
||||
newBlockedApps = target.blockedApps,
|
||||
enabled = current.enabled,
|
||||
)
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (error: Throwable) {
|
||||
_error.value = error.message ?: error.toString()
|
||||
return@withLock
|
||||
}
|
||||
if (!store.setActiveProfile(id)) {
|
||||
_error.value = "Could not switch profile"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun createProfile(name: String): Boolean = operationMutex.withLock {
|
||||
_error.value = null
|
||||
val current = store.state.first()
|
||||
val normalized = name.trim()
|
||||
val issue = validateProfileName(normalized, current.profiles)
|
||||
if (issue != null) {
|
||||
_error.value = issue.message()
|
||||
return@withLock false
|
||||
}
|
||||
try {
|
||||
store.createProfile(normalized)
|
||||
true
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (error: Throwable) {
|
||||
_error.value = error.message ?: error.toString()
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun duplicateProfile(sourceId: String, name: String): Boolean =
|
||||
operationMutex.withLock {
|
||||
_error.value = null
|
||||
val current = store.state.first()
|
||||
val source = current.profiles.firstOrNull { it.id == sourceId } ?: return@withLock false
|
||||
val normalized = name.trim().ifBlank {
|
||||
duplicateProfileName(source.name, current.profiles)
|
||||
}
|
||||
val issue = validateProfileName(normalized, current.profiles)
|
||||
if (issue != null) {
|
||||
_error.value = issue.message()
|
||||
return@withLock false
|
||||
}
|
||||
try {
|
||||
store.duplicateProfile(sourceId, normalized) != null
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (error: Throwable) {
|
||||
_error.value = error.message ?: error.toString()
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun renameProfile(id: String, name: String): Boolean =
|
||||
operationMutex.withLock {
|
||||
_error.value = null
|
||||
val current = store.state.first()
|
||||
val target = current.profiles.firstOrNull { it.id == id } ?: return@withLock false
|
||||
val normalized = name.trim()
|
||||
val issue = validateProfileName(normalized, current.profiles, excludingId = id)
|
||||
if (issue != null) {
|
||||
_error.value = issue.message()
|
||||
return@withLock false
|
||||
}
|
||||
if (target.name == normalized) return@withLock true
|
||||
try {
|
||||
store.renameProfile(id, normalized)
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (error: Throwable) {
|
||||
_error.value = error.message ?: error.toString()
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteProfile(id: String): Boolean = operationMutex.withLock {
|
||||
_error.value = null
|
||||
val current = store.state.first()
|
||||
current.profiles.firstOrNull { it.id == id } ?: return@withLock false
|
||||
if (current.profiles.size <= 1) {
|
||||
_error.value = "At least one profile is required"
|
||||
return@withLock false
|
||||
}
|
||||
if (current.activeProfileId == id) {
|
||||
val fallback = current.profiles
|
||||
.filterNot { it.id == id }
|
||||
.minWithOrNull(compareBy<RuleProfile> { it.createdAtMillis }.thenBy { it.id })
|
||||
?: return@withLock false
|
||||
try {
|
||||
applyProfileChange(
|
||||
oldBlockedApps = current.blockedApps,
|
||||
newBlockedApps = fallback.blockedApps,
|
||||
enabled = current.enabled,
|
||||
)
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (error: Throwable) {
|
||||
_error.value = error.message ?: error.toString()
|
||||
return@withLock false
|
||||
}
|
||||
}
|
||||
try {
|
||||
val deleted = store.deleteProfile(id)
|
||||
if (!deleted) _error.value = "Could not delete profile"
|
||||
deleted
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (error: Throwable) {
|
||||
_error.value = error.message ?: error.toString()
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun removeBlockedEverywhere(key: AppKey) {
|
||||
operationMutex.withLock {
|
||||
val current = store.state.first()
|
||||
if (key in current.blockedApps) {
|
||||
replaceWith(current.blockedApps - key)
|
||||
}
|
||||
store.removeBlockedEverywhere(key)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun removeBlockedForUserEverywhere(userId: Int) {
|
||||
operationMutex.withLock {
|
||||
val current = store.state.first()
|
||||
val keys = current.blockedApps.filter { it.userId == userId }.toSet()
|
||||
if (keys.isNotEmpty()) {
|
||||
replaceWith(current.blockedApps - keys)
|
||||
}
|
||||
store.removeBlockedForUserEverywhere(userId)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun applyProfileChange(
|
||||
oldBlockedApps: Set<AppKey>,
|
||||
newBlockedApps: Set<AppKey>,
|
||||
enabled: Boolean,
|
||||
) {
|
||||
val oldResolved = resolve(oldBlockedApps)
|
||||
val newResolved = resolve(newBlockedApps)
|
||||
val removed = oldResolved - newResolved
|
||||
if (enabled) {
|
||||
if (removed.isNotEmpty()) backend.unblockAll(removed)
|
||||
backend.reapply(newResolved)
|
||||
return
|
||||
}
|
||||
if (removed.isEmpty()) return
|
||||
try {
|
||||
backend.unblockAll(removed)
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun duplicateProfileName(
|
||||
sourceName: String,
|
||||
profiles: List<RuleProfile>,
|
||||
): String {
|
||||
val baseName = "$sourceName copy"
|
||||
if (profiles.none { it.name.equals(baseName, ignoreCase = true) }) return baseName
|
||||
var index = 2
|
||||
while (true) {
|
||||
val candidate = "$baseName $index"
|
||||
if (profiles.none { it.name.equals(candidate, ignoreCase = true) }) return candidate
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
|
||||
private fun ProfileNameIssue.message(): String = when (this) {
|
||||
ProfileNameIssue.BLANK -> "Profile name cannot be empty"
|
||||
ProfileNameIssue.TOO_LONG -> "Profile name is too long"
|
||||
ProfileNameIssue.DUPLICATE -> "A profile with this name already exists"
|
||||
}
|
||||
|
||||
private suspend fun replaceWith(keys: Set<AppKey>) {
|
||||
try {
|
||||
backend.reapply(resolve(keys))
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (error: Throwable) {
|
||||
_error.value = error.message ?: error.toString()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun resolve(keys: Set<AppKey>): Set<BlockedApp> = withContext(ioDispatcher) {
|
||||
keys.mapNotNull { key ->
|
||||
uidResolver.resolve(key)?.let { uid -> BlockedApp(key, uid) }
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DEFAULT_DRIFT_CHECK_INTERVAL_MS = 60_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.krrishg.kwall.firewall
|
||||
|
||||
object FirewallObservationParser {
|
||||
|
||||
fun chainEnabled(result: ShellResult): Boolean? {
|
||||
if (!result.isEffectivelySuccessful) return null
|
||||
val value = result.stdout
|
||||
.lineSequence()
|
||||
.map(String::trim)
|
||||
.firstOrNull { it.startsWith(CHAIN_PREFIX) }
|
||||
?.substringAfter(':')
|
||||
?.trim()
|
||||
?.lowercase()
|
||||
?: return null
|
||||
return when (value) {
|
||||
"enabled", "true" -> true
|
||||
"disabled", "false" -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun packageDenied(result: ShellResult, packageName: String): Boolean? {
|
||||
if (!result.isEffectivelySuccessful) return null
|
||||
val prefix = "$packageName:"
|
||||
val value = result.stdout
|
||||
.lineSequence()
|
||||
.map(String::trim)
|
||||
.lastOrNull { it.startsWith(prefix) }
|
||||
?.substringAfter(':')
|
||||
?.trim()
|
||||
?.lowercase()
|
||||
?: return null
|
||||
return when (value) {
|
||||
"deny", "denied", "false" -> true
|
||||
"allow", "allowed", "true" -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private const val CHAIN_PREFIX = "chain:"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.krrishg.kwall.firewall
|
||||
|
||||
data class FirewallState(
|
||||
val enabled: Boolean = false,
|
||||
val profiles: List<RuleProfile> = listOf(defaultRuleProfile()),
|
||||
val activeProfileId: String = DEFAULT_PROFILE_ID,
|
||||
) {
|
||||
val activeProfile: RuleProfile
|
||||
get() = profiles.firstOrNull { it.id == activeProfileId }
|
||||
?: profiles.firstOrNull()
|
||||
?: defaultRuleProfile()
|
||||
|
||||
val blockedApps: Set<AppKey>
|
||||
get() = activeProfile.blockedApps
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.krrishg.kwall.firewall
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface FirewallStateStore {
|
||||
val state: Flow<FirewallState>
|
||||
|
||||
suspend fun setEnabled(enabled: Boolean)
|
||||
|
||||
suspend fun setBlocked(key: AppKey, blocked: Boolean)
|
||||
|
||||
suspend fun createProfile(name: String): RuleProfile
|
||||
|
||||
suspend fun duplicateProfile(sourceId: String, name: String): RuleProfile?
|
||||
|
||||
suspend fun renameProfile(id: String, name: String): Boolean
|
||||
|
||||
suspend fun deleteProfile(id: String): Boolean
|
||||
|
||||
suspend fun setActiveProfile(id: String): Boolean
|
||||
|
||||
suspend fun removeBlockedEverywhere(key: AppKey)
|
||||
|
||||
suspend fun removeBlockedForUserEverywhere(userId: Int)
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package com.krrishg.kwall.firewall
|
||||
|
||||
import android.content.Context
|
||||
import android.os.DeadObjectException
|
||||
import android.os.IBinder
|
||||
import android.os.Process
|
||||
import android.util.Log
|
||||
import java.lang.reflect.InvocationTargetException
|
||||
import java.lang.reflect.Method
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
class FirewallUserService(
|
||||
private val appContext: Context? = null,
|
||||
) : IFirewallService.Stub() {
|
||||
|
||||
@Volatile
|
||||
private var cachedService: Any? = null
|
||||
|
||||
@Volatile
|
||||
private var cachedRuleMethod: Method? = null
|
||||
|
||||
@Volatile
|
||||
private var cachedChainMethod: Method? = null
|
||||
|
||||
@Volatile
|
||||
private var cachedReplaceMethod: Method? = null
|
||||
|
||||
@Volatile
|
||||
private var cachedRuleQueryMethod: Method? = null
|
||||
|
||||
@Volatile
|
||||
private var cachedChainQueryMethod: Method? = null
|
||||
|
||||
override fun probe(): String? =
|
||||
applyRule(FirewallCommands.CHAIN, Process.myUid(), FirewallCommands.RULE_DEFAULT)
|
||||
|
||||
override fun setUidFirewallRule(chain: Int, uid: Int, rule: Int): String? =
|
||||
applyRule(chain, uid, rule)
|
||||
|
||||
override fun setUidFirewallRules(chain: Int, uids: IntArray, rules: IntArray): Array<String?> {
|
||||
val size = minOf(uids.size, rules.size)
|
||||
return Array(size) { index -> applyRule(chain, uids[index], rules[index]) }
|
||||
}
|
||||
|
||||
override fun setFirewallChainEnabled(chain: Int, enabled: Boolean): String? {
|
||||
return try {
|
||||
val service = connectivityService() ?: return "Connectivity service is unavailable"
|
||||
val method = chainMethod(service) ?: return "setFirewallChainEnabled is unavailable"
|
||||
method.invoke(service, chain, enabled)
|
||||
null
|
||||
} catch (error: Throwable) {
|
||||
failure(error)
|
||||
}
|
||||
}
|
||||
|
||||
override fun replaceUidFirewallChain(chain: Int, uids: IntArray): String? {
|
||||
return try {
|
||||
val service = connectivityService() ?: return "Connectivity service is unavailable"
|
||||
val method = replaceMethod(service)
|
||||
if (method != null) {
|
||||
method.invoke(service, chain, uids)
|
||||
} else {
|
||||
val ruleMethod = ruleMethod(service)
|
||||
?: return "replaceFirewallChain and setUidFirewallRule are unavailable"
|
||||
uids.forEach { uid ->
|
||||
ruleMethod.invoke(service, chain, uid, FirewallCommands.RULE_DENY)
|
||||
}
|
||||
}
|
||||
null
|
||||
} catch (error: Throwable) {
|
||||
failure(error)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getUidFirewallRule(chain: Int, uid: Int): Int {
|
||||
return try {
|
||||
val service = connectivityService() ?: return FirewallCommands.RULE_UNKNOWN
|
||||
val method = ruleQueryMethod(service) ?: return FirewallCommands.RULE_UNKNOWN
|
||||
method.invoke(service, chain, uid) as? Int ?: FirewallCommands.RULE_UNKNOWN
|
||||
} catch (error: Throwable) {
|
||||
failure(error)
|
||||
FirewallCommands.RULE_UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
override fun getFirewallChainEnabled(chain: Int): Int {
|
||||
return try {
|
||||
val service = connectivityService() ?: return FirewallCommands.CHAIN_UNKNOWN
|
||||
val method = chainQueryMethod(service) ?: return FirewallCommands.CHAIN_UNKNOWN
|
||||
when (method.invoke(service, chain)) {
|
||||
true -> FirewallCommands.CHAIN_ENABLED
|
||||
false -> FirewallCommands.CHAIN_DISABLED
|
||||
else -> FirewallCommands.CHAIN_UNKNOWN
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
failure(error)
|
||||
FirewallCommands.CHAIN_UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
override fun destroy() {
|
||||
if (runCatching { isPackageRemoved() }.getOrDefault(false)) {
|
||||
runCatching { setFirewallChainEnabled(FirewallCommands.CHAIN, false) }
|
||||
runCatching { replaceUidFirewallChain(FirewallCommands.CHAIN, IntArray(0)) }
|
||||
}
|
||||
exitProcess(0)
|
||||
}
|
||||
|
||||
private fun isPackageRemoved(): Boolean {
|
||||
val context = appContext ?: return false
|
||||
repeat(UNINSTALL_CHECK_ATTEMPTS) {
|
||||
if (!isPackageInstalled(context)) {
|
||||
Thread.sleep(REMOVAL_CONFIRM_DELAY_MS)
|
||||
return !isPackageInstalled(context)
|
||||
}
|
||||
Thread.sleep(UNINSTALL_CHECK_DELAY_MS)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun isPackageInstalled(context: Context): Boolean =
|
||||
runCatching {
|
||||
context.packageManager.getApplicationInfo(context.packageName, 0)
|
||||
}.isSuccess
|
||||
|
||||
private fun applyRule(chain: Int, uid: Int, rule: Int): String? {
|
||||
return try {
|
||||
val service = connectivityService() ?: return "Connectivity service is unavailable"
|
||||
val method = ruleMethod(service) ?: return "setUidFirewallRule is unavailable"
|
||||
method.invoke(service, chain, uid, rule)
|
||||
null
|
||||
} catch (error: Throwable) {
|
||||
failure(error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ruleMethod(service: Any): Method? {
|
||||
cachedRuleMethod?.let { return it }
|
||||
val method = runCatching {
|
||||
service.javaClass.getMethod(
|
||||
"setUidFirewallRule",
|
||||
Int::class.javaPrimitiveType,
|
||||
Int::class.javaPrimitiveType,
|
||||
Int::class.javaPrimitiveType,
|
||||
)
|
||||
}.getOrNull() ?: return null
|
||||
cachedRuleMethod = method
|
||||
return method
|
||||
}
|
||||
|
||||
private fun chainMethod(service: Any): Method? {
|
||||
cachedChainMethod?.let { return it }
|
||||
val method = runCatching {
|
||||
service.javaClass.getMethod(
|
||||
"setFirewallChainEnabled",
|
||||
Int::class.javaPrimitiveType,
|
||||
Boolean::class.javaPrimitiveType,
|
||||
)
|
||||
}.getOrNull() ?: return null
|
||||
cachedChainMethod = method
|
||||
return method
|
||||
}
|
||||
|
||||
private fun replaceMethod(service: Any): Method? {
|
||||
cachedReplaceMethod?.let { return it }
|
||||
val method = runCatching {
|
||||
service.javaClass.getMethod(
|
||||
"replaceFirewallChain",
|
||||
Int::class.javaPrimitiveType,
|
||||
IntArray::class.java,
|
||||
)
|
||||
}.getOrNull() ?: return null
|
||||
cachedReplaceMethod = method
|
||||
return method
|
||||
}
|
||||
|
||||
private fun ruleQueryMethod(service: Any): Method? {
|
||||
cachedRuleQueryMethod?.let { return it }
|
||||
val method = runCatching {
|
||||
service.javaClass.getMethod(
|
||||
"getUidFirewallRule",
|
||||
Int::class.javaPrimitiveType,
|
||||
Int::class.javaPrimitiveType,
|
||||
)
|
||||
}.getOrNull() ?: return null
|
||||
cachedRuleQueryMethod = method
|
||||
return method
|
||||
}
|
||||
|
||||
private fun chainQueryMethod(service: Any): Method? {
|
||||
cachedChainQueryMethod?.let { return it }
|
||||
val method = runCatching {
|
||||
service.javaClass.getMethod(
|
||||
"getFirewallChainEnabled",
|
||||
Int::class.javaPrimitiveType,
|
||||
)
|
||||
}.getOrNull() ?: return null
|
||||
cachedChainQueryMethod = method
|
||||
return method
|
||||
}
|
||||
|
||||
private fun connectivityService(): Any? {
|
||||
cachedService?.let { return it }
|
||||
val binder = Class.forName("android.os.ServiceManager")
|
||||
.getMethod("getService", String::class.java)
|
||||
.invoke(null, "connectivity") as? IBinder ?: return null
|
||||
val service = Class.forName("android.net.IConnectivityManager\$Stub")
|
||||
.getMethod("asInterface", IBinder::class.java)
|
||||
.invoke(null, binder) ?: return null
|
||||
cachedService = service
|
||||
return service
|
||||
}
|
||||
|
||||
private fun failure(error: Throwable): String? {
|
||||
val cause = (error as? InvocationTargetException)?.targetException ?: error
|
||||
val text = cause.message ?: cause.toString()
|
||||
if (text.contains(UID_OWNER_MAP_MISSING, ignoreCase = true)) return null
|
||||
if (cause is DeadObjectException) {
|
||||
cachedService = null
|
||||
cachedRuleMethod = null
|
||||
cachedChainMethod = null
|
||||
cachedReplaceMethod = null
|
||||
cachedRuleQueryMethod = null
|
||||
cachedChainQueryMethod = null
|
||||
}
|
||||
Log.w(TAG, "Firewall binder call failed", cause)
|
||||
return text
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "FirewallUserService"
|
||||
const val UID_OWNER_MAP_MISSING = "suidownermap does not have entry for uid"
|
||||
const val UNINSTALL_CHECK_ATTEMPTS = 20
|
||||
const val UNINSTALL_CHECK_DELAY_MS = 100L
|
||||
const val REMOVAL_CONFIRM_DELAY_MS = 250L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.krrishg.kwall.firewall
|
||||
|
||||
const val DEFAULT_PROFILE_ID = "default"
|
||||
const val DEFAULT_PROFILE_NAME = "Default"
|
||||
const val MAX_PROFILE_NAME_LENGTH = 40
|
||||
|
||||
data class RuleProfile(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val blockedApps: Set<AppKey> = emptySet(),
|
||||
val createdAtMillis: Long = 0L,
|
||||
)
|
||||
|
||||
enum class ProfileNameIssue {
|
||||
BLANK,
|
||||
TOO_LONG,
|
||||
DUPLICATE,
|
||||
}
|
||||
|
||||
fun defaultRuleProfile(
|
||||
blockedApps: Set<AppKey> = emptySet(),
|
||||
createdAtMillis: Long = 0L,
|
||||
): RuleProfile = RuleProfile(
|
||||
id = DEFAULT_PROFILE_ID,
|
||||
name = DEFAULT_PROFILE_NAME,
|
||||
blockedApps = blockedApps,
|
||||
createdAtMillis = createdAtMillis,
|
||||
)
|
||||
|
||||
fun validateProfileName(
|
||||
name: String,
|
||||
profiles: List<RuleProfile>,
|
||||
excludingId: String? = null,
|
||||
): ProfileNameIssue? {
|
||||
val normalized = name.trim()
|
||||
if (normalized.isEmpty()) return ProfileNameIssue.BLANK
|
||||
if (normalized.length > MAX_PROFILE_NAME_LENGTH) return ProfileNameIssue.TOO_LONG
|
||||
val duplicate = profiles.any { profile ->
|
||||
profile.id != excludingId && profile.name.equals(normalized, ignoreCase = true)
|
||||
}
|
||||
return if (duplicate) ProfileNameIssue.DUPLICATE else null
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.krrishg.kwall.firewall
|
||||
|
||||
data class ShellResult(
|
||||
val exitCode: Int,
|
||||
val stdout: String,
|
||||
val stderr: String,
|
||||
) {
|
||||
val success: Boolean get() = exitCode == 0
|
||||
|
||||
private val details: String by lazy { "$stdout\n$stderr".lowercase() }
|
||||
|
||||
val isUnknownCommand: Boolean get() = details.contains("unknown command")
|
||||
|
||||
val isUnsupportedPlatform: Boolean
|
||||
get() = details.contains("not available on pre-t devices") ||
|
||||
details.contains("unknown command")
|
||||
|
||||
val isPermissionDenied: Boolean
|
||||
get() = details.contains("permission denied") ||
|
||||
details.contains("securityexception")
|
||||
|
||||
val isSystemAppError: Boolean get() = details.contains("system app")
|
||||
|
||||
val isPackageNotFound: Boolean
|
||||
get() = details.contains("namenotfound") ||
|
||||
details.contains("name not found") ||
|
||||
details.contains("not installed")
|
||||
|
||||
val isUidOwnerMapMissing: Boolean
|
||||
get() = details.contains("suidownermap does not have entry for uid")
|
||||
|
||||
val isEffectivelySuccessful: Boolean
|
||||
get() = (success && !isUnknownCommand && !isUnsupportedPlatform) || isUidOwnerMapMissing
|
||||
|
||||
val errorMessage: String
|
||||
get() = stderr.trim().ifBlank { stdout.trim() }.ifBlank { "Command failed" }
|
||||
}
|
||||
|
||||
interface ShellExecutor {
|
||||
suspend fun exec(command: String): ShellResult
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package com.krrishg.kwall.firewall
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import rikka.shizuku.Shizuku
|
||||
|
||||
class ShizukuFirewallBackend(
|
||||
context: Context,
|
||||
private val shell: ShellExecutor,
|
||||
private val binder: BinderFirewallClient,
|
||||
private val scope: CoroutineScope,
|
||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) : FirewallBackend {
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
private val _availability = MutableStateFlow<FirewallAvailability>(FirewallAvailability.Checking)
|
||||
override val availability: StateFlow<FirewallAvailability> = _availability.asStateFlow()
|
||||
|
||||
@Volatile
|
||||
private var shellChainAvailable = false
|
||||
|
||||
@Volatile
|
||||
private var shellRulesAvailable = false
|
||||
|
||||
init {
|
||||
Shizuku.addBinderReceivedListener {
|
||||
scope.launch { refreshAvailability() }
|
||||
}
|
||||
Shizuku.addBinderDeadListener {
|
||||
_availability.value = FirewallAvailability.ShizukuNotRunning
|
||||
}
|
||||
Shizuku.addRequestPermissionResultListener { _, _ ->
|
||||
scope.launch { refreshAvailability() }
|
||||
}
|
||||
scope.launch { refreshAvailability() }
|
||||
}
|
||||
|
||||
override suspend fun refreshAvailability(): Unit = withContext(ioDispatcher) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
_availability.value = FirewallAvailability.UnsupportedAndroid
|
||||
return@withContext
|
||||
}
|
||||
if (!isShizukuInstalled()) {
|
||||
_availability.value = FirewallAvailability.ShizukuNotInstalled
|
||||
return@withContext
|
||||
}
|
||||
if (!runCatching { Shizuku.pingBinder() }.getOrDefault(false)) {
|
||||
_availability.value = FirewallAvailability.ShizukuNotRunning
|
||||
return@withContext
|
||||
}
|
||||
val permission = runCatching { Shizuku.checkSelfPermission() }.getOrElse {
|
||||
_availability.value = FirewallAvailability.ShizukuNotRunning
|
||||
return@withContext
|
||||
}
|
||||
if (permission != PackageManager.PERMISSION_GRANTED) {
|
||||
_availability.value = FirewallAvailability.PermissionDenied
|
||||
return@withContext
|
||||
}
|
||||
|
||||
val chainProbe = runCatching { shell.exec(FirewallCommands.chainState()) }.getOrNull()
|
||||
shellChainAvailable = chainProbe?.let {
|
||||
it.success && !it.isUnknownCommand && !it.isUnsupportedPlatform
|
||||
} == true
|
||||
|
||||
val rulesProbe = runCatching {
|
||||
shell.exec(FirewallCommands.packageState(appContext.packageName))
|
||||
}.getOrNull()
|
||||
shellRulesAvailable = rulesProbe?.let {
|
||||
it.success && !it.isUnknownCommand && !it.isUnsupportedPlatform
|
||||
} == true
|
||||
|
||||
val binderRulesAvailable = runCatching { binder.probe() }.getOrDefault(false)
|
||||
val chainTransport = when {
|
||||
shellChainAvailable -> ChainTransport.SHELL
|
||||
binderRulesAvailable -> ChainTransport.BINDER
|
||||
else -> null
|
||||
}
|
||||
val rulesTransport = when {
|
||||
binderRulesAvailable -> RulesTransport.BINDER
|
||||
shellRulesAvailable -> RulesTransport.SHELL
|
||||
else -> null
|
||||
}
|
||||
_availability.value = if (chainTransport != null && rulesTransport != null) {
|
||||
FirewallAvailability.Ready(rulesTransport, chainTransport)
|
||||
} else {
|
||||
FirewallAvailability.CommandUnavailable
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setChainEnabled(enabled: Boolean): Unit = withContext(ioDispatcher) {
|
||||
val ready = readyOrThrow()
|
||||
when (ready.chainTransport) {
|
||||
ChainTransport.SHELL -> setChainViaShell(enabled, ready)
|
||||
ChainTransport.BINDER -> setChainViaBinder(enabled, ready)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun block(app: BlockedApp): Unit = withContext(ioDispatcher) {
|
||||
if (app.uid % PER_USER_RANGE < FIRST_APPLICATION_UID) {
|
||||
throw FirewallException("System UID apps cannot be blocked")
|
||||
}
|
||||
val ready = readyOrThrow()
|
||||
if (ready.rulesTransport == RulesTransport.BINDER) {
|
||||
val binderError = runCatching {
|
||||
binder.setUidFirewallRule(FirewallCommands.CHAIN, app.uid, FirewallCommands.RULE_DENY)
|
||||
}.exceptionOrNull()
|
||||
if (binderError == null) return@withContext
|
||||
if (!shellRulesAvailable) throw FirewallException(binderError.message ?: "Binder rule failed", binderError)
|
||||
_availability.value = ready.copy(rulesTransport = RulesTransport.SHELL)
|
||||
}
|
||||
blockViaShell(app.key.packageName, throwOnMissing = true)
|
||||
}
|
||||
|
||||
override suspend fun unblock(app: BlockedApp): Unit = withContext(ioDispatcher) {
|
||||
val ready = readyOrThrow()
|
||||
if (ready.rulesTransport == RulesTransport.BINDER) {
|
||||
val binderError = runCatching {
|
||||
binder.setUidFirewallRule(FirewallCommands.CHAIN, app.uid, FirewallCommands.RULE_DEFAULT)
|
||||
}.exceptionOrNull()
|
||||
if (binderError == null) return@withContext
|
||||
if (!shellRulesAvailable) throw FirewallException(binderError.message ?: "Binder rule failed", binderError)
|
||||
_availability.value = ready.copy(rulesTransport = RulesTransport.SHELL)
|
||||
}
|
||||
unblockViaShell(app.key.packageName)
|
||||
}
|
||||
|
||||
override suspend fun reapply(apps: Set<BlockedApp>): Unit = withContext(ioDispatcher) {
|
||||
val ready = readyOrThrow()
|
||||
when (ready.chainTransport) {
|
||||
ChainTransport.SHELL -> setChainViaShell(true, ready)
|
||||
ChainTransport.BINDER -> setChainViaBinder(true, ready)
|
||||
}
|
||||
if (ready.rulesTransport == RulesTransport.BINDER) {
|
||||
val uids = apps.map { it.uid }.toIntArray()
|
||||
val replaceError = runCatching {
|
||||
binder.replaceUidFirewallChain(FirewallCommands.CHAIN, uids)
|
||||
}.exceptionOrNull()
|
||||
if (replaceError == null) return@withContext
|
||||
if (!shellRulesAvailable) {
|
||||
throw FirewallException(replaceError.message ?: "Binder rule failed", replaceError)
|
||||
}
|
||||
_availability.value = ready.copy(rulesTransport = RulesTransport.SHELL)
|
||||
}
|
||||
apps.forEach { blockViaShell(it.key.packageName, throwOnMissing = false) }
|
||||
}
|
||||
|
||||
override suspend fun clearRules(): Unit = withContext(ioDispatcher) {
|
||||
val ready = readyOrThrow()
|
||||
if (ready.rulesTransport != RulesTransport.BINDER) return@withContext
|
||||
val replaceError = runCatching {
|
||||
binder.replaceUidFirewallChain(FirewallCommands.CHAIN, IntArray(0))
|
||||
}.exceptionOrNull() ?: return@withContext
|
||||
if (!shellRulesAvailable) {
|
||||
throw FirewallException(replaceError.message ?: "Could not clear firewall rules", replaceError)
|
||||
}
|
||||
_availability.value = ready.copy(rulesTransport = RulesTransport.SHELL)
|
||||
}
|
||||
|
||||
override suspend fun unblockAll(apps: Set<BlockedApp>): Unit = withContext(ioDispatcher) {
|
||||
if (apps.isEmpty()) return@withContext
|
||||
val ready = readyOrThrow()
|
||||
if (ready.rulesTransport == RulesTransport.BINDER) {
|
||||
val uids = apps.map { it.uid }.toIntArray()
|
||||
val rules = IntArray(uids.size).apply { fill(FirewallCommands.RULE_DEFAULT) }
|
||||
val errors = runCatching {
|
||||
binder.setUidFirewallRules(FirewallCommands.CHAIN, uids, rules)
|
||||
}.getOrElse { error ->
|
||||
if (!shellRulesAvailable) throw error
|
||||
apps.forEach { unblockViaShell(it.key.packageName) }
|
||||
return@withContext
|
||||
}
|
||||
val failure = errors.firstOrNull { !it.isNullOrBlank() }
|
||||
if (failure != null) {
|
||||
if (shellRulesAvailable) {
|
||||
_availability.value = ready.copy(rulesTransport = RulesTransport.SHELL)
|
||||
apps.forEach { unblockViaShell(it.key.packageName) }
|
||||
return@withContext
|
||||
}
|
||||
throw FirewallException(failure)
|
||||
}
|
||||
return@withContext
|
||||
}
|
||||
apps.forEach { unblockViaShell(it.key.packageName) }
|
||||
}
|
||||
|
||||
override suspend fun observe(apps: Set<BlockedApp>): FirewallObservation =
|
||||
withContext(ioDispatcher) {
|
||||
val ready = _availability.value as? FirewallAvailability.Ready
|
||||
?: return@withContext FirewallObservation()
|
||||
val appStates = apps
|
||||
.mapNotNull { app ->
|
||||
observeDenied(app, ready)?.let { denied -> app.key to denied }
|
||||
}
|
||||
.toMap()
|
||||
FirewallObservation(
|
||||
chainEnabled = observeChainEnabled(ready),
|
||||
appStates = appStates,
|
||||
)
|
||||
}
|
||||
|
||||
override fun requestPermission() {
|
||||
if (runCatching { Shizuku.pingBinder() }.getOrDefault(false)) {
|
||||
runCatching { Shizuku.requestPermission(REQUEST_CODE) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun observeChainEnabled(ready: FirewallAvailability.Ready): Boolean? =
|
||||
when (ready.chainTransport) {
|
||||
ChainTransport.SHELL -> observeChainViaShell() ?: observeChainViaBinder()
|
||||
ChainTransport.BINDER -> observeChainViaBinder() ?: observeChainViaShell()
|
||||
}
|
||||
|
||||
private suspend fun observeChainViaShell(): Boolean? {
|
||||
if (!shellChainAvailable) return null
|
||||
val result = runCatching { shell.exec(FirewallCommands.chainState()) }.getOrNull()
|
||||
?: return null
|
||||
return FirewallObservationParser.chainEnabled(result)
|
||||
}
|
||||
|
||||
private suspend fun observeChainViaBinder(): Boolean? =
|
||||
runCatching { binder.getFirewallChainEnabled(FirewallCommands.CHAIN) }.getOrNull()
|
||||
|
||||
private suspend fun observeDenied(
|
||||
app: BlockedApp,
|
||||
ready: FirewallAvailability.Ready,
|
||||
): Boolean? = when (ready.rulesTransport) {
|
||||
RulesTransport.BINDER -> observeDeniedViaBinder(app) ?: observeDeniedViaShell(app)
|
||||
RulesTransport.SHELL -> observeDeniedViaShell(app) ?: observeDeniedViaBinder(app)
|
||||
}
|
||||
|
||||
private suspend fun observeDeniedViaShell(app: BlockedApp): Boolean? {
|
||||
if (!shellRulesAvailable || app.key.userId != SHELL_USER_ID) return null
|
||||
val result = runCatching {
|
||||
shell.exec(FirewallCommands.packageState(app.key.packageName))
|
||||
}.getOrNull() ?: return null
|
||||
return FirewallObservationParser.packageDenied(result, app.key.packageName)
|
||||
}
|
||||
|
||||
private suspend fun observeDeniedViaBinder(app: BlockedApp): Boolean? {
|
||||
val rule = runCatching {
|
||||
binder.getUidFirewallRule(FirewallCommands.CHAIN, app.uid)
|
||||
}.getOrNull() ?: return null
|
||||
return rule == FirewallCommands.RULE_DENY
|
||||
}
|
||||
|
||||
private suspend fun readyOrThrow(): FirewallAvailability.Ready {
|
||||
val current = _availability.value
|
||||
if (current is FirewallAvailability.Ready) return current
|
||||
refreshAvailability()
|
||||
return _availability.value as? FirewallAvailability.Ready
|
||||
?: throw FirewallException("Shizuku firewall is unavailable")
|
||||
}
|
||||
|
||||
private suspend fun setChainViaShell(enabled: Boolean, ready: FirewallAvailability.Ready) {
|
||||
val result = shell.exec(FirewallCommands.chainEnabled(enabled))
|
||||
if (result.isEffectivelySuccessful) return
|
||||
if (ready.chainTransport == ChainTransport.SHELL) {
|
||||
setChainViaBinder(enabled, ready)
|
||||
} else {
|
||||
throw FirewallException(result.errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun setChainViaBinder(enabled: Boolean, ready: FirewallAvailability.Ready) {
|
||||
val error = runCatching {
|
||||
binder.setFirewallChainEnabled(FirewallCommands.CHAIN, enabled)
|
||||
}.exceptionOrNull()
|
||||
if (error == null) return
|
||||
if (shellChainAvailable && ready.chainTransport == ChainTransport.BINDER) {
|
||||
val result = shell.exec(FirewallCommands.chainEnabled(enabled))
|
||||
if (result.isEffectivelySuccessful) return
|
||||
}
|
||||
throw FirewallException(error.message ?: "Could not change firewall chain", error)
|
||||
}
|
||||
|
||||
private suspend fun blockViaShell(packageName: String, throwOnMissing: Boolean) {
|
||||
val result = shell.exec(FirewallCommands.packageNetworking(packageName, false))
|
||||
if (result.isEffectivelySuccessful) return
|
||||
if (result.isPackageNotFound && !throwOnMissing) return
|
||||
if (result.isSystemAppError) throw FirewallException("System apps cannot be blocked")
|
||||
throw FirewallException(result.errorMessage)
|
||||
}
|
||||
|
||||
private suspend fun unblockViaShell(packageName: String) {
|
||||
val result = shell.exec(FirewallCommands.packageNetworking(packageName, true))
|
||||
if (result.isEffectivelySuccessful) return
|
||||
if (result.isPackageNotFound || result.isSystemAppError) return
|
||||
throw FirewallException(result.errorMessage)
|
||||
}
|
||||
|
||||
private fun isShizukuInstalled(): Boolean =
|
||||
runCatching {
|
||||
appContext.packageManager.getPackageInfo(SHIZUKU_PACKAGE, 0)
|
||||
}.isSuccess
|
||||
|
||||
private companion object {
|
||||
const val SHIZUKU_PACKAGE = "moe.shizuku.privileged.api"
|
||||
const val REQUEST_CODE = 1001
|
||||
const val FIRST_APPLICATION_UID = 10_000
|
||||
const val PER_USER_RANGE = 100_000
|
||||
const val SHELL_USER_ID = 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.krrishg.kwall.network
|
||||
|
||||
sealed interface ConnectionSnapshot {
|
||||
data class Available(val sockets: List<SocketEntry>) : ConnectionSnapshot
|
||||
|
||||
data class Unavailable(val reason: String) : ConnectionSnapshot
|
||||
}
|
||||
|
||||
interface ConnectionInspector {
|
||||
suspend fun snapshot(): ConnectionSnapshot
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.krrishg.kwall.network
|
||||
|
||||
import android.app.AppOpsManager
|
||||
import android.app.usage.NetworkStats
|
||||
import android.app.usage.NetworkStatsManager
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.Build
|
||||
import android.os.Process
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class NetworkStatsUsageSource(
|
||||
context: Context,
|
||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
private val clock: () -> Long = System::currentTimeMillis,
|
||||
) : NetworkUsageSource {
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
private val appOps: AppOpsManager? = appContext.getSystemService(AppOpsManager::class.java)
|
||||
private val statsManager: NetworkStatsManager? =
|
||||
appContext.getSystemService(NetworkStatsManager::class.java)
|
||||
|
||||
override fun hasUsageAccess(): Boolean {
|
||||
val ops = appOps ?: return false
|
||||
val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
ops.unsafeCheckOpNoThrow(
|
||||
AppOpsManager.OPSTR_GET_USAGE_STATS,
|
||||
Process.myUid(),
|
||||
appContext.packageName,
|
||||
)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
ops.checkOpNoThrow(
|
||||
AppOpsManager.OPSTR_GET_USAGE_STATS,
|
||||
Process.myUid(),
|
||||
appContext.packageName,
|
||||
)
|
||||
}
|
||||
return mode == AppOpsManager.MODE_ALLOWED
|
||||
}
|
||||
|
||||
override suspend fun usageFor(uid: Int, range: UsageRange): UsageResult =
|
||||
withContext(ioDispatcher) {
|
||||
if (!hasUsageAccess()) return@withContext UsageResult.PermissionRequired
|
||||
val end = clock()
|
||||
val start = UsageWindow.start(range, end)
|
||||
val outcomes = listOf(
|
||||
runCatching { query(ConnectivityManager.TYPE_WIFI, uid, start, end) },
|
||||
runCatching { query(ConnectivityManager.TYPE_MOBILE, uid, start, end) },
|
||||
)
|
||||
val usages = outcomes.mapNotNull { it.getOrNull() }
|
||||
if (usages.isEmpty()) {
|
||||
val error = outcomes.firstNotNullOfOrNull { it.exceptionOrNull() }
|
||||
return@withContext UsageResult.Unavailable(
|
||||
error?.message ?: "Data usage is unavailable",
|
||||
)
|
||||
}
|
||||
UsageResult.Available(usages.fold(DataUsage.Zero) { total, usage -> total + usage })
|
||||
}
|
||||
|
||||
private fun query(type: Int, uid: Int, start: Long, end: Long): DataUsage? {
|
||||
val manager = statsManager ?: return null
|
||||
return manager.queryDetailsForUid(type, null, start, end, uid).use { stats ->
|
||||
var received = 0L
|
||||
var sent = 0L
|
||||
val bucket = NetworkStats.Bucket()
|
||||
while (stats.hasNextBucket()) {
|
||||
stats.getNextBucket(bucket)
|
||||
received += bucket.rxBytes
|
||||
sent += bucket.txBytes
|
||||
}
|
||||
DataUsage(receivedBytes = received, sentBytes = sent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.krrishg.kwall.network
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
|
||||
data class DataUsage(
|
||||
val receivedBytes: Long = 0,
|
||||
val sentBytes: Long = 0,
|
||||
) {
|
||||
val totalBytes: Long get() = receivedBytes + sentBytes
|
||||
|
||||
operator fun plus(other: DataUsage): DataUsage = DataUsage(
|
||||
receivedBytes = receivedBytes + other.receivedBytes,
|
||||
sentBytes = sentBytes + other.sentBytes,
|
||||
)
|
||||
|
||||
companion object {
|
||||
val Zero = DataUsage()
|
||||
}
|
||||
}
|
||||
|
||||
enum class UsageRange {
|
||||
TODAY,
|
||||
WEEK,
|
||||
MONTH,
|
||||
}
|
||||
|
||||
object UsageWindow {
|
||||
fun start(range: UsageRange, nowMillis: Long, zone: ZoneId = ZoneId.systemDefault()): Long {
|
||||
val today = Instant.ofEpochMilli(nowMillis).atZone(zone).toLocalDate()
|
||||
val start = when (range) {
|
||||
UsageRange.TODAY -> today
|
||||
UsageRange.WEEK -> today.minusDays(6)
|
||||
UsageRange.MONTH -> today.minusDays(29)
|
||||
}
|
||||
return start.atStartOfDay(zone).toInstant().toEpochMilli()
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface UsageResult {
|
||||
data class Available(val usage: DataUsage) : UsageResult
|
||||
|
||||
data object PermissionRequired : UsageResult
|
||||
|
||||
data class Unavailable(val reason: String) : UsageResult
|
||||
}
|
||||
|
||||
interface NetworkUsageSource {
|
||||
fun hasUsageAccess(): Boolean
|
||||
|
||||
suspend fun usageFor(uid: Int, range: UsageRange): UsageResult
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.krrishg.kwall.network
|
||||
|
||||
import com.krrishg.kwall.firewall.ShellExecutor
|
||||
import com.krrishg.kwall.firewall.ShellResult
|
||||
|
||||
class ProcNetConnectionInspector(
|
||||
private val shell: ShellExecutor,
|
||||
) : ConnectionInspector {
|
||||
|
||||
override suspend fun snapshot(): ConnectionSnapshot {
|
||||
val result = shell.exec(COMMAND)
|
||||
val sections = parseSections(result.stdout)
|
||||
if (sections.isEmpty()) {
|
||||
return ConnectionSnapshot.Unavailable(unavailableReason(result))
|
||||
}
|
||||
|
||||
val sockets = mutableListOf<SocketEntry>()
|
||||
var readableSections = 0
|
||||
for ((name, body) in sections) {
|
||||
if (body.containsAnyDenial()) continue
|
||||
val protocol = PROTOCOLS[name] ?: continue
|
||||
readableSections++
|
||||
sockets += ProcNetParser.parse(protocol, body)
|
||||
}
|
||||
|
||||
if (readableSections == 0) {
|
||||
return ConnectionSnapshot.Unavailable(unavailableReason(result))
|
||||
}
|
||||
return ConnectionSnapshot.Available(sockets)
|
||||
}
|
||||
|
||||
private fun parseSections(output: String): Map<String, String> {
|
||||
val sections = linkedMapOf<String, StringBuilder>()
|
||||
var current: StringBuilder? = null
|
||||
for (line in output.lineSequence()) {
|
||||
val marker = MARKER.matchEntire(line.trim())
|
||||
if (marker != null) {
|
||||
current = StringBuilder()
|
||||
sections[marker.groupValues[1]] = current
|
||||
continue
|
||||
}
|
||||
current?.appendLine(line)
|
||||
}
|
||||
return sections.mapValues { (_, body) -> body.toString() }
|
||||
}
|
||||
|
||||
private fun unavailableReason(result: ShellResult): String {
|
||||
val details = "${result.stderr}\n${result.stdout}".trim()
|
||||
return when {
|
||||
details.isBlank() -> "Socket information is unavailable"
|
||||
details.containsAnyDenial() -> "This device does not allow reading socket information"
|
||||
else -> details.lineSequence().firstOrNull { it.isNotBlank() }
|
||||
?: "Socket information is unavailable"
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.containsAnyDenial(): Boolean =
|
||||
contains("permission denied", ignoreCase = true) ||
|
||||
contains("no such file", ignoreCase = true) ||
|
||||
contains("not permitted", ignoreCase = true) ||
|
||||
contains("operation not permitted", ignoreCase = true)
|
||||
|
||||
private companion object {
|
||||
private val MARKER = Regex("===\\s*(\\w+)\\s*===")
|
||||
private val PROTOCOLS = mapOf(
|
||||
"tcp" to SocketProtocol.TCP,
|
||||
"tcp6" to SocketProtocol.TCP6,
|
||||
"udp" to SocketProtocol.UDP,
|
||||
"udp6" to SocketProtocol.UDP6,
|
||||
)
|
||||
private const val COMMAND =
|
||||
"for f in tcp tcp6 udp udp6; do echo \"=== \$f ===\"; cat \"/proc/net/\$f\" 2>&1; done"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.krrishg.kwall.network
|
||||
|
||||
import java.net.InetAddress
|
||||
|
||||
object ProcNetParser {
|
||||
|
||||
private val WHITESPACE = Regex("\\s+")
|
||||
private const val WORD_LENGTH = 8
|
||||
private const val IPV4_LENGTH = 8
|
||||
private const val IPV6_LENGTH = 32
|
||||
private const val UID_INDEX = 7
|
||||
private const val STATE_INDEX = 3
|
||||
private const val LOCAL_INDEX = 1
|
||||
private const val REMOTE_INDEX = 2
|
||||
private const val MIN_FIELDS = 10
|
||||
|
||||
fun parse(protocol: SocketProtocol, output: String): List<SocketEntry> =
|
||||
output.lineSequence()
|
||||
.mapNotNull { line -> runCatching { parseLine(protocol, line) }.getOrNull() }
|
||||
.toList()
|
||||
|
||||
private fun parseLine(protocol: SocketProtocol, line: String): SocketEntry? {
|
||||
val trimmed = line.trim()
|
||||
if (trimmed.isEmpty() || trimmed.startsWith("sl")) return null
|
||||
val fields = trimmed.split(WHITESPACE)
|
||||
if (fields.size < MIN_FIELDS || !fields[0].endsWith(':')) return null
|
||||
val local = parseEndpoint(fields[LOCAL_INDEX]) ?: return null
|
||||
val remote = parseEndpoint(fields[REMOTE_INDEX]) ?: return null
|
||||
val uid = fields[UID_INDEX].toIntOrNull() ?: return null
|
||||
val state = if (protocol.isTcp) TcpState.fromHex(fields[STATE_INDEX]) else null
|
||||
return SocketEntry(
|
||||
uid = uid,
|
||||
protocol = protocol,
|
||||
localAddress = local.address,
|
||||
localPort = local.port,
|
||||
remoteAddress = remote.address,
|
||||
remotePort = remote.port,
|
||||
state = state,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseEndpoint(field: String): Endpoint? {
|
||||
val separator = field.indexOf(':')
|
||||
if (separator <= 0 || separator == field.lastIndex) return null
|
||||
val hexAddress = field.substring(0, separator)
|
||||
if (hexAddress.length != IPV4_LENGTH && hexAddress.length != IPV6_LENGTH) return null
|
||||
val address = if (hexAddress.all { it == '0' }) null else decodeAddress(hexAddress)
|
||||
val port = field.substring(separator + 1).toIntOrNull(16) ?: return null
|
||||
return Endpoint(address, port)
|
||||
}
|
||||
|
||||
private fun decodeAddress(hex: String): InetAddress? {
|
||||
val bytes = hex.chunked(WORD_LENGTH)
|
||||
.flatMap { word ->
|
||||
word.chunked(2)
|
||||
.map { pair -> pair.toInt(16).toByte() }
|
||||
.reversed()
|
||||
}
|
||||
.toByteArray()
|
||||
return InetAddress.getByAddress(bytes)
|
||||
}
|
||||
|
||||
private data class Endpoint(
|
||||
val address: InetAddress?,
|
||||
val port: Int,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.krrishg.kwall.network
|
||||
|
||||
import java.net.InetAddress
|
||||
|
||||
enum class SocketProtocol {
|
||||
TCP,
|
||||
TCP6,
|
||||
UDP,
|
||||
UDP6,
|
||||
;
|
||||
|
||||
val isTcp: Boolean get() = this == TCP || this == TCP6
|
||||
|
||||
val label: String get() = if (isTcp) "TCP" else "UDP"
|
||||
}
|
||||
|
||||
enum class TcpState(val code: String) {
|
||||
ESTABLISHED("01"),
|
||||
SYN_SENT("02"),
|
||||
SYN_RECV("03"),
|
||||
FIN_WAIT1("04"),
|
||||
FIN_WAIT2("05"),
|
||||
TIME_WAIT("06"),
|
||||
CLOSE("07"),
|
||||
CLOSE_WAIT("08"),
|
||||
LAST_ACK("09"),
|
||||
LISTEN("0A"),
|
||||
CLOSING("0B"),
|
||||
NEW_SYN_RECV("0C"),
|
||||
UNKNOWN(""),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromHex(hex: String): TcpState =
|
||||
entries.firstOrNull { it.code == hex.uppercase() } ?: UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
data class SocketEntry(
|
||||
val uid: Int,
|
||||
val protocol: SocketProtocol,
|
||||
val localAddress: InetAddress?,
|
||||
val localPort: Int,
|
||||
val remoteAddress: InetAddress?,
|
||||
val remotePort: Int,
|
||||
val state: TcpState?,
|
||||
) {
|
||||
val isListening: Boolean get() = protocol.isTcp && state == TcpState.LISTEN
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.krrishg.kwall.notifications
|
||||
|
||||
import com.krrishg.kwall.firewall.FirewallAvailability
|
||||
|
||||
enum class FirewallNotificationTitle {
|
||||
FIREWALL,
|
||||
ON,
|
||||
OFF,
|
||||
PAUSED,
|
||||
ATTENTION,
|
||||
}
|
||||
|
||||
enum class FirewallNotificationAction {
|
||||
PAUSE,
|
||||
RESUME,
|
||||
OPEN,
|
||||
}
|
||||
|
||||
sealed interface FirewallNotificationText {
|
||||
data object NoBlockedApps : FirewallNotificationText
|
||||
data object NoSelectedApps : FirewallNotificationText
|
||||
data class BlockedApps(val count: Int) : FirewallNotificationText
|
||||
data class SelectedApps(val count: Int) : FirewallNotificationText
|
||||
data class Availability(val availability: FirewallAvailability) : FirewallNotificationText
|
||||
data class Error(val message: String) : FirewallNotificationText
|
||||
}
|
||||
|
||||
data class FirewallNotificationContent(
|
||||
val title: FirewallNotificationTitle,
|
||||
val text: FirewallNotificationText,
|
||||
val action: FirewallNotificationAction,
|
||||
val directToggle: Boolean,
|
||||
)
|
||||
|
||||
fun firewallNotificationContent(
|
||||
enabled: Boolean,
|
||||
appCount: Int,
|
||||
availability: FirewallAvailability,
|
||||
error: String?,
|
||||
appLockEnabled: Boolean,
|
||||
): FirewallNotificationContent {
|
||||
if (error != null) {
|
||||
return FirewallNotificationContent(
|
||||
title = FirewallNotificationTitle.ATTENTION,
|
||||
text = FirewallNotificationText.Error(error),
|
||||
action = FirewallNotificationAction.OPEN,
|
||||
directToggle = false,
|
||||
)
|
||||
}
|
||||
if (availability == FirewallAvailability.Checking) {
|
||||
return FirewallNotificationContent(
|
||||
title = FirewallNotificationTitle.FIREWALL,
|
||||
text = FirewallNotificationText.Availability(availability),
|
||||
action = FirewallNotificationAction.OPEN,
|
||||
directToggle = false,
|
||||
)
|
||||
}
|
||||
if (availability !is FirewallAvailability.Ready) {
|
||||
return FirewallNotificationContent(
|
||||
title = if (enabled) {
|
||||
FirewallNotificationTitle.PAUSED
|
||||
} else {
|
||||
FirewallNotificationTitle.OFF
|
||||
},
|
||||
text = FirewallNotificationText.Availability(availability),
|
||||
action = FirewallNotificationAction.OPEN,
|
||||
directToggle = false,
|
||||
)
|
||||
}
|
||||
if (enabled) {
|
||||
return FirewallNotificationContent(
|
||||
title = FirewallNotificationTitle.ON,
|
||||
text = if (appCount == 0) {
|
||||
FirewallNotificationText.NoBlockedApps
|
||||
} else {
|
||||
FirewallNotificationText.BlockedApps(appCount)
|
||||
},
|
||||
action = FirewallNotificationAction.PAUSE,
|
||||
directToggle = !appLockEnabled,
|
||||
)
|
||||
}
|
||||
return FirewallNotificationContent(
|
||||
title = FirewallNotificationTitle.OFF,
|
||||
text = if (appCount == 0) {
|
||||
FirewallNotificationText.NoSelectedApps
|
||||
} else {
|
||||
FirewallNotificationText.SelectedApps(appCount)
|
||||
},
|
||||
action = FirewallNotificationAction.RESUME,
|
||||
directToggle = !appLockEnabled,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package com.krrishg.kwall.notifications
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
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.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.krrishg.kwall.MainActivity
|
||||
import com.krrishg.kwall.R
|
||||
import com.krrishg.kwall.firewall.FirewallAvailability
|
||||
import com.krrishg.kwall.receivers.FirewallNotificationReceiver
|
||||
|
||||
object FirewallNotifications {
|
||||
|
||||
const val CHANNEL_ID = "firewall_status"
|
||||
const val AUTO_BLOCK_CHANNEL_ID = "auto_block"
|
||||
const val NOTIFICATION_ID = 1001
|
||||
const val AUTO_BLOCK_NOTIFICATION_ID = 1002
|
||||
const val ACTION_TOGGLE = "com.krrishg.kwall.action.TOGGLE_FIREWALL"
|
||||
const val EXTRA_TOGGLE_TARGET = "com.krrishg.kwall.extra.TOGGLE_TARGET"
|
||||
|
||||
private const val REQUEST_CONTENT = 0
|
||||
private const val REQUEST_OPEN = 1
|
||||
private const val REQUEST_TOGGLE_APP = 2
|
||||
private const val REQUEST_TOGGLE_BROADCAST = 3
|
||||
private const val REQUEST_AUTO_BLOCK_CONTENT = 4
|
||||
private const val PENDING_FLAGS = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
private const val ACTIVITY_FLAGS =
|
||||
Intent.FLAG_ACTIVITY_NEW_TASK or
|
||||
Intent.FLAG_ACTIVITY_CLEAR_TOP or
|
||||
Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
|
||||
fun createChannel(context: Context) {
|
||||
val manager = context.getSystemService(NotificationManager::class.java) ?: return
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
context.getString(R.string.firewall_channel_name),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply {
|
||||
setShowBadge(false)
|
||||
setSound(null, null)
|
||||
enableVibration(false)
|
||||
}
|
||||
manager.createNotificationChannel(channel)
|
||||
val autoBlockChannel = NotificationChannel(
|
||||
AUTO_BLOCK_CHANNEL_ID,
|
||||
context.getString(R.string.firewall_auto_block_channel_name),
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
).apply {
|
||||
setShowBadge(true)
|
||||
}
|
||||
manager.createNotificationChannel(autoBlockChannel)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun notifyAutoBlocked(context: Context, label: String) {
|
||||
if (!canPostNotifications(context)) return
|
||||
val text = context.getString(R.string.firewall_auto_block_text, label)
|
||||
val notification = NotificationCompat.Builder(context, AUTO_BLOCK_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_launcher_monochrome)
|
||||
.setContentTitle(context.getString(R.string.firewall_auto_block_title))
|
||||
.setContentText(text)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
|
||||
.setContentIntent(autoBlockContentIntent(context))
|
||||
.setAutoCancel(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||
.build()
|
||||
|
||||
runCatching {
|
||||
NotificationManagerCompat.from(context)
|
||||
.notify(AUTO_BLOCK_NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun update(context: Context, content: FirewallNotificationContent) {
|
||||
if (!canPostNotifications(context)) return
|
||||
val text = content.text.resolve(context)
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_launcher_monochrome)
|
||||
.setContentTitle(context.getString(content.title.titleRes()))
|
||||
.setContentText(text)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
|
||||
.setContentIntent(contentIntent(context))
|
||||
.addAction(
|
||||
content.action.iconRes(),
|
||||
context.getString(content.action.labelRes()),
|
||||
actionIntent(context, content),
|
||||
)
|
||||
.setOngoing(true)
|
||||
.setAutoCancel(false)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setShowWhen(false)
|
||||
.setSilent(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.build()
|
||||
|
||||
runCatching {
|
||||
NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
|
||||
private fun actionIntent(
|
||||
context: Context,
|
||||
content: FirewallNotificationContent,
|
||||
): PendingIntent = when {
|
||||
content.action == FirewallNotificationAction.OPEN -> openAppPendingIntent(context)
|
||||
content.directToggle -> toggleBroadcastPendingIntent(context, content.action.toggleTarget())
|
||||
else -> toggleAppPendingIntent(context, content.action.toggleTarget())
|
||||
}
|
||||
|
||||
private fun contentIntent(context: Context): PendingIntent {
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = ACTIVITY_FLAGS
|
||||
}
|
||||
return PendingIntent.getActivity(context, REQUEST_CONTENT, intent, PENDING_FLAGS)
|
||||
}
|
||||
|
||||
private fun openAppPendingIntent(context: Context): PendingIntent {
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = ACTIVITY_FLAGS
|
||||
}
|
||||
return PendingIntent.getActivity(context, REQUEST_OPEN, intent, PENDING_FLAGS)
|
||||
}
|
||||
|
||||
private fun autoBlockContentIntent(context: Context): PendingIntent {
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = ACTIVITY_FLAGS
|
||||
}
|
||||
return PendingIntent.getActivity(context, REQUEST_AUTO_BLOCK_CONTENT, intent, PENDING_FLAGS)
|
||||
}
|
||||
|
||||
private fun toggleAppPendingIntent(context: Context, enable: Boolean): PendingIntent {
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = ACTIVITY_FLAGS
|
||||
putExtra(EXTRA_TOGGLE_TARGET, enable)
|
||||
}
|
||||
return PendingIntent.getActivity(context, REQUEST_TOGGLE_APP, intent, PENDING_FLAGS)
|
||||
}
|
||||
|
||||
private fun toggleBroadcastPendingIntent(context: Context, enable: Boolean): PendingIntent {
|
||||
val intent = Intent(context, FirewallNotificationReceiver::class.java).apply {
|
||||
action = ACTION_TOGGLE
|
||||
putExtra(EXTRA_TOGGLE_TARGET, enable)
|
||||
}
|
||||
return PendingIntent.getBroadcast(context, REQUEST_TOGGLE_BROADCAST, intent, PENDING_FLAGS)
|
||||
}
|
||||
|
||||
private fun FirewallNotificationTitle.titleRes(): Int = when (this) {
|
||||
FirewallNotificationTitle.FIREWALL -> R.string.firewall_title
|
||||
FirewallNotificationTitle.ON -> R.string.firewall_notification_on_title
|
||||
FirewallNotificationTitle.OFF -> R.string.firewall_state_off
|
||||
FirewallNotificationTitle.PAUSED -> R.string.firewall_paused_title
|
||||
FirewallNotificationTitle.ATTENTION -> R.string.firewall_notification_attention_title
|
||||
}
|
||||
|
||||
private fun FirewallNotificationText.resolve(context: Context): String = when (this) {
|
||||
FirewallNotificationText.NoBlockedApps -> context.getString(R.string.firewall_state_no_apps)
|
||||
FirewallNotificationText.NoSelectedApps ->
|
||||
context.getString(R.string.firewall_notification_no_selected_apps)
|
||||
is FirewallNotificationText.BlockedApps -> context.resources.getQuantityString(
|
||||
R.plurals.firewall_blocked_apps,
|
||||
count,
|
||||
count,
|
||||
)
|
||||
is FirewallNotificationText.SelectedApps -> context.resources.getQuantityString(
|
||||
R.plurals.firewall_notification_selected_apps,
|
||||
count,
|
||||
count,
|
||||
)
|
||||
is FirewallNotificationText.Availability -> availability.resolve(context)
|
||||
is FirewallNotificationText.Error ->
|
||||
context.getString(R.string.firewall_notification_error, message)
|
||||
}
|
||||
|
||||
private fun FirewallAvailability.resolve(context: Context): String = when (this) {
|
||||
FirewallAvailability.Checking -> context.getString(R.string.firewall_state_checking)
|
||||
FirewallAvailability.UnsupportedAndroid -> context.getString(R.string.firewall_state_android)
|
||||
FirewallAvailability.ShizukuNotInstalled ->
|
||||
context.getString(R.string.firewall_state_shizuku_missing)
|
||||
FirewallAvailability.ShizukuNotRunning ->
|
||||
context.getString(R.string.firewall_state_shizuku_stopped)
|
||||
FirewallAvailability.PermissionDenied -> context.getString(R.string.firewall_state_permission)
|
||||
FirewallAvailability.CommandUnavailable ->
|
||||
context.getString(R.string.firewall_state_unsupported)
|
||||
is FirewallAvailability.Ready -> context.getString(R.string.firewall_state_checking)
|
||||
}
|
||||
|
||||
private fun FirewallNotificationAction.labelRes(): Int = when (this) {
|
||||
FirewallNotificationAction.PAUSE -> R.string.firewall_notification_pause
|
||||
FirewallNotificationAction.RESUME -> R.string.firewall_notification_resume
|
||||
FirewallNotificationAction.OPEN -> R.string.firewall_notification_open
|
||||
}
|
||||
|
||||
private fun FirewallNotificationAction.iconRes(): Int = when (this) {
|
||||
FirewallNotificationAction.PAUSE -> R.drawable.ic_notification_pause
|
||||
FirewallNotificationAction.RESUME -> R.drawable.ic_notification_play
|
||||
FirewallNotificationAction.OPEN -> R.drawable.ic_notification_open
|
||||
}
|
||||
|
||||
private fun FirewallNotificationAction.toggleTarget(): Boolean =
|
||||
this == FirewallNotificationAction.RESUME
|
||||
|
||||
private fun canPostNotifications(context: Context): Boolean =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.POST_NOTIFICATIONS,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.krrishg.kwall.notifications
|
||||
|
||||
import android.content.Context
|
||||
import com.krrishg.kwall.firewall.FirewallController
|
||||
import com.krrishg.kwall.settings.AppSettingsStore
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class FirewallStatusNotifier(
|
||||
context: Context,
|
||||
settingsStore: AppSettingsStore,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
private val appLockEnabled = settingsStore.appLockEnabled
|
||||
.stateIn(scope, SharingStarted.Eagerly, true)
|
||||
|
||||
private var controller: FirewallController? = null
|
||||
private var collectJob: Job? = null
|
||||
|
||||
fun start(controller: FirewallController) {
|
||||
this.controller = controller
|
||||
if (collectJob?.isActive == true) return
|
||||
collectJob = scope.launch {
|
||||
combine(
|
||||
controller.state,
|
||||
controller.availability,
|
||||
controller.error,
|
||||
appLockEnabled,
|
||||
) { state, availability, error, appLockEnabled ->
|
||||
firewallNotificationContent(
|
||||
enabled = state.enabled,
|
||||
appCount = state.blockedApps.size,
|
||||
availability = availability,
|
||||
error = error,
|
||||
appLockEnabled = appLockEnabled,
|
||||
)
|
||||
}.collect { content ->
|
||||
FirewallNotifications.update(appContext, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
val controller = controller ?: return
|
||||
scope.launch {
|
||||
val state = controller.state.value
|
||||
FirewallNotifications.update(
|
||||
appContext,
|
||||
firewallNotificationContent(
|
||||
enabled = state.enabled,
|
||||
appCount = state.blockedApps.size,
|
||||
availability = controller.availability.value,
|
||||
error = controller.error.value,
|
||||
appLockEnabled = appLockEnabled.value,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.krrishg.kwall.receivers
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import com.krrishg.kwall.KwallApplication
|
||||
import com.krrishg.kwall.firewall.FirewallAvailability
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent?) {
|
||||
if (intent?.action != Intent.ACTION_BOOT_COMPLETED) return
|
||||
val application = context.applicationContext as? KwallApplication ?: return
|
||||
val pendingResult = goAsync()
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val container = application.container
|
||||
container.firewallManager.refreshAvailability()
|
||||
container.profileScheduler.onSystemEvent()
|
||||
val state = container.firewallStateStore.state.first()
|
||||
val availability = container.firewallManager.availability.value
|
||||
if (state.enabled && availability is FirewallAvailability.Ready) {
|
||||
container.firewallManager.restore()
|
||||
}
|
||||
container.firewallStatusNotifier.refresh()
|
||||
} catch (error: Throwable) {
|
||||
Log.w(TAG, "Could not restore the firewall after boot", error)
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "BootReceiver"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.krrishg.kwall.receivers
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import com.krrishg.kwall.KwallApplication
|
||||
import com.krrishg.kwall.notifications.FirewallNotifications
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class FirewallNotificationReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent?) {
|
||||
if (intent?.action != FirewallNotifications.ACTION_TOGGLE) return
|
||||
val enable = intent.getBooleanExtra(FirewallNotifications.EXTRA_TOGGLE_TARGET, false)
|
||||
val application = context.applicationContext as? KwallApplication ?: return
|
||||
val pendingResult = goAsync()
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val container = application.container
|
||||
if (container.settingsStore.appLockEnabled.first()) {
|
||||
container.firewallStatusNotifier.refresh()
|
||||
return@launch
|
||||
}
|
||||
container.firewallManager.setEnabled(enable)
|
||||
} catch (error: Throwable) {
|
||||
Log.w(TAG, "Could not toggle the firewall from the notification", error)
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "FirewallNotification"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.krrishg.kwall.receivers
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import com.krrishg.kwall.KwallApplication
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class PackageAddedReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent?) {
|
||||
if (intent?.action != Intent.ACTION_PACKAGE_ADDED) return
|
||||
if (intent.getBooleanExtra(Intent.EXTRA_REPLACING, false)) return
|
||||
val packageName = intent.data?.schemeSpecificPart ?: return
|
||||
val uid = intent.getIntExtra(Intent.EXTRA_UID, -1)
|
||||
if (uid < 0) return
|
||||
val application = context.applicationContext as? KwallApplication ?: return
|
||||
val pendingResult = goAsync()
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
application.container.autoBlockNewlyInstalled(
|
||||
packageName = packageName,
|
||||
userId = uid / PER_USER_RANGE,
|
||||
)
|
||||
} catch (error: Throwable) {
|
||||
Log.w(TAG, "Could not auto-block $packageName", error)
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "PackageAddedReceiver"
|
||||
const val PER_USER_RANGE = 100_000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.krrishg.kwall.receivers
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import com.krrishg.kwall.KwallApplication
|
||||
import com.krrishg.kwall.scheduler.AlarmManagerScheduleAlarms
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ScheduleAlarmReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent?) {
|
||||
if (intent?.action != AlarmManagerScheduleAlarms.ACTION_SCHEDULE_ALARM) return
|
||||
val application = context.applicationContext as? KwallApplication ?: return
|
||||
val pendingResult = goAsync()
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
application.container.profileScheduler.onAlarm()
|
||||
} catch (error: Throwable) {
|
||||
Log.w(TAG, "Could not apply the scheduled profile", error)
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ScheduleAlarm"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.krrishg.kwall.receivers
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import com.krrishg.kwall.KwallApplication
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ScheduleMaintenanceReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent?) {
|
||||
val action = intent?.action ?: return
|
||||
if (action !in HANDLED_ACTIONS) return
|
||||
val application = context.applicationContext as? KwallApplication ?: return
|
||||
val pendingResult = goAsync()
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
application.container.profileScheduler.onSystemEvent()
|
||||
} catch (error: Throwable) {
|
||||
Log.w(TAG, "Could not reschedule profile activations", error)
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ScheduleMaintenance"
|
||||
const val ACTION_EXACT_ALARM_PERMISSION_CHANGED =
|
||||
"android.app.action.SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED"
|
||||
|
||||
val HANDLED_ACTIONS = setOf(
|
||||
Intent.ACTION_TIME_CHANGED,
|
||||
Intent.ACTION_TIMEZONE_CHANGED,
|
||||
Intent.ACTION_DATE_CHANGED,
|
||||
Intent.ACTION_MY_PACKAGE_REPLACED,
|
||||
ACTION_EXACT_ALARM_PERMISSION_CHANGED,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package com.krrishg.kwall.scheduler
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import java.io.IOException
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private val Context.scheduleDataStore by preferencesDataStore(name = "profile_schedules")
|
||||
|
||||
class DataStoreScheduleStore(context: Context) : ScheduleStore {
|
||||
|
||||
private val dataStore = context.applicationContext.scheduleDataStore
|
||||
|
||||
private val preferences = dataStore.data
|
||||
.catch { error ->
|
||||
if (error is IOException) {
|
||||
emit(emptyPreferences())
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
override val schedules: Flow<List<ProfileSchedule>> = preferences.map { values ->
|
||||
ScheduleJsonCodec.decode(values[SCHEDULES_KEY])
|
||||
}
|
||||
|
||||
override suspend fun create(schedule: ProfileSchedule): Boolean {
|
||||
var created = false
|
||||
dataStore.edit { values ->
|
||||
val current = ScheduleJsonCodec.decode(values[SCHEDULES_KEY])
|
||||
if (current.any { it.id == schedule.id }) return@edit
|
||||
values[SCHEDULES_KEY] = ScheduleJsonCodec.encode(current + schedule)
|
||||
created = true
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
override suspend fun update(schedule: ProfileSchedule): Boolean {
|
||||
var updated = false
|
||||
dataStore.edit { values ->
|
||||
val current = ScheduleJsonCodec.decode(values[SCHEDULES_KEY])
|
||||
if (current.none { it.id == schedule.id }) return@edit
|
||||
values[SCHEDULES_KEY] = ScheduleJsonCodec.encode(
|
||||
current.map { existing -> if (existing.id == schedule.id) schedule else existing },
|
||||
)
|
||||
updated = true
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
override suspend fun delete(id: String): Boolean {
|
||||
var deleted = false
|
||||
dataStore.edit { values ->
|
||||
val current = ScheduleJsonCodec.decode(values[SCHEDULES_KEY])
|
||||
if (current.none { it.id == id }) return@edit
|
||||
values[SCHEDULES_KEY] = ScheduleJsonCodec.encode(current.filterNot { it.id == id })
|
||||
deleted = true
|
||||
}
|
||||
return deleted
|
||||
}
|
||||
|
||||
override suspend fun setEnabled(id: String, enabled: Boolean): Boolean {
|
||||
var updated = false
|
||||
dataStore.edit { values ->
|
||||
val current = ScheduleJsonCodec.decode(values[SCHEDULES_KEY])
|
||||
if (current.none { it.id == id }) return@edit
|
||||
values[SCHEDULES_KEY] = ScheduleJsonCodec.encode(
|
||||
current.map { schedule ->
|
||||
if (schedule.id == id) schedule.copy(enabled = enabled) else schedule
|
||||
},
|
||||
)
|
||||
updated = true
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
override suspend fun readRuntime(): SchedulerRuntime {
|
||||
val values = preferences.first()
|
||||
return values.toRuntime()
|
||||
}
|
||||
|
||||
override suspend fun writeRuntime(runtime: SchedulerRuntime) {
|
||||
dataStore.edit { values ->
|
||||
values.putRuntime(runtime)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clearRuntime() {
|
||||
dataStore.edit { values ->
|
||||
values.remove(RUNTIME_ACTIVE_SCHEDULE_ID_KEY)
|
||||
values.remove(RUNTIME_ACTIVE_WINDOW_START_KEY)
|
||||
values.remove(RUNTIME_ACTIVE_PROFILE_ID_KEY)
|
||||
values.remove(RUNTIME_FALLBACK_PROFILE_ID_KEY)
|
||||
values.remove(RUNTIME_FIREWALL_WAS_ENABLED_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Preferences.toRuntime(): SchedulerRuntime = SchedulerRuntime(
|
||||
activeScheduleId = this[RUNTIME_ACTIVE_SCHEDULE_ID_KEY],
|
||||
activeWindowStartMillis = this[RUNTIME_ACTIVE_WINDOW_START_KEY],
|
||||
activeProfileId = this[RUNTIME_ACTIVE_PROFILE_ID_KEY],
|
||||
fallbackProfileId = this[RUNTIME_FALLBACK_PROFILE_ID_KEY],
|
||||
firewallWasEnabledBefore = if (contains(RUNTIME_FIREWALL_WAS_ENABLED_KEY)) {
|
||||
this[RUNTIME_FIREWALL_WAS_ENABLED_KEY]
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
|
||||
private fun MutablePreferences.putRuntime(runtime: SchedulerRuntime) {
|
||||
runtime.activeScheduleId.putOrRemove(this, RUNTIME_ACTIVE_SCHEDULE_ID_KEY)
|
||||
runtime.activeWindowStartMillis.putOrRemove(this, RUNTIME_ACTIVE_WINDOW_START_KEY)
|
||||
runtime.activeProfileId.putOrRemove(this, RUNTIME_ACTIVE_PROFILE_ID_KEY)
|
||||
runtime.fallbackProfileId.putOrRemove(this, RUNTIME_FALLBACK_PROFILE_ID_KEY)
|
||||
runtime.firewallWasEnabledBefore.putOrRemove(this, RUNTIME_FIREWALL_WAS_ENABLED_KEY)
|
||||
}
|
||||
|
||||
private fun String?.putOrRemove(
|
||||
preferences: MutablePreferences,
|
||||
key: Preferences.Key<String>,
|
||||
) {
|
||||
if (this == null) preferences.remove(key) else preferences[key] = this
|
||||
}
|
||||
|
||||
private fun Long?.putOrRemove(
|
||||
preferences: MutablePreferences,
|
||||
key: Preferences.Key<Long>,
|
||||
) {
|
||||
if (this == null) preferences.remove(key) else preferences[key] = this
|
||||
}
|
||||
|
||||
private fun Boolean?.putOrRemove(
|
||||
preferences: MutablePreferences,
|
||||
key: Preferences.Key<Boolean>,
|
||||
) {
|
||||
if (this == null) preferences.remove(key) else preferences[key] = this
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val SCHEDULES_KEY = stringPreferencesKey("schedules")
|
||||
val RUNTIME_ACTIVE_SCHEDULE_ID_KEY = stringPreferencesKey("runtime_active_schedule_id")
|
||||
val RUNTIME_ACTIVE_WINDOW_START_KEY = longPreferencesKey("runtime_active_window_start")
|
||||
val RUNTIME_ACTIVE_PROFILE_ID_KEY = stringPreferencesKey("runtime_active_profile_id")
|
||||
val RUNTIME_FALLBACK_PROFILE_ID_KEY = stringPreferencesKey("runtime_fallback_profile_id")
|
||||
val RUNTIME_FIREWALL_WAS_ENABLED_KEY =
|
||||
booleanPreferencesKey("runtime_firewall_was_enabled")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.krrishg.kwall.scheduler
|
||||
|
||||
import java.time.DayOfWeek
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
|
||||
const val MAX_SCHEDULE_NAME_LENGTH = 40
|
||||
|
||||
data class ProfileSchedule(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val profileId: String,
|
||||
val fallbackProfileId: String,
|
||||
val recurrence: ScheduleRecurrence,
|
||||
val startTime: LocalTime,
|
||||
val endTime: LocalTime? = null,
|
||||
val enabled: Boolean = true,
|
||||
val createdAtMillis: Long = 0L,
|
||||
)
|
||||
|
||||
sealed interface ScheduleRecurrence {
|
||||
val anchorDate: LocalDate
|
||||
|
||||
data class Once(val date: LocalDate) : ScheduleRecurrence {
|
||||
override val anchorDate: LocalDate get() = date
|
||||
}
|
||||
|
||||
data class Daily(
|
||||
override val anchorDate: LocalDate,
|
||||
val intervalDays: Int = 1,
|
||||
) : ScheduleRecurrence
|
||||
|
||||
data class Weekly(
|
||||
override val anchorDate: LocalDate,
|
||||
val daysOfWeek: Set<DayOfWeek>,
|
||||
val intervalWeeks: Int = 1,
|
||||
) : ScheduleRecurrence
|
||||
|
||||
data class Monthly(
|
||||
override val anchorDate: LocalDate,
|
||||
val dayOfMonth: Int,
|
||||
val intervalMonths: Int = 1,
|
||||
) : ScheduleRecurrence
|
||||
}
|
||||
|
||||
enum class ScheduleIssue {
|
||||
BLANK_NAME,
|
||||
NAME_TOO_LONG,
|
||||
PROFILE_MISSING,
|
||||
FALLBACK_MISSING,
|
||||
INVALID_INTERVAL,
|
||||
NO_WEEKDAYS,
|
||||
INVALID_DAY_OF_MONTH,
|
||||
PAST_ONE_TIME,
|
||||
OVERLAP,
|
||||
}
|
||||
|
||||
fun validateScheduleBasics(
|
||||
schedule: ProfileSchedule,
|
||||
availableProfileIds: Set<String>,
|
||||
): List<ScheduleIssue> {
|
||||
val issues = mutableListOf<ScheduleIssue>()
|
||||
val name = schedule.name.trim()
|
||||
if (name.isEmpty()) issues += ScheduleIssue.BLANK_NAME
|
||||
if (name.length > MAX_SCHEDULE_NAME_LENGTH) issues += ScheduleIssue.NAME_TOO_LONG
|
||||
if (schedule.profileId !in availableProfileIds) issues += ScheduleIssue.PROFILE_MISSING
|
||||
if (schedule.fallbackProfileId !in availableProfileIds) issues += ScheduleIssue.FALLBACK_MISSING
|
||||
when (val recurrence = schedule.recurrence) {
|
||||
is ScheduleRecurrence.Once -> Unit
|
||||
is ScheduleRecurrence.Daily -> {
|
||||
if (recurrence.intervalDays < 1) issues += ScheduleIssue.INVALID_INTERVAL
|
||||
}
|
||||
is ScheduleRecurrence.Weekly -> {
|
||||
if (recurrence.daysOfWeek.isEmpty()) issues += ScheduleIssue.NO_WEEKDAYS
|
||||
if (recurrence.intervalWeeks < 1) issues += ScheduleIssue.INVALID_INTERVAL
|
||||
}
|
||||
is ScheduleRecurrence.Monthly -> {
|
||||
if (recurrence.dayOfMonth !in 1..31) issues += ScheduleIssue.INVALID_DAY_OF_MONTH
|
||||
if (recurrence.intervalMonths < 1) issues += ScheduleIssue.INVALID_INTERVAL
|
||||
}
|
||||
}
|
||||
return issues
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.krrishg.kwall.scheduler
|
||||
|
||||
import android.util.Log
|
||||
import com.krrishg.kwall.firewall.DEFAULT_PROFILE_ID
|
||||
import com.krrishg.kwall.firewall.FirewallController
|
||||
import java.time.Clock
|
||||
import java.time.ZoneId
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
class ProfileScheduler(
|
||||
private val scheduleStore: ScheduleStore,
|
||||
private val firewallController: FirewallController,
|
||||
private val alarms: ScheduleAlarms,
|
||||
private val clock: Clock = Clock.systemDefaultZone(),
|
||||
private val zoneProvider: () -> ZoneId = { ZoneId.systemDefault() },
|
||||
) {
|
||||
|
||||
private val mutex = Mutex()
|
||||
private val started = AtomicBoolean(false)
|
||||
|
||||
fun start(scope: CoroutineScope) {
|
||||
if (!started.compareAndSet(false, true)) return
|
||||
scope.launch {
|
||||
scheduleStore.schedules.collect {
|
||||
try {
|
||||
reconcile()
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (error: Throwable) {
|
||||
Log.w(TAG, "Could not reconcile profile schedules", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun onAlarm() {
|
||||
reconcile()
|
||||
}
|
||||
|
||||
suspend fun onSystemEvent() {
|
||||
reconcile()
|
||||
}
|
||||
|
||||
private suspend fun reconcile() = mutex.withLock {
|
||||
val now = clock.instant()
|
||||
val zone = zoneProvider()
|
||||
val schedules = scheduleStore.schedules.first()
|
||||
val firewallState = firewallController.state.value
|
||||
val profileIds = firewallState.profiles.map { it.id }.toSet()
|
||||
val usable = schedules.filter { schedule ->
|
||||
schedule.enabled && validateScheduleBasics(schedule, profileIds).isEmpty()
|
||||
}
|
||||
val active = ScheduleEngine.activeSchedule(usable, now, zone)
|
||||
val runtime = scheduleStore.readRuntime()
|
||||
|
||||
if (active != null) {
|
||||
activate(active, runtime, firewallState.enabled)
|
||||
} else if (runtime.activeScheduleId != null) {
|
||||
deactivate(runtime, profileIds)
|
||||
}
|
||||
|
||||
val next = ScheduleEngine.nextTransition(usable, now, zone)
|
||||
if (next == null) {
|
||||
alarms.cancel()
|
||||
} else {
|
||||
alarms.scheduleNext(next)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun activate(
|
||||
active: ActiveSchedule,
|
||||
runtime: SchedulerRuntime,
|
||||
firewallWasEnabled: Boolean,
|
||||
) {
|
||||
val windowStart = active.startedAt.toEpochMilli()
|
||||
val sameWindow = runtime.activeScheduleId == active.schedule.id &&
|
||||
runtime.activeWindowStartMillis == windowStart
|
||||
val alreadyApplied = sameWindow && runtime.activeProfileId == active.schedule.profileId
|
||||
if (alreadyApplied) {
|
||||
if (runtime.fallbackProfileId != active.schedule.fallbackProfileId) {
|
||||
scheduleStore.writeRuntime(
|
||||
runtime.copy(fallbackProfileId = active.schedule.fallbackProfileId),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
val baseline = runtime.firewallWasEnabledBefore ?: firewallWasEnabled
|
||||
scheduleStore.writeRuntime(
|
||||
SchedulerRuntime(
|
||||
activeScheduleId = active.schedule.id,
|
||||
activeWindowStartMillis = windowStart,
|
||||
activeProfileId = active.schedule.profileId,
|
||||
fallbackProfileId = active.schedule.fallbackProfileId,
|
||||
firewallWasEnabledBefore = baseline,
|
||||
),
|
||||
)
|
||||
val applied = firewallController.applyScheduledProfile(
|
||||
active.schedule.profileId,
|
||||
enableFirewall = true,
|
||||
)
|
||||
if (!applied) scheduleStore.clearRuntime()
|
||||
}
|
||||
|
||||
private suspend fun deactivate(
|
||||
runtime: SchedulerRuntime,
|
||||
profileIds: Set<String>,
|
||||
) {
|
||||
val fallback = runtime.fallbackProfileId?.takeIf { it in profileIds }
|
||||
?: profileIds.firstOrNull { it == DEFAULT_PROFILE_ID }
|
||||
?: profileIds.firstOrNull()
|
||||
val enableFirewall = when (runtime.firewallWasEnabledBefore) {
|
||||
true -> null
|
||||
false -> false
|
||||
null -> null
|
||||
}
|
||||
if (fallback != null) {
|
||||
val applied = firewallController.applyScheduledProfile(fallback, enableFirewall)
|
||||
if (applied) scheduleStore.clearRuntime()
|
||||
} else {
|
||||
scheduleStore.clearRuntime()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ProfileScheduler"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.krrishg.kwall.scheduler
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import com.krrishg.kwall.receivers.ScheduleAlarmReceiver
|
||||
import java.time.Instant
|
||||
|
||||
interface ScheduleAlarms {
|
||||
fun canScheduleExactAlarms(): Boolean
|
||||
|
||||
fun scheduleNext(at: Instant)
|
||||
|
||||
fun cancel()
|
||||
|
||||
fun requestExactAlarmPermission()
|
||||
}
|
||||
|
||||
class AlarmManagerScheduleAlarms(context: Context) : ScheduleAlarms {
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
private val alarmManager = appContext.getSystemService(AlarmManager::class.java)
|
||||
|
||||
override fun canScheduleExactAlarms(): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return true
|
||||
val manager = alarmManager ?: return false
|
||||
return runCatching { manager.canScheduleExactAlarms() }.getOrDefault(false)
|
||||
}
|
||||
|
||||
override fun scheduleNext(at: Instant) {
|
||||
val manager = alarmManager ?: return
|
||||
val triggerAtMillis = maxOf(at.toEpochMilli(), System.currentTimeMillis() + MIN_DELAY_MILLIS)
|
||||
val pendingIntent = pendingIntent()
|
||||
if (canScheduleExactAlarms()) {
|
||||
try {
|
||||
manager.setExactAndAllowWhileIdle(
|
||||
AlarmManager.RTC_WAKEUP,
|
||||
triggerAtMillis,
|
||||
pendingIntent,
|
||||
)
|
||||
return
|
||||
} catch (_: SecurityException) {
|
||||
}
|
||||
}
|
||||
manager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent)
|
||||
}
|
||||
|
||||
override fun cancel() {
|
||||
val manager = alarmManager ?: return
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
appContext,
|
||||
REQUEST_CODE,
|
||||
alarmIntent(),
|
||||
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE,
|
||||
) ?: return
|
||||
manager.cancel(pendingIntent)
|
||||
pendingIntent.cancel()
|
||||
}
|
||||
|
||||
override fun requestExactAlarmPermission() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return
|
||||
if (canScheduleExactAlarms()) return
|
||||
val intent = Intent(
|
||||
Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM,
|
||||
Uri.fromParts("package", appContext.packageName, null),
|
||||
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
runCatching { appContext.startActivity(intent) }
|
||||
}
|
||||
|
||||
private fun pendingIntent(): PendingIntent = PendingIntent.getBroadcast(
|
||||
appContext,
|
||||
REQUEST_CODE,
|
||||
alarmIntent(),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
private fun alarmIntent(): Intent = Intent(appContext, ScheduleAlarmReceiver::class.java).apply {
|
||||
action = ACTION_SCHEDULE_ALARM
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_SCHEDULE_ALARM = "com.krrishg.kwall.action.SCHEDULE_ALARM"
|
||||
private const val REQUEST_CODE = 2001
|
||||
private const val MIN_DELAY_MILLIS = 1_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
package com.krrishg.kwall.scheduler
|
||||
|
||||
import java.time.DayOfWeek
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.YearMonth
|
||||
import java.time.ZoneId
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
data class ActiveSchedule(
|
||||
val schedule: ProfileSchedule,
|
||||
val startedAt: Instant,
|
||||
)
|
||||
|
||||
data class ScheduleOccurrence(
|
||||
val schedule: ProfileSchedule,
|
||||
val date: LocalDate,
|
||||
val start: Instant,
|
||||
)
|
||||
|
||||
data class ScheduleWindow(
|
||||
val schedule: ProfileSchedule,
|
||||
val start: Instant,
|
||||
val end: Instant?,
|
||||
)
|
||||
|
||||
object ScheduleEngine {
|
||||
|
||||
private const val MAX_ITERATIONS = 100_000
|
||||
private const val OVERLAP_LOOKAHEAD_DAYS = 365L * 5
|
||||
|
||||
fun windowFor(
|
||||
schedule: ProfileSchedule,
|
||||
date: LocalDate,
|
||||
zone: ZoneId,
|
||||
): ScheduleWindow? {
|
||||
val start = startInstant(schedule, date, zone)
|
||||
val end = endInstant(schedule, date, zone)
|
||||
return ScheduleWindow(schedule, start, end)
|
||||
}
|
||||
|
||||
fun nextOccurrence(
|
||||
schedule: ProfileSchedule,
|
||||
after: Instant,
|
||||
zone: ZoneId,
|
||||
): Instant? = nextOccurrenceAfter(schedule, after, zone)?.start
|
||||
|
||||
fun activeSchedule(
|
||||
schedules: List<ProfileSchedule>,
|
||||
at: Instant,
|
||||
zone: ZoneId,
|
||||
): ActiveSchedule? {
|
||||
var latestStart: ScheduleOccurrence? = null
|
||||
var latestEnd: Instant? = null
|
||||
|
||||
schedules.forEach { schedule ->
|
||||
val occurrence = previousOccurrenceAtOrBefore(schedule, at, zone) ?: return@forEach
|
||||
if (latestStart == null || isLater(occurrence, latestStart!!)) {
|
||||
latestStart = occurrence
|
||||
}
|
||||
val end = endInstant(schedule, occurrence.date, zone)
|
||||
if (end != null && !end.isAfter(at) && (latestEnd == null || end.isAfter(latestEnd))) {
|
||||
latestEnd = end
|
||||
}
|
||||
}
|
||||
|
||||
val start = latestStart ?: return null
|
||||
if (latestEnd != null && latestEnd!!.isAfter(start.start)) return null
|
||||
return ActiveSchedule(start.schedule, start.start)
|
||||
}
|
||||
|
||||
fun nextTransition(
|
||||
schedules: List<ProfileSchedule>,
|
||||
after: Instant,
|
||||
zone: ZoneId,
|
||||
): Instant? {
|
||||
var next: Instant? = null
|
||||
schedules.forEach { schedule ->
|
||||
val nextOccurrence = nextOccurrenceAfter(schedule, after, zone)
|
||||
if (nextOccurrence != null) {
|
||||
next = earliest(next, nextOccurrence.start)
|
||||
val end = endInstant(schedule, nextOccurrence.date, zone)
|
||||
if (end != null && end.isAfter(after)) next = earliest(next, end)
|
||||
}
|
||||
val previousOccurrence = previousOccurrenceAtOrBefore(schedule, after, zone)
|
||||
if (previousOccurrence != null) {
|
||||
val end = endInstant(schedule, previousOccurrence.date, zone)
|
||||
if (end != null && end.isAfter(after)) next = earliest(next, end)
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
fun validateSchedule(
|
||||
candidate: ProfileSchedule,
|
||||
existing: List<ProfileSchedule>,
|
||||
availableProfileIds: Set<String>,
|
||||
now: Instant,
|
||||
zone: ZoneId,
|
||||
): List<ScheduleIssue> {
|
||||
val issues = validateScheduleBasics(candidate, availableProfileIds).toMutableList()
|
||||
val recurrence = candidate.recurrence
|
||||
if (recurrence is ScheduleRecurrence.Once) {
|
||||
val start = startInstant(candidate, recurrence.date, zone)
|
||||
if (!start.isAfter(now)) issues += ScheduleIssue.PAST_ONE_TIME
|
||||
}
|
||||
if (issues.isNotEmpty()) return issues
|
||||
|
||||
val from = now.minus(2, ChronoUnit.DAYS)
|
||||
val to = now.plus(OVERLAP_LOOKAHEAD_DAYS, ChronoUnit.DAYS)
|
||||
val candidateWindows = occurrencesInRange(candidate, from, to, zone)
|
||||
val overlaps = existing
|
||||
.asSequence()
|
||||
.filter { it.id != candidate.id }
|
||||
.map { other -> occurrencesInRange(other, from, to, zone) }
|
||||
.any { otherWindows -> overlaps(candidateWindows, otherWindows) }
|
||||
if (overlaps) issues += ScheduleIssue.OVERLAP
|
||||
return issues
|
||||
}
|
||||
|
||||
fun occurrencesInRange(
|
||||
schedule: ProfileSchedule,
|
||||
from: Instant,
|
||||
to: Instant,
|
||||
zone: ZoneId,
|
||||
): List<ScheduleWindow> {
|
||||
if (to.isBefore(from)) return emptyList()
|
||||
val firstDate = from.atZone(zone).toLocalDate().minusDays(2)
|
||||
var date = nextOccurrenceDate(schedule, firstDate) ?: return emptyList()
|
||||
val windows = mutableListOf<ScheduleWindow>()
|
||||
var iterations = 0
|
||||
while (iterations++ < MAX_ITERATIONS) {
|
||||
val start = startInstant(schedule, date, zone)
|
||||
if (start.isAfter(to)) break
|
||||
val window = windowFor(schedule, date, zone)
|
||||
if (window != null) {
|
||||
val end = window.end
|
||||
val relevant = if (end == null) {
|
||||
!start.isBefore(from)
|
||||
} else {
|
||||
end.isAfter(from)
|
||||
}
|
||||
if (relevant) windows += window
|
||||
}
|
||||
date = nextOccurrenceDate(schedule, date.plusDays(1)) ?: break
|
||||
}
|
||||
return windows
|
||||
}
|
||||
|
||||
fun startInstant(schedule: ProfileSchedule, date: LocalDate, zone: ZoneId): Instant =
|
||||
date.atTime(schedule.startTime).atZone(zone).toInstant()
|
||||
|
||||
private fun endInstant(schedule: ProfileSchedule, date: LocalDate, zone: ZoneId): Instant? {
|
||||
val endTime = schedule.endTime ?: return null
|
||||
val endDate = if (endTime.isAfter(schedule.startTime)) date else date.plusDays(1)
|
||||
val start = startInstant(schedule, date, zone)
|
||||
var end = endDate.atTime(endTime).atZone(zone).toInstant()
|
||||
if (!end.isAfter(start)) end = start.plusSeconds(60)
|
||||
return end
|
||||
}
|
||||
|
||||
private fun nextOccurrenceAfter(
|
||||
schedule: ProfileSchedule,
|
||||
after: Instant,
|
||||
zone: ZoneId,
|
||||
): ScheduleOccurrence? {
|
||||
var date = nextOccurrenceDate(schedule, after.atZone(zone).toLocalDate()) ?: return null
|
||||
var iterations = 0
|
||||
while (iterations++ < MAX_ITERATIONS) {
|
||||
val start = startInstant(schedule, date, zone)
|
||||
if (start.isAfter(after)) return ScheduleOccurrence(schedule, date, start)
|
||||
date = nextOccurrenceDate(schedule, date.plusDays(1)) ?: return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun previousOccurrenceAtOrBefore(
|
||||
schedule: ProfileSchedule,
|
||||
at: Instant,
|
||||
zone: ZoneId,
|
||||
): ScheduleOccurrence? {
|
||||
var date = previousOccurrenceDate(schedule, at.atZone(zone).toLocalDate()) ?: return null
|
||||
var iterations = 0
|
||||
while (iterations++ < MAX_ITERATIONS) {
|
||||
val start = startInstant(schedule, date, zone)
|
||||
if (!start.isAfter(at)) return ScheduleOccurrence(schedule, date, start)
|
||||
date = previousOccurrenceDate(schedule, date.minusDays(1)) ?: return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun nextOccurrenceDate(
|
||||
schedule: ProfileSchedule,
|
||||
date: LocalDate,
|
||||
): LocalDate? = nextRawOccurrenceDate(schedule, date)
|
||||
|
||||
private fun previousOccurrenceDate(
|
||||
schedule: ProfileSchedule,
|
||||
date: LocalDate,
|
||||
): LocalDate? = previousRawOccurrenceDate(schedule, date)
|
||||
|
||||
private fun nextRawOccurrenceDate(
|
||||
schedule: ProfileSchedule,
|
||||
date: LocalDate,
|
||||
): LocalDate? = when (val recurrence = schedule.recurrence) {
|
||||
is ScheduleRecurrence.Once -> recurrence.date.takeIf { !it.isBefore(date) }
|
||||
is ScheduleRecurrence.Daily -> nextDailyDate(recurrence, date)
|
||||
is ScheduleRecurrence.Weekly -> nextWeeklyDate(recurrence, date)
|
||||
is ScheduleRecurrence.Monthly -> nextMonthlyDate(recurrence, date)
|
||||
}
|
||||
|
||||
private fun previousRawOccurrenceDate(
|
||||
schedule: ProfileSchedule,
|
||||
date: LocalDate,
|
||||
): LocalDate? = when (val recurrence = schedule.recurrence) {
|
||||
is ScheduleRecurrence.Once -> recurrence.date.takeIf { !it.isAfter(date) }
|
||||
is ScheduleRecurrence.Daily -> previousDailyDate(recurrence, date)
|
||||
is ScheduleRecurrence.Weekly -> previousWeeklyDate(recurrence, date)
|
||||
is ScheduleRecurrence.Monthly -> previousMonthlyDate(recurrence, date)
|
||||
}
|
||||
|
||||
private fun nextDailyDate(
|
||||
recurrence: ScheduleRecurrence.Daily,
|
||||
date: LocalDate,
|
||||
): LocalDate? {
|
||||
if (recurrence.intervalDays < 1) return null
|
||||
val anchor = recurrence.anchorDate
|
||||
if (date <= anchor) return anchor
|
||||
val interval = recurrence.intervalDays.toLong()
|
||||
val days = ChronoUnit.DAYS.between(anchor, date)
|
||||
val steps = (days + interval - 1) / interval
|
||||
return anchor.plusDays(steps * interval)
|
||||
}
|
||||
|
||||
private fun previousDailyDate(
|
||||
recurrence: ScheduleRecurrence.Daily,
|
||||
date: LocalDate,
|
||||
): LocalDate? {
|
||||
if (recurrence.intervalDays < 1) return null
|
||||
val anchor = recurrence.anchorDate
|
||||
if (date < anchor) return null
|
||||
val interval = recurrence.intervalDays.toLong()
|
||||
val steps = ChronoUnit.DAYS.between(anchor, date) / interval
|
||||
return anchor.plusDays(steps * interval)
|
||||
}
|
||||
|
||||
private fun nextWeeklyDate(
|
||||
recurrence: ScheduleRecurrence.Weekly,
|
||||
date: LocalDate,
|
||||
): LocalDate? {
|
||||
if (recurrence.daysOfWeek.isEmpty() || recurrence.intervalWeeks < 1) return null
|
||||
val anchor = recurrence.anchorDate
|
||||
val interval = recurrence.intervalWeeks.toLong()
|
||||
val anchorWeek = mondayOf(anchor)
|
||||
val targetWeek = mondayOf(if (date.isBefore(anchor)) anchor else date)
|
||||
val weeks = ChronoUnit.WEEKS.between(anchorWeek, targetWeek)
|
||||
var step = if (weeks <= 0) 0 else (weeks + interval - 1) / interval
|
||||
var iterations = 0
|
||||
while (iterations++ < MAX_ITERATIONS) {
|
||||
val week = anchorWeek.plusWeeks(step * interval)
|
||||
weeklyDates(recurrence, week).forEach { candidate ->
|
||||
if (!candidate.isBefore(date) && !candidate.isBefore(anchor)) return candidate
|
||||
}
|
||||
step += 1
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun previousWeeklyDate(
|
||||
recurrence: ScheduleRecurrence.Weekly,
|
||||
date: LocalDate,
|
||||
): LocalDate? {
|
||||
if (recurrence.daysOfWeek.isEmpty() || recurrence.intervalWeeks < 1) return null
|
||||
val anchor = recurrence.anchorDate
|
||||
if (date < anchor) return null
|
||||
val interval = recurrence.intervalWeeks.toLong()
|
||||
val anchorWeek = mondayOf(anchor)
|
||||
val weeks = ChronoUnit.WEEKS.between(anchorWeek, mondayOf(date))
|
||||
var step = (weeks / interval).coerceAtLeast(0)
|
||||
var iterations = 0
|
||||
while (step >= 0 && iterations++ < MAX_ITERATIONS) {
|
||||
val week = anchorWeek.plusWeeks(step * interval)
|
||||
weeklyDates(recurrence, week)
|
||||
.asReversed()
|
||||
.forEach { candidate ->
|
||||
if (!candidate.isAfter(date) && !candidate.isBefore(anchor)) return candidate
|
||||
}
|
||||
step -= 1
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun weeklyDates(
|
||||
recurrence: ScheduleRecurrence.Weekly,
|
||||
weekStart: LocalDate,
|
||||
): List<LocalDate> = recurrence.daysOfWeek
|
||||
.sortedBy { it.value }
|
||||
.map { day -> weekStart.plusDays((day.value - 1).toLong()) }
|
||||
|
||||
private fun nextMonthlyDate(
|
||||
recurrence: ScheduleRecurrence.Monthly,
|
||||
date: LocalDate,
|
||||
): LocalDate? {
|
||||
if (recurrence.dayOfMonth !in 1..31 || recurrence.intervalMonths < 1) return null
|
||||
val anchor = recurrence.anchorDate
|
||||
val effective = if (date.isBefore(anchor)) anchor else date
|
||||
val interval = recurrence.intervalMonths.toLong()
|
||||
val anchorMonth = YearMonth.from(anchor)
|
||||
val months = ChronoUnit.MONTHS.between(anchorMonth, YearMonth.from(effective))
|
||||
var step = if (months <= 0) 0 else (months + interval - 1) / interval
|
||||
var iterations = 0
|
||||
while (iterations++ < MAX_ITERATIONS) {
|
||||
val month = anchorMonth.plusMonths(step * interval)
|
||||
val candidate = month.atDay(recurrence.dayOfMonth.coerceAtMost(month.lengthOfMonth()))
|
||||
if (!candidate.isBefore(effective) && !candidate.isBefore(anchor)) return candidate
|
||||
step += 1
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun previousMonthlyDate(
|
||||
recurrence: ScheduleRecurrence.Monthly,
|
||||
date: LocalDate,
|
||||
): LocalDate? {
|
||||
if (recurrence.dayOfMonth !in 1..31 || recurrence.intervalMonths < 1) return null
|
||||
val anchor = recurrence.anchorDate
|
||||
if (date < anchor) return null
|
||||
val interval = recurrence.intervalMonths.toLong()
|
||||
val anchorMonth = YearMonth.from(anchor)
|
||||
val months = ChronoUnit.MONTHS.between(anchorMonth, YearMonth.from(date))
|
||||
var step = (months / interval).coerceAtLeast(0)
|
||||
var iterations = 0
|
||||
while (step >= 0 && iterations++ < MAX_ITERATIONS) {
|
||||
val month = anchorMonth.plusMonths(step * interval)
|
||||
val candidate = month.atDay(recurrence.dayOfMonth.coerceAtMost(month.lengthOfMonth()))
|
||||
if (!candidate.isAfter(date) && !candidate.isBefore(anchor)) return candidate
|
||||
step -= 1
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun overlaps(
|
||||
candidateWindows: List<ScheduleWindow>,
|
||||
otherWindows: List<ScheduleWindow>,
|
||||
): Boolean {
|
||||
candidateWindows.forEach { candidate ->
|
||||
otherWindows.forEach { other ->
|
||||
if (windowsConflict(candidate, other)) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun windowsConflict(first: ScheduleWindow, second: ScheduleWindow): Boolean {
|
||||
val firstEnd = first.end
|
||||
val secondEnd = second.end
|
||||
return when {
|
||||
firstEnd == null && secondEnd == null -> first.start == second.start
|
||||
firstEnd == null -> !first.start.isBefore(second.start) && first.start.isBefore(secondEnd)
|
||||
secondEnd == null -> !second.start.isBefore(first.start) && second.start.isBefore(firstEnd)
|
||||
else -> first.start.isBefore(secondEnd) && second.start.isBefore(firstEnd)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isLater(candidate: ScheduleOccurrence, current: ScheduleOccurrence): Boolean {
|
||||
if (candidate.start != current.start) return candidate.start.isAfter(current.start)
|
||||
val createdAt = candidate.schedule.createdAtMillis.compareTo(current.schedule.createdAtMillis)
|
||||
if (createdAt != 0) return createdAt > 0
|
||||
return candidate.schedule.id > current.schedule.id
|
||||
}
|
||||
|
||||
private fun earliest(current: Instant?, candidate: Instant): Instant =
|
||||
if (current == null || candidate.isBefore(current)) candidate else current
|
||||
|
||||
private fun mondayOf(date: LocalDate): LocalDate =
|
||||
date.minusDays((date.dayOfWeek.value - DayOfWeek.MONDAY.value).toLong())
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.krrishg.kwall.scheduler
|
||||
|
||||
import java.time.DayOfWeek
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@Serializable
|
||||
internal data class ScheduleRecordList(
|
||||
val version: Int = 1,
|
||||
val schedules: List<ScheduleRecord> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class ScheduleRecord(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val profileId: String,
|
||||
val fallbackProfileId: String,
|
||||
val type: String,
|
||||
val anchorDate: String,
|
||||
val startTime: String,
|
||||
val endTime: String? = null,
|
||||
val interval: Int = 1,
|
||||
val daysOfWeek: List<Int> = emptyList(),
|
||||
val dayOfMonth: Int = 1,
|
||||
val enabled: Boolean = true,
|
||||
val createdAtMillis: Long = 0L,
|
||||
)
|
||||
|
||||
internal object ScheduleJsonCodec {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
fun encode(schedules: List<ProfileSchedule>): String =
|
||||
json.encodeToString(
|
||||
ScheduleRecordList(schedules = schedules.map { it.toRecord() }),
|
||||
)
|
||||
|
||||
fun decode(raw: String?): List<ProfileSchedule> {
|
||||
if (raw.isNullOrBlank()) return emptyList()
|
||||
return runCatching {
|
||||
json.decodeFromString<ScheduleRecordList>(raw)
|
||||
.schedules
|
||||
.mapNotNull { it.toProfileSchedule() }
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
private fun ProfileSchedule.toRecord(): ScheduleRecord {
|
||||
val recurrence = this.recurrence
|
||||
val type = when (recurrence) {
|
||||
is ScheduleRecurrence.Once -> TYPE_ONCE
|
||||
is ScheduleRecurrence.Daily -> TYPE_DAILY
|
||||
is ScheduleRecurrence.Weekly -> TYPE_WEEKLY
|
||||
is ScheduleRecurrence.Monthly -> TYPE_MONTHLY
|
||||
}
|
||||
val interval = when (recurrence) {
|
||||
is ScheduleRecurrence.Once -> 1
|
||||
is ScheduleRecurrence.Daily -> recurrence.intervalDays
|
||||
is ScheduleRecurrence.Weekly -> recurrence.intervalWeeks
|
||||
is ScheduleRecurrence.Monthly -> recurrence.intervalMonths
|
||||
}
|
||||
val daysOfWeek = (recurrence as? ScheduleRecurrence.Weekly)
|
||||
?.daysOfWeek
|
||||
?.sortedBy { it.value }
|
||||
?.map { it.value }
|
||||
.orEmpty()
|
||||
val dayOfMonth = (recurrence as? ScheduleRecurrence.Monthly)?.dayOfMonth ?: 1
|
||||
return ScheduleRecord(
|
||||
id = id,
|
||||
name = name,
|
||||
profileId = profileId,
|
||||
fallbackProfileId = fallbackProfileId,
|
||||
type = type,
|
||||
anchorDate = recurrence.anchorDate.toString(),
|
||||
startTime = startTime.toString(),
|
||||
endTime = endTime?.toString(),
|
||||
interval = interval,
|
||||
daysOfWeek = daysOfWeek,
|
||||
dayOfMonth = dayOfMonth,
|
||||
enabled = enabled,
|
||||
createdAtMillis = createdAtMillis,
|
||||
)
|
||||
}
|
||||
|
||||
private fun ScheduleRecord.toProfileSchedule(): ProfileSchedule? {
|
||||
val anchor = runCatching { LocalDate.parse(anchorDate) }.getOrNull() ?: return null
|
||||
val start = runCatching { LocalTime.parse(startTime) }.getOrNull() ?: return null
|
||||
val end = if (endTime != null) {
|
||||
runCatching { LocalTime.parse(endTime) }.getOrNull() ?: return null
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val recurrence = when (type) {
|
||||
TYPE_ONCE -> ScheduleRecurrence.Once(anchor)
|
||||
TYPE_DAILY -> ScheduleRecurrence.Daily(anchor, interval)
|
||||
TYPE_WEEKLY -> {
|
||||
val days = daysOfWeek.mapNotNull { value ->
|
||||
runCatching { DayOfWeek.of(value) }.getOrNull()
|
||||
}.toSet()
|
||||
ScheduleRecurrence.Weekly(anchor, days, interval)
|
||||
}
|
||||
TYPE_MONTHLY -> ScheduleRecurrence.Monthly(anchor, dayOfMonth, interval)
|
||||
else -> return null
|
||||
}
|
||||
return ProfileSchedule(
|
||||
id = id,
|
||||
name = name,
|
||||
profileId = profileId,
|
||||
fallbackProfileId = fallbackProfileId,
|
||||
recurrence = recurrence,
|
||||
startTime = start,
|
||||
endTime = end,
|
||||
enabled = enabled,
|
||||
createdAtMillis = createdAtMillis,
|
||||
)
|
||||
}
|
||||
|
||||
private const val TYPE_ONCE = "once"
|
||||
private const val TYPE_DAILY = "daily"
|
||||
private const val TYPE_WEEKLY = "weekly"
|
||||
private const val TYPE_MONTHLY = "monthly"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.krrishg.kwall.scheduler
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface ScheduleStore {
|
||||
val schedules: Flow<List<ProfileSchedule>>
|
||||
|
||||
suspend fun create(schedule: ProfileSchedule): Boolean
|
||||
|
||||
suspend fun update(schedule: ProfileSchedule): Boolean
|
||||
|
||||
suspend fun delete(id: String): Boolean
|
||||
|
||||
suspend fun setEnabled(id: String, enabled: Boolean): Boolean
|
||||
|
||||
suspend fun readRuntime(): SchedulerRuntime
|
||||
|
||||
suspend fun writeRuntime(runtime: SchedulerRuntime)
|
||||
|
||||
suspend fun clearRuntime()
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.krrishg.kwall.scheduler
|
||||
|
||||
data class SchedulerRuntime(
|
||||
val activeScheduleId: String? = null,
|
||||
val activeWindowStartMillis: Long? = null,
|
||||
val activeProfileId: String? = null,
|
||||
val fallbackProfileId: String? = null,
|
||||
val firewallWasEnabledBefore: Boolean? = null,
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.krrishg.kwall.settings
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface AppSettingsStore {
|
||||
val appLockEnabled: Flow<Boolean>
|
||||
val autoBlockNewApps: Flow<Boolean>
|
||||
|
||||
suspend fun setAppLockEnabled(enabled: Boolean)
|
||||
|
||||
suspend fun setAutoBlockNewApps(enabled: Boolean)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.krrishg.kwall.settings
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import java.io.IOException
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private val Context.settingsDataStore by preferencesDataStore(name = "settings")
|
||||
|
||||
class DataStoreAppSettingsStore(context: Context) : AppSettingsStore {
|
||||
|
||||
private val dataStore = context.applicationContext.settingsDataStore
|
||||
|
||||
private val preferences = dataStore.data
|
||||
.catch { error ->
|
||||
if (error is IOException) {
|
||||
emit(emptyPreferences())
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
override val appLockEnabled: Flow<Boolean> = preferences
|
||||
.map { values -> values[APP_LOCK_ENABLED_KEY] ?: true }
|
||||
|
||||
override val autoBlockNewApps: Flow<Boolean> = preferences
|
||||
.map { values -> values[AUTO_BLOCK_NEW_APPS_KEY] ?: false }
|
||||
|
||||
override suspend fun setAppLockEnabled(enabled: Boolean) {
|
||||
dataStore.edit { values ->
|
||||
values[APP_LOCK_ENABLED_KEY] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setAutoBlockNewApps(enabled: Boolean) {
|
||||
dataStore.edit { values ->
|
||||
values[AUTO_BLOCK_NEW_APPS_KEY] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val APP_LOCK_ENABLED_KEY = booleanPreferencesKey("app_lock_enabled")
|
||||
val AUTO_BLOCK_NEW_APPS_KEY = booleanPreferencesKey("auto_block_new_apps")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.krrishg.kwall.shizuku
|
||||
|
||||
import com.krrishg.kwall.firewall.ShellExecutor
|
||||
import com.krrishg.kwall.firewall.ShellResult
|
||||
import java.lang.reflect.Method
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import rikka.shizuku.Shizuku
|
||||
import rikka.shizuku.ShizukuRemoteProcess
|
||||
|
||||
class ShizukuShellExecutor : ShellExecutor {
|
||||
|
||||
override suspend fun exec(command: String): ShellResult = withContext(Dispatchers.IO) {
|
||||
if (!runCatching { Shizuku.pingBinder() }.getOrDefault(false)) {
|
||||
return@withContext ShellResult(-1, "", "Shizuku is not running")
|
||||
}
|
||||
try {
|
||||
val process = newProcess(
|
||||
arrayOf("/system/bin/sh", "-c", command),
|
||||
null,
|
||||
null,
|
||||
) ?: return@withContext ShellResult(-1, "", "Shizuku process is unavailable")
|
||||
|
||||
coroutineScope {
|
||||
val stdout = async(Dispatchers.IO) {
|
||||
process.inputStream.bufferedReader().use { it.readText() }
|
||||
}
|
||||
val stderr = async(Dispatchers.IO) {
|
||||
process.errorStream.bufferedReader().use { it.readText() }
|
||||
}
|
||||
process.outputStream.close()
|
||||
val exitCode = process.waitFor()
|
||||
val result = ShellResult(exitCode, stdout.await(), stderr.await())
|
||||
process.destroy()
|
||||
result
|
||||
}
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (error: Exception) {
|
||||
ShellResult(-1, "", error.message ?: error.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun newProcess(
|
||||
command: Array<String>,
|
||||
environment: Array<String>?,
|
||||
directory: String?,
|
||||
): ShizukuRemoteProcess? {
|
||||
val method: Method = NEW_PROCESS_METHOD ?: return null
|
||||
return method.invoke(null, command, environment, directory) as? ShizukuRemoteProcess
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val NEW_PROCESS_METHOD: Method? by lazy {
|
||||
runCatching {
|
||||
Shizuku::class.java.getDeclaredMethod(
|
||||
"newProcess",
|
||||
Array<String>::class.java,
|
||||
Array<String>::class.java,
|
||||
String::class.java,
|
||||
).apply { isAccessible = true }
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,775 @@
|
||||
package com.krrishg.kwall.ui.apps
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.DateRange
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel as composeViewModel
|
||||
import com.krrishg.kwall.R
|
||||
import com.krrishg.kwall.data.InstalledApp
|
||||
import com.krrishg.kwall.firewall.AppBlockability
|
||||
import com.krrishg.kwall.firewall.ChainTransport
|
||||
import com.krrishg.kwall.firewall.FirewallAvailability
|
||||
import com.krrishg.kwall.firewall.FirewallController
|
||||
import com.krrishg.kwall.firewall.RulesTransport
|
||||
import com.krrishg.kwall.firewall.SHIZUKU_PACKAGE
|
||||
import com.krrishg.kwall.scheduler.ScheduleAlarms
|
||||
import com.krrishg.kwall.scheduler.ScheduleStore
|
||||
import com.krrishg.kwall.ui.components.AppIcon
|
||||
import com.krrishg.kwall.ui.components.AppIconPreloader
|
||||
import com.krrishg.kwall.ui.components.SystemBadge
|
||||
import com.krrishg.kwall.ui.components.WorkBadge
|
||||
import com.krrishg.kwall.ui.components.blockabilityReason
|
||||
import com.krrishg.kwall.ui.detail.AppDetailRoute
|
||||
import com.krrishg.kwall.ui.detail.AppDetailViewModel
|
||||
import com.krrishg.kwall.ui.profiles.ProfilesDialog
|
||||
import com.krrishg.kwall.ui.schedules.SchedulesRoute
|
||||
import com.krrishg.kwall.ui.schedules.SchedulesViewModel
|
||||
import com.krrishg.kwall.ui.settings.AppSettingsDialog
|
||||
import com.krrishg.kwall.ui.theme.KwallTheme
|
||||
|
||||
@Composable
|
||||
fun AppListRoute(
|
||||
viewModel: AppListViewModel,
|
||||
detailViewModelFactory: ViewModelProvider.Factory,
|
||||
appLockEnabled: Boolean,
|
||||
onAppLockEnabledChange: (Boolean) -> Unit,
|
||||
autoBlockNewApps: Boolean,
|
||||
onAutoBlockNewAppsChange: (Boolean) -> Unit,
|
||||
scheduleStore: ScheduleStore,
|
||||
scheduleAlarms: ScheduleAlarms,
|
||||
firewallController: FirewallController,
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
var showSchedules by rememberSaveable { mutableStateOf(false) }
|
||||
if (showSchedules) {
|
||||
val schedulesFactory = remember(scheduleStore, scheduleAlarms, firewallController) {
|
||||
SchedulesViewModel.factory(
|
||||
scheduleStore = scheduleStore,
|
||||
firewallController = firewallController,
|
||||
alarms = scheduleAlarms,
|
||||
)
|
||||
}
|
||||
SchedulesRoute(
|
||||
viewModel = composeViewModel<SchedulesViewModel>(factory = schedulesFactory),
|
||||
onBack = { showSchedules = false },
|
||||
)
|
||||
return
|
||||
}
|
||||
val selected = uiState.selectedApp
|
||||
if (selected != null) {
|
||||
BackHandler(onBack = viewModel::clearSelection)
|
||||
AppDetailRoute(
|
||||
app = selected.app,
|
||||
blockability = selected.blockability,
|
||||
warnAllProfiles = selected.warnAllProfiles,
|
||||
viewModel = composeViewModel<AppDetailViewModel>(factory = detailViewModelFactory),
|
||||
onBack = viewModel::clearSelection,
|
||||
)
|
||||
return
|
||||
}
|
||||
AppListScreen(
|
||||
uiState = uiState,
|
||||
onShowSystemAppsChange = viewModel::setShowSystemApps,
|
||||
onFilterChange = viewModel::setFilter,
|
||||
onSearchQueryChange = viewModel::setSearchQuery,
|
||||
onRefresh = viewModel::refresh,
|
||||
onBlockedChange = viewModel::setBlocked,
|
||||
onFirewallEnabledChange = viewModel::setFirewallEnabled,
|
||||
onGrantPermission = viewModel::requestFirewallPermission,
|
||||
onRetryAvailability = viewModel::refreshFirewallAvailability,
|
||||
onInstallShizuku = { openShizukuStore(context) },
|
||||
onOpenShizuku = { openShizuku(context) },
|
||||
onAppClick = viewModel::selectApp,
|
||||
onOpenSchedules = { showSchedules = true },
|
||||
appLockEnabled = appLockEnabled,
|
||||
onAppLockEnabledChange = onAppLockEnabledChange,
|
||||
autoBlockNewApps = autoBlockNewApps,
|
||||
onAutoBlockNewAppsChange = onAutoBlockNewAppsChange,
|
||||
onSwitchProfile = viewModel::switchProfile,
|
||||
onCreateProfile = viewModel::createProfile,
|
||||
onRenameProfile = viewModel::renameProfile,
|
||||
onDuplicateProfile = viewModel::duplicateProfile,
|
||||
onDeleteProfile = viewModel::deleteProfile,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AppListScreen(
|
||||
uiState: AppListUiState,
|
||||
onShowSystemAppsChange: (Boolean) -> Unit,
|
||||
onFilterChange: (AppListFilter) -> Unit,
|
||||
onSearchQueryChange: (String) -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onBlockedChange: (InstalledApp, Boolean) -> Unit,
|
||||
onFirewallEnabledChange: (Boolean) -> Unit,
|
||||
onGrantPermission: () -> Unit,
|
||||
onRetryAvailability: () -> Unit,
|
||||
onInstallShizuku: () -> Unit,
|
||||
onOpenShizuku: () -> Unit,
|
||||
onAppClick: (AppRowUiState) -> Unit,
|
||||
onOpenSchedules: () -> Unit,
|
||||
appLockEnabled: Boolean,
|
||||
onAppLockEnabledChange: (Boolean) -> Unit,
|
||||
autoBlockNewApps: Boolean,
|
||||
onAutoBlockNewAppsChange: (Boolean) -> Unit,
|
||||
onSwitchProfile: (String) -> Unit,
|
||||
onCreateProfile: (String) -> Unit,
|
||||
onRenameProfile: (String, String) -> Unit,
|
||||
onDuplicateProfile: (String, String) -> Unit,
|
||||
onDeleteProfile: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var showSettingsDialog by rememberSaveable { mutableStateOf(false) }
|
||||
var showFirewallInfoDialog by rememberSaveable { mutableStateOf(false) }
|
||||
var showProfilesDialog by rememberSaveable { mutableStateOf(false) }
|
||||
val firewallReady = uiState.firewall.availability is FirewallAvailability.Ready
|
||||
val firewallToggleDescription = stringResource(R.string.firewall_title)
|
||||
val activeProfileName = uiState.firewall.activeProfile?.name
|
||||
?: stringResource(R.string.profile_default_name)
|
||||
val profileSelectorDescription = stringResource(
|
||||
R.string.profile_switch_description,
|
||||
activeProfileName,
|
||||
)
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clickable { showProfilesDialog = true }
|
||||
.semantics { contentDescription = profileSelectorDescription },
|
||||
) {
|
||||
Text(stringResource(R.string.apps_title))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = activeProfileName,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = onOpenSchedules) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.DateRange,
|
||||
contentDescription = stringResource(R.string.schedules_open),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { showFirewallInfoDialog = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Info,
|
||||
contentDescription = stringResource(R.string.firewall_info_open),
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = uiState.firewall.enabled,
|
||||
onCheckedChange = onFirewallEnabledChange,
|
||||
enabled = firewallReady && !uiState.firewall.isApplying,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.semantics { contentDescription = firewallToggleDescription },
|
||||
)
|
||||
IconButton(onClick = { showSettingsDialog = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Settings,
|
||||
contentDescription = stringResource(R.string.settings_open),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onRefresh, enabled = !uiState.isLoading) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Refresh,
|
||||
contentDescription = stringResource(R.string.refresh),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { contentPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(contentPadding)
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
AppSearchField(
|
||||
query = uiState.searchQuery,
|
||||
onQueryChange = onSearchQueryChange,
|
||||
)
|
||||
AppFilterRow(
|
||||
selected = uiState.filter,
|
||||
onFilterChange = onFilterChange,
|
||||
)
|
||||
SystemAppsToggle(
|
||||
checked = uiState.showSystemApps,
|
||||
onCheckedChange = onShowSystemAppsChange,
|
||||
)
|
||||
HorizontalDivider()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
AppsContent(
|
||||
uiState = uiState,
|
||||
onRetry = onRefresh,
|
||||
onBlockedChange = onBlockedChange,
|
||||
onAppClick = onAppClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showSettingsDialog) {
|
||||
AppSettingsDialog(
|
||||
appLockEnabled = appLockEnabled,
|
||||
autoBlockNewApps = autoBlockNewApps,
|
||||
onAppLockEnabledChange = onAppLockEnabledChange,
|
||||
onAutoBlockNewAppsChange = onAutoBlockNewAppsChange,
|
||||
onDismiss = { showSettingsDialog = false },
|
||||
)
|
||||
}
|
||||
if (showFirewallInfoDialog) {
|
||||
FirewallInfoDialog(
|
||||
firewall = uiState.firewall,
|
||||
onGrantPermission = onGrantPermission,
|
||||
onRetryAvailability = onRetryAvailability,
|
||||
onInstallShizuku = onInstallShizuku,
|
||||
onOpenShizuku = onOpenShizuku,
|
||||
onDismiss = { showFirewallInfoDialog = false },
|
||||
)
|
||||
}
|
||||
if (showProfilesDialog) {
|
||||
ProfilesDialog(
|
||||
profiles = uiState.firewall.profiles,
|
||||
activeProfileId = uiState.firewall.activeProfileId,
|
||||
isBusy = uiState.firewall.isApplying,
|
||||
errorMessage = uiState.firewall.errorMessage,
|
||||
onSwitch = onSwitchProfile,
|
||||
onCreate = onCreateProfile,
|
||||
onRename = onRenameProfile,
|
||||
onDuplicate = onDuplicateProfile,
|
||||
onDelete = onDeleteProfile,
|
||||
onDismiss = { showProfilesDialog = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppSearchField(
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp, vertical = 4.dp),
|
||||
placeholder = { Text(stringResource(R.string.search_apps)) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Search,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
trailingIcon = if (query.isNotEmpty()) {
|
||||
{
|
||||
IconButton(onClick = { onQueryChange("") }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = stringResource(R.string.clear_search),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppFilterRow(
|
||||
selected: AppListFilter,
|
||||
onFilterChange: (AppListFilter) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
AppListFilter.entries.forEach { filter ->
|
||||
FilterChip(
|
||||
selected = selected == filter,
|
||||
onClick = { onFilterChange(filter) },
|
||||
label = { Text(stringResource(filter.labelRes)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val AppListFilter.labelRes: Int
|
||||
get() = when (this) {
|
||||
AppListFilter.ALL -> R.string.filter_all
|
||||
AppListFilter.BLOCKED -> R.string.filter_blocked
|
||||
AppListFilter.UNBLOCKED -> R.string.filter_unblocked
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SystemAppsToggle(
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.toggleable(
|
||||
value = checked,
|
||||
onValueChange = onCheckedChange,
|
||||
role = Role.Switch,
|
||||
)
|
||||
.padding(horizontal = 20.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.show_system_apps),
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppsContent(
|
||||
uiState: AppListUiState,
|
||||
onRetry: () -> Unit,
|
||||
onBlockedChange: (InstalledApp, Boolean) -> Unit,
|
||||
onAppClick: (AppRowUiState) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when {
|
||||
uiState.isLoading -> LoadingState(modifier)
|
||||
uiState.errorMessage != null -> ErrorState(
|
||||
message = uiState.errorMessage,
|
||||
onRetry = onRetry,
|
||||
modifier = modifier,
|
||||
)
|
||||
uiState.apps.isEmpty() -> EmptyState(
|
||||
showSystemApps = uiState.showSystemApps,
|
||||
isFiltering = uiState.filter != AppListFilter.ALL || uiState.searchQuery.isNotBlank(),
|
||||
modifier = modifier,
|
||||
)
|
||||
else -> AppsList(
|
||||
rows = uiState.apps,
|
||||
togglesEnabled = uiState.firewall.availability is FirewallAvailability.Ready,
|
||||
isApplying = uiState.firewall.isApplying,
|
||||
onBlockedChange = onBlockedChange,
|
||||
onAppClick = onAppClick,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppsList(
|
||||
rows: List<AppRowUiState>,
|
||||
togglesEnabled: Boolean,
|
||||
isApplying: Boolean,
|
||||
onBlockedChange: (InstalledApp, Boolean) -> Unit,
|
||||
onAppClick: (AppRowUiState) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val apps = remember(rows) { rows.map { it.app } }
|
||||
AppIconPreloader(apps = apps)
|
||||
Column(modifier = modifier.fillMaxSize()) {
|
||||
Text(
|
||||
text = pluralStringResource(R.plurals.apps_count, rows.size, rows.size),
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = rows,
|
||||
key = { _, row -> row.listKey },
|
||||
contentType = { _, _ -> "app" },
|
||||
) { index, row ->
|
||||
AppRow(
|
||||
row = row,
|
||||
togglesEnabled = togglesEnabled && !isApplying,
|
||||
onBlockedChange = onBlockedChange,
|
||||
onAppClick = onAppClick,
|
||||
)
|
||||
if (index < rows.lastIndex) {
|
||||
HorizontalDivider(modifier = Modifier.padding(start = 76.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppRow(
|
||||
row: AppRowUiState,
|
||||
togglesEnabled: Boolean,
|
||||
onBlockedChange: (InstalledApp, Boolean) -> Unit,
|
||||
onAppClick: (AppRowUiState) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val app = row.app
|
||||
val blockable = row.blockability == AppBlockability.BLOCKABLE
|
||||
val reason = blockabilityReason(row.blockability)
|
||||
?: if (row.warnAllProfiles) {
|
||||
stringResource(R.string.firewall_blocked_warning_all_profiles)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onAppClick(row) }
|
||||
.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
AppIcon(app = app)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = app.label,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (app.isWorkProfile) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
WorkBadge()
|
||||
}
|
||||
if (app.isSystem) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
SystemBadge()
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = app.packageName,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (reason != null) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = reason,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (row.warnAllProfiles) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
key(app.key) {
|
||||
Switch(
|
||||
checked = row.isBlocked,
|
||||
onCheckedChange = { checked -> onBlockedChange(app, checked) },
|
||||
enabled = togglesEnabled && blockable,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingState(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.loading_apps),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorState(
|
||||
message: String?,
|
||||
onRetry: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.load_error),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
if (!message.isNullOrBlank()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Button(onClick = onRetry) {
|
||||
Text(stringResource(R.string.retry))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyState(
|
||||
showSystemApps: Boolean,
|
||||
isFiltering: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val showAllAppsMessage = showSystemApps || isFiltering
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (showAllAppsMessage) {
|
||||
R.string.all_apps_empty_title
|
||||
} else {
|
||||
R.string.user_apps_empty_title
|
||||
},
|
||||
),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (showAllAppsMessage) {
|
||||
R.string.all_apps_empty_description
|
||||
} else {
|
||||
R.string.user_apps_empty_description
|
||||
},
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openShizukuStore(context: Context) {
|
||||
val storeIntent = Intent(
|
||||
Intent.ACTION_VIEW,
|
||||
"market://details?id=$SHIZUKU_PACKAGE".toUri(),
|
||||
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
if (runCatching { context.startActivity(storeIntent) }.isSuccess) return
|
||||
|
||||
val webIntent = Intent(
|
||||
Intent.ACTION_VIEW,
|
||||
"https://play.google.com/store/apps/details?id=$SHIZUKU_PACKAGE".toUri(),
|
||||
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
runCatching { context.startActivity(webIntent) }
|
||||
}
|
||||
|
||||
private fun openShizuku(context: Context) {
|
||||
val launchIntent = context.packageManager.getLaunchIntentForPackage(SHIZUKU_PACKAGE)
|
||||
if (launchIntent != null) {
|
||||
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
runCatching { context.startActivity(launchIntent) }
|
||||
.onFailure { openShizukuStore(context) }
|
||||
} else {
|
||||
openShizukuStore(context)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun AppListScreenPreview() {
|
||||
KwallTheme {
|
||||
AppListScreen(
|
||||
uiState = AppListUiState(
|
||||
apps = listOf(
|
||||
AppRowUiState(
|
||||
app = InstalledApp(
|
||||
packageName = "com.example.notes",
|
||||
label = "Notes",
|
||||
isSystem = false,
|
||||
icon = null,
|
||||
uid = 10123,
|
||||
userId = 0,
|
||||
isWorkProfile = false,
|
||||
hasInternetPermission = true,
|
||||
),
|
||||
isBlocked = true,
|
||||
blockability = AppBlockability.BLOCKABLE,
|
||||
warnAllProfiles = false,
|
||||
),
|
||||
AppRowUiState(
|
||||
app = InstalledApp(
|
||||
packageName = "com.example.chat",
|
||||
label = "Chat",
|
||||
isSystem = false,
|
||||
icon = null,
|
||||
uid = 1010123,
|
||||
userId = 10,
|
||||
isWorkProfile = true,
|
||||
hasInternetPermission = true,
|
||||
),
|
||||
isBlocked = false,
|
||||
blockability = AppBlockability.BLOCKABLE,
|
||||
warnAllProfiles = false,
|
||||
),
|
||||
AppRowUiState(
|
||||
app = InstalledApp(
|
||||
packageName = "com.android.systemui",
|
||||
label = "System UI",
|
||||
isSystem = true,
|
||||
icon = null,
|
||||
uid = 1000,
|
||||
userId = 0,
|
||||
isWorkProfile = false,
|
||||
hasInternetPermission = true,
|
||||
),
|
||||
isBlocked = false,
|
||||
blockability = AppBlockability.SYSTEM_UID,
|
||||
warnAllProfiles = false,
|
||||
),
|
||||
),
|
||||
isLoading = false,
|
||||
firewall = FirewallUiState(
|
||||
enabled = true,
|
||||
availability = FirewallAvailability.Ready(
|
||||
rulesTransport = RulesTransport.BINDER,
|
||||
chainTransport = ChainTransport.SHELL,
|
||||
),
|
||||
blockedCount = 1,
|
||||
),
|
||||
),
|
||||
onShowSystemAppsChange = {},
|
||||
onFilterChange = {},
|
||||
onSearchQueryChange = {},
|
||||
onRefresh = {},
|
||||
onBlockedChange = { _, _ -> },
|
||||
onFirewallEnabledChange = {},
|
||||
onGrantPermission = {},
|
||||
onRetryAvailability = {},
|
||||
onInstallShizuku = {},
|
||||
onOpenShizuku = {},
|
||||
onAppClick = {},
|
||||
onOpenSchedules = {},
|
||||
appLockEnabled = true,
|
||||
onAppLockEnabledChange = {},
|
||||
autoBlockNewApps = false,
|
||||
onAutoBlockNewAppsChange = {},
|
||||
onSwitchProfile = {},
|
||||
onCreateProfile = {},
|
||||
onRenameProfile = { _, _ -> },
|
||||
onDuplicateProfile = { _, _ -> },
|
||||
onDeleteProfile = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
package com.krrishg.kwall.ui.apps
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.krrishg.kwall.AppChangeNotifier
|
||||
import com.krrishg.kwall.data.AppRepository
|
||||
import com.krrishg.kwall.data.InstalledApp
|
||||
import com.krrishg.kwall.firewall.AppBlockability
|
||||
import com.krrishg.kwall.firewall.AppKey
|
||||
import com.krrishg.kwall.firewall.FIREWALL_PACKAGES
|
||||
import com.krrishg.kwall.firewall.FirewallAvailability
|
||||
import com.krrishg.kwall.firewall.FirewallController
|
||||
import com.krrishg.kwall.firewall.FirewallDriftState
|
||||
import com.krrishg.kwall.firewall.RuleProfile
|
||||
import com.krrishg.kwall.firewall.RulesTransport
|
||||
import com.krrishg.kwall.firewall.blockabilityFor
|
||||
import com.krrishg.kwall.scheduler.ScheduleStore
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class AppRowUiState(
|
||||
val app: InstalledApp,
|
||||
val isBlocked: Boolean,
|
||||
val blockability: AppBlockability,
|
||||
val warnAllProfiles: Boolean,
|
||||
) {
|
||||
val listKey: String = app.key.toString()
|
||||
}
|
||||
|
||||
enum class AppListFilter {
|
||||
ALL,
|
||||
BLOCKED,
|
||||
UNBLOCKED,
|
||||
}
|
||||
|
||||
data class FirewallUiState(
|
||||
val enabled: Boolean = false,
|
||||
val availability: FirewallAvailability = FirewallAvailability.Checking,
|
||||
val blockedCount: Int = 0,
|
||||
val isApplying: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val drift: FirewallDriftState = FirewallDriftState(),
|
||||
val profiles: List<RuleProfile> = emptyList(),
|
||||
val activeProfileId: String = "",
|
||||
) {
|
||||
val activeProfile: RuleProfile?
|
||||
get() = profiles.firstOrNull { it.id == activeProfileId } ?: profiles.firstOrNull()
|
||||
}
|
||||
|
||||
private data class FirewallSnapshot(
|
||||
val enabled: Boolean,
|
||||
val blockedApps: Set<AppKey>,
|
||||
val availability: FirewallAvailability,
|
||||
val error: String?,
|
||||
val drift: FirewallDriftState,
|
||||
val profiles: List<RuleProfile>,
|
||||
val activeProfileId: String,
|
||||
)
|
||||
|
||||
data class AppListUiState(
|
||||
val apps: List<AppRowUiState> = emptyList(),
|
||||
val showSystemApps: Boolean = false,
|
||||
val filter: AppListFilter = AppListFilter.ALL,
|
||||
val searchQuery: String = "",
|
||||
val isLoading: Boolean = true,
|
||||
val errorMessage: String? = null,
|
||||
val firewall: FirewallUiState = FirewallUiState(),
|
||||
val selectedApp: AppRowUiState? = null,
|
||||
)
|
||||
|
||||
class AppListViewModel(
|
||||
private val repository: AppRepository,
|
||||
private val firewallController: FirewallController,
|
||||
private val appChangeNotifier: AppChangeNotifier,
|
||||
private val scheduleStore: ScheduleStore,
|
||||
) : ViewModel() {
|
||||
|
||||
private var allApps: List<InstalledApp> = emptyList()
|
||||
private var uidCounts: Map<Int, Int> = emptyMap()
|
||||
private var packageCounts: Map<String, Int> = emptyMap()
|
||||
private var loadJob: Job? = null
|
||||
|
||||
private val _uiState = MutableStateFlow(AppListUiState())
|
||||
val uiState: StateFlow<AppListUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
observeFirewallState()
|
||||
observeAppChanges()
|
||||
viewModelScope.launch { firewallController.refreshAvailability() }
|
||||
loadApps()
|
||||
}
|
||||
|
||||
fun setShowSystemApps(show: Boolean) {
|
||||
if (_uiState.value.showSystemApps == show) return
|
||||
_uiState.update { current ->
|
||||
val updated = current.copy(showSystemApps = show)
|
||||
updated.copy(apps = buildRows(updated))
|
||||
}
|
||||
}
|
||||
|
||||
fun setFilter(filter: AppListFilter) {
|
||||
if (_uiState.value.filter == filter) return
|
||||
_uiState.update { current ->
|
||||
val updated = current.copy(filter = filter)
|
||||
updated.copy(apps = buildRows(updated))
|
||||
}
|
||||
}
|
||||
|
||||
fun setSearchQuery(query: String) {
|
||||
if (_uiState.value.searchQuery == query) return
|
||||
_uiState.update { current ->
|
||||
val updated = current.copy(searchQuery = query)
|
||||
updated.copy(apps = buildRows(updated))
|
||||
}
|
||||
}
|
||||
|
||||
fun selectApp(row: AppRowUiState) {
|
||||
_uiState.update { it.copy(selectedApp = row) }
|
||||
}
|
||||
|
||||
fun clearSelection() {
|
||||
_uiState.update { it.copy(selectedApp = null) }
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
loadApps()
|
||||
viewModelScope.launch {
|
||||
firewallController.refreshAvailability()
|
||||
firewallController.restore()
|
||||
firewallController.checkForDrift()
|
||||
}
|
||||
}
|
||||
|
||||
fun setBlocked(app: InstalledApp, blocked: Boolean) {
|
||||
if (_uiState.value.firewall.isApplying) return
|
||||
_uiState.update { current ->
|
||||
current.copy(
|
||||
firewall = current.firewall.copy(isApplying = true, errorMessage = null),
|
||||
)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
firewallController.setBlocked(app, blocked)
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (error: Exception) {
|
||||
_uiState.update { current ->
|
||||
current.copy(
|
||||
firewall = current.firewall.copy(
|
||||
errorMessage = error.message ?: "Could not update the firewall",
|
||||
),
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
_uiState.update { current ->
|
||||
current.copy(firewall = current.firewall.copy(isApplying = false))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setFirewallEnabled(enabled: Boolean) {
|
||||
if (_uiState.value.firewall.isApplying) return
|
||||
_uiState.update { current ->
|
||||
current.copy(
|
||||
firewall = current.firewall.copy(isApplying = true, errorMessage = null),
|
||||
)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
firewallController.setEnabled(enabled)
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (error: Exception) {
|
||||
_uiState.update { current ->
|
||||
current.copy(
|
||||
firewall = current.firewall.copy(
|
||||
errorMessage = error.message ?: "Could not update the firewall",
|
||||
),
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
_uiState.update { current ->
|
||||
current.copy(firewall = current.firewall.copy(isApplying = false))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun requestFirewallPermission() {
|
||||
firewallController.requestPermission()
|
||||
}
|
||||
|
||||
fun refreshFirewallAvailability() {
|
||||
viewModelScope.launch { firewallController.refreshAvailability() }
|
||||
}
|
||||
|
||||
fun switchProfile(id: String) {
|
||||
applyProfileOperation { firewallController.switchProfile(id) }
|
||||
}
|
||||
|
||||
fun createProfile(name: String) {
|
||||
applyProfileOperation { firewallController.createProfile(name) }
|
||||
}
|
||||
|
||||
fun renameProfile(id: String, name: String) {
|
||||
applyProfileOperation { firewallController.renameProfile(id, name) }
|
||||
}
|
||||
|
||||
fun duplicateProfile(id: String, name: String) {
|
||||
applyProfileOperation { firewallController.duplicateProfile(id, name) }
|
||||
}
|
||||
|
||||
fun deleteProfile(id: String) {
|
||||
if (_uiState.value.firewall.isApplying) return
|
||||
_uiState.update { current ->
|
||||
current.copy(
|
||||
firewall = current.firewall.copy(isApplying = true, errorMessage = null),
|
||||
)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val usedBySchedule = scheduleStore.schedules.first().any { schedule ->
|
||||
schedule.profileId == id || schedule.fallbackProfileId == id
|
||||
}
|
||||
if (usedBySchedule) {
|
||||
_uiState.update { current ->
|
||||
current.copy(
|
||||
firewall = current.firewall.copy(
|
||||
errorMessage = "This profile is used by a schedule",
|
||||
),
|
||||
)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
firewallController.deleteProfile(id)
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (error: Exception) {
|
||||
_uiState.update { current ->
|
||||
current.copy(
|
||||
firewall = current.firewall.copy(
|
||||
errorMessage = error.message ?: "Could not update profile",
|
||||
),
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
_uiState.update { current ->
|
||||
current.copy(firewall = current.firewall.copy(isApplying = false))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyProfileOperation(operation: suspend () -> Unit) {
|
||||
if (_uiState.value.firewall.isApplying) return
|
||||
_uiState.update { current ->
|
||||
current.copy(
|
||||
firewall = current.firewall.copy(isApplying = true, errorMessage = null),
|
||||
)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
operation()
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (error: Exception) {
|
||||
_uiState.update { current ->
|
||||
current.copy(
|
||||
firewall = current.firewall.copy(
|
||||
errorMessage = error.message ?: "Could not update profile",
|
||||
),
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
_uiState.update { current ->
|
||||
current.copy(firewall = current.firewall.copy(isApplying = false))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeFirewallState() {
|
||||
viewModelScope.launch {
|
||||
combine(
|
||||
firewallController.state,
|
||||
firewallController.availability,
|
||||
firewallController.error,
|
||||
firewallController.drift,
|
||||
) { state, availability, error, drift ->
|
||||
FirewallSnapshot(
|
||||
enabled = state.enabled,
|
||||
blockedApps = state.blockedApps,
|
||||
availability = availability,
|
||||
error = error,
|
||||
drift = drift,
|
||||
profiles = state.profiles,
|
||||
activeProfileId = state.activeProfileId,
|
||||
)
|
||||
}.collect { snapshot ->
|
||||
_uiState.update { current ->
|
||||
val updated = current.copy(
|
||||
firewall = current.firewall.copy(
|
||||
enabled = snapshot.enabled,
|
||||
availability = snapshot.availability,
|
||||
blockedCount = snapshot.blockedApps.size,
|
||||
errorMessage = snapshot.error,
|
||||
drift = snapshot.drift,
|
||||
profiles = snapshot.profiles,
|
||||
activeProfileId = snapshot.activeProfileId,
|
||||
),
|
||||
)
|
||||
updated.copy(apps = buildRows(updated))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeAppChanges() {
|
||||
viewModelScope.launch {
|
||||
appChangeNotifier.events.collect {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadApps() {
|
||||
loadJob?.cancel()
|
||||
loadJob = viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
try {
|
||||
val apps = repository.loadInstalledApps()
|
||||
allApps = apps
|
||||
uidCounts = apps.groupingBy { it.uid }.eachCount()
|
||||
packageCounts = apps.groupingBy { it.packageName }.eachCount()
|
||||
_uiState.update { current ->
|
||||
val updated = current.copy(isLoading = false)
|
||||
updated.copy(apps = buildRows(updated))
|
||||
}
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (error: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = error.message ?: "Could not load apps",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildRows(uiState: AppListUiState): List<AppRowUiState> {
|
||||
val state = firewallController.state.value
|
||||
val availability = firewallController.availability.value
|
||||
val rulesTransport = (availability as? FirewallAvailability.Ready)?.rulesTransport
|
||||
val query = uiState.searchQuery.trim()
|
||||
return allApps
|
||||
.filter { uiState.showSystemApps || !it.isSystem }
|
||||
.map { app ->
|
||||
val assessment = blockabilityFor(
|
||||
app = app,
|
||||
uidCounts = uidCounts,
|
||||
packageCounts = packageCounts,
|
||||
rulesTransport = rulesTransport,
|
||||
firewallPackages = FIREWALL_PACKAGES,
|
||||
)
|
||||
AppRowUiState(
|
||||
app = app,
|
||||
isBlocked = state.blockedApps.contains(app.key),
|
||||
blockability = assessment.blockability,
|
||||
warnAllProfiles = assessment.warnAllProfiles,
|
||||
)
|
||||
}
|
||||
.filter { row -> matchesFilter(row, uiState.filter) }
|
||||
.filter { row -> matchesQuery(row, query) }
|
||||
}
|
||||
|
||||
private fun matchesFilter(row: AppRowUiState, filter: AppListFilter): Boolean = when (filter) {
|
||||
AppListFilter.ALL -> true
|
||||
AppListFilter.BLOCKED -> row.isBlocked
|
||||
AppListFilter.UNBLOCKED -> !row.isBlocked
|
||||
}
|
||||
|
||||
private fun matchesQuery(row: AppRowUiState, query: String): Boolean =
|
||||
query.isEmpty() || row.app.label.contains(query, ignoreCase = true)
|
||||
|
||||
companion object {
|
||||
fun factory(
|
||||
repository: AppRepository,
|
||||
firewallController: FirewallController,
|
||||
appChangeNotifier: AppChangeNotifier,
|
||||
scheduleStore: ScheduleStore,
|
||||
): ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
AppListViewModel(
|
||||
repository = repository,
|
||||
firewallController = firewallController,
|
||||
appChangeNotifier = appChangeNotifier,
|
||||
scheduleStore = scheduleStore,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.krrishg.kwall.ui.apps
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.krrishg.kwall.R
|
||||
import com.krrishg.kwall.firewall.FirewallAvailability
|
||||
import com.krrishg.kwall.firewall.FirewallDriftStatus
|
||||
|
||||
@Composable
|
||||
fun FirewallInfoDialog(
|
||||
firewall: FirewallUiState,
|
||||
onGrantPermission: () -> Unit,
|
||||
onRetryAvailability: () -> Unit,
|
||||
onInstallShizuku: () -> Unit,
|
||||
onOpenShizuku: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val availability = firewall.availability
|
||||
val ready = availability is FirewallAvailability.Ready
|
||||
val status = when (availability) {
|
||||
FirewallAvailability.Checking -> stringResource(R.string.firewall_state_checking)
|
||||
FirewallAvailability.UnsupportedAndroid -> stringResource(R.string.firewall_state_android)
|
||||
FirewallAvailability.ShizukuNotInstalled -> stringResource(R.string.firewall_state_shizuku_missing)
|
||||
FirewallAvailability.ShizukuNotRunning -> stringResource(R.string.firewall_state_shizuku_stopped)
|
||||
FirewallAvailability.PermissionDenied -> stringResource(R.string.firewall_state_permission)
|
||||
FirewallAvailability.CommandUnavailable -> stringResource(R.string.firewall_state_unsupported)
|
||||
is FirewallAvailability.Ready -> when {
|
||||
!firewall.enabled -> stringResource(R.string.firewall_state_off)
|
||||
firewall.blockedCount == 0 -> stringResource(R.string.firewall_state_no_apps)
|
||||
else -> pluralStringResource(
|
||||
R.plurals.firewall_blocked_apps,
|
||||
firewall.blockedCount,
|
||||
firewall.blockedCount,
|
||||
)
|
||||
}
|
||||
}
|
||||
val action = when (availability) {
|
||||
FirewallAvailability.ShizukuNotInstalled ->
|
||||
R.string.firewall_action_install_shizuku to onInstallShizuku
|
||||
FirewallAvailability.ShizukuNotRunning ->
|
||||
R.string.firewall_action_open_shizuku to onOpenShizuku
|
||||
FirewallAvailability.PermissionDenied ->
|
||||
R.string.firewall_action_grant to onGrantPermission
|
||||
FirewallAvailability.CommandUnavailable ->
|
||||
R.string.retry to onRetryAvailability
|
||||
else -> null
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.firewall_title)) },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(text = status, style = MaterialTheme.typography.bodyLarge)
|
||||
if (ready) {
|
||||
Text(
|
||||
text = stringResource(R.string.firewall_description),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (ready && firewall.drift.status != FirewallDriftStatus.IDLE) {
|
||||
Text(
|
||||
text = stringResource(firewall.drift.status.labelRes),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = when (firewall.drift.status) {
|
||||
FirewallDriftStatus.DRIFT_DETECTED -> MaterialTheme.colorScheme.error
|
||||
FirewallDriftStatus.REPAIRED -> MaterialTheme.colorScheme.primary
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
if (ready && (firewall.enabled || firewall.blockedCount > 0)) {
|
||||
Text(
|
||||
text = stringResource(R.string.firewall_drift_automatic),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (ready && firewall.enabled && firewall.blockedCount > 0) {
|
||||
Text(
|
||||
text = stringResource(R.string.firewall_reset_notice),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.firewall_uninstall_notice),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (ready && !firewall.enabled && firewall.blockedCount > 0) {
|
||||
Text(
|
||||
text = stringResource(R.string.firewall_selection_paused),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (!ready && firewall.enabled && firewall.blockedCount > 0) {
|
||||
Text(
|
||||
text = stringResource(R.string.firewall_rules_persist),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
firewall.errorMessage?.let { message ->
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.close))
|
||||
}
|
||||
},
|
||||
dismissButton = action?.let { (labelRes, onClick) ->
|
||||
{
|
||||
TextButton(onClick = onClick) {
|
||||
Text(stringResource(labelRes))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private val FirewallDriftStatus.labelRes: Int
|
||||
get() = when (this) {
|
||||
FirewallDriftStatus.IDLE, FirewallDriftStatus.CHECKING ->
|
||||
R.string.firewall_drift_checking
|
||||
FirewallDriftStatus.IN_SYNC -> R.string.firewall_drift_in_sync
|
||||
FirewallDriftStatus.REPAIRED -> R.string.firewall_drift_repaired
|
||||
FirewallDriftStatus.DRIFT_DETECTED -> R.string.firewall_drift_detected
|
||||
FirewallDriftStatus.UNAVAILABLE -> R.string.firewall_drift_unavailable
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.krrishg.kwall.ui.components
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.util.LruCache
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import com.krrishg.kwall.data.InstalledApp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun AppIcon(
|
||||
app: InstalledApp,
|
||||
modifier: Modifier = Modifier,
|
||||
iconSize: Dp = 44.dp,
|
||||
) {
|
||||
val iconSizePx = with(LocalDensity.current) { iconSize.roundToPx() }
|
||||
val cacheKey = remember(app.key, app.icon, iconSizePx) {
|
||||
AppIconCache.key(app, iconSizePx)
|
||||
}
|
||||
val bitmap by produceState(
|
||||
initialValue = AppIconCache.get(cacheKey),
|
||||
key1 = cacheKey,
|
||||
) {
|
||||
if (value == null) {
|
||||
value = AppIconCache.load(cacheKey, app.icon, iconSizePx)
|
||||
}
|
||||
}
|
||||
val iconModifier = modifier
|
||||
.size(iconSize)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
|
||||
val iconBitmap = bitmap
|
||||
if (iconBitmap != null) {
|
||||
Image(
|
||||
bitmap = iconBitmap,
|
||||
contentDescription = null,
|
||||
modifier = iconModifier,
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = iconModifier.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = app.label.trim().take(1).uppercase().ifEmpty { "?" },
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppIconPreloader(
|
||||
apps: List<InstalledApp>,
|
||||
iconSize: Dp = 44.dp,
|
||||
) {
|
||||
val iconSizePx = with(LocalDensity.current) { iconSize.roundToPx() }
|
||||
LaunchedEffect(apps, iconSizePx) {
|
||||
apps.forEach { app ->
|
||||
AppIconCache.load(AppIconCache.key(app, iconSizePx), app.icon, iconSizePx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private object AppIconCache {
|
||||
private const val MAX_BYTES = 24 * 1024 * 1024
|
||||
|
||||
private val decodeDispatcher = Dispatchers.Default.limitedParallelism(2)
|
||||
|
||||
private val cache = object : LruCache<String, ImageBitmap>(MAX_BYTES) {
|
||||
override fun sizeOf(key: String, value: ImageBitmap): Int = value.width * value.height * 4
|
||||
}
|
||||
|
||||
fun key(app: InstalledApp, sizePx: Int): String =
|
||||
"${app.key}:${System.identityHashCode(app.icon)}:$sizePx"
|
||||
|
||||
fun get(key: String): ImageBitmap? = cache.get(key)
|
||||
|
||||
suspend fun load(key: String, icon: Drawable?, sizePx: Int): ImageBitmap? {
|
||||
if (icon == null) return null
|
||||
cache.get(key)?.let { return it }
|
||||
return withContext(decodeDispatcher) {
|
||||
cache.get(key) ?: runCatching {
|
||||
icon.toBitmap(sizePx, sizePx).asImageBitmap()
|
||||
}.getOrNull()?.also { cache.put(key, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.krrishg.kwall.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.krrishg.kwall.R
|
||||
|
||||
@Composable
|
||||
fun SystemBadge(modifier: Modifier = Modifier) {
|
||||
Badge(
|
||||
text = stringResource(R.string.system_app_badge),
|
||||
modifier = modifier,
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WorkBadge(modifier: Modifier = Modifier) {
|
||||
Badge(
|
||||
text = stringResource(R.string.work_profile_badge),
|
||||
modifier = modifier,
|
||||
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Badge(
|
||||
text: String,
|
||||
containerColor: Color,
|
||||
contentColor: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
color = containerColor,
|
||||
contentColor = contentColor,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.krrishg.kwall.ui.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.krrishg.kwall.R
|
||||
import com.krrishg.kwall.firewall.AppBlockability
|
||||
|
||||
@Composable
|
||||
fun blockabilityReason(blockability: AppBlockability): String? = when (blockability) {
|
||||
AppBlockability.BLOCKABLE -> null
|
||||
AppBlockability.SYSTEM_UID -> stringResource(R.string.firewall_blocked_reason_system)
|
||||
AppBlockability.SHARED_UID -> stringResource(R.string.firewall_blocked_reason_shared)
|
||||
AppBlockability.NO_INTERNET -> stringResource(R.string.firewall_blocked_reason_no_internet)
|
||||
AppBlockability.FIREWALL_APP -> stringResource(R.string.firewall_blocked_reason_firewall)
|
||||
AppBlockability.WORK_PROFILE_ONLY -> stringResource(R.string.firewall_blocked_reason_work_only)
|
||||
AppBlockability.UNRESOLVED -> stringResource(R.string.firewall_blocked_reason_unresolved)
|
||||
}
|
||||
@@ -0,0 +1,652 @@
|
||||
package com.krrishg.kwall.ui.detail
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import android.text.format.Formatter
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.krrishg.kwall.R
|
||||
import com.krrishg.kwall.data.InstalledApp
|
||||
import com.krrishg.kwall.firewall.AppBlockability
|
||||
import com.krrishg.kwall.network.DataUsage
|
||||
import com.krrishg.kwall.network.SocketEntry
|
||||
import com.krrishg.kwall.network.SocketProtocol
|
||||
import com.krrishg.kwall.network.TcpState
|
||||
import com.krrishg.kwall.network.UsageRange
|
||||
import com.krrishg.kwall.ui.components.AppIcon
|
||||
import com.krrishg.kwall.ui.components.SystemBadge
|
||||
import com.krrishg.kwall.ui.components.WorkBadge
|
||||
import com.krrishg.kwall.ui.components.blockabilityReason
|
||||
import com.krrishg.kwall.ui.theme.KwallTheme
|
||||
import java.net.InetAddress
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
|
||||
@Composable
|
||||
fun AppDetailRoute(
|
||||
app: InstalledApp,
|
||||
blockability: AppBlockability,
|
||||
warnAllProfiles: Boolean,
|
||||
viewModel: AppDetailViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
|
||||
LaunchedEffect(app.key) { viewModel.load(app) }
|
||||
LaunchedEffect(app.key, lifecycle) {
|
||||
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
while (isActive) {
|
||||
viewModel.refreshConnections()
|
||||
delay(CONNECTION_POLL_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
LifecycleResumeEffect(app.key) {
|
||||
viewModel.refreshUsage()
|
||||
onPauseOrDispose { }
|
||||
}
|
||||
|
||||
AppDetailScreen(
|
||||
app = app,
|
||||
uiState = uiState,
|
||||
blockability = blockability,
|
||||
warnAllProfiles = warnAllProfiles,
|
||||
onBack = onBack,
|
||||
onBlockedChange = viewModel::setBlocked,
|
||||
onUsageRangeChange = viewModel::selectUsageRange,
|
||||
onGrantUsageAccess = { openUsageAccessSettings(context) },
|
||||
onRefresh = {
|
||||
viewModel.refreshUsage()
|
||||
viewModel.refreshConnectionsNow()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AppDetailScreen(
|
||||
app: InstalledApp,
|
||||
uiState: AppDetailUiState,
|
||||
blockability: AppBlockability,
|
||||
warnAllProfiles: Boolean,
|
||||
onBack: () -> Unit,
|
||||
onBlockedChange: (Boolean) -> Unit,
|
||||
onUsageRangeChange: (UsageRange) -> Unit,
|
||||
onGrantUsageAccess: () -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(app.label) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.detail_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = onRefresh) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Refresh,
|
||||
contentDescription = stringResource(R.string.detail_refresh),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { contentPadding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.padding(contentPadding)
|
||||
.fillMaxSize(),
|
||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
item { AppHeader(app) }
|
||||
item {
|
||||
BlockCard(
|
||||
isBlocked = uiState.isBlocked,
|
||||
isBlocking = uiState.isBlocking,
|
||||
firewallReady = uiState.firewallReady,
|
||||
blockability = blockability,
|
||||
warnAllProfiles = warnAllProfiles,
|
||||
onBlockedChange = onBlockedChange,
|
||||
)
|
||||
}
|
||||
item {
|
||||
UsageCard(
|
||||
usage = uiState.usage,
|
||||
range = uiState.usageRange,
|
||||
onRangeChange = onUsageRangeChange,
|
||||
onGrantUsageAccess = onGrantUsageAccess,
|
||||
)
|
||||
}
|
||||
item {
|
||||
ConnectionsHeader(connections = uiState.connections)
|
||||
}
|
||||
item { ConnectionsCard(connections = uiState.connections) }
|
||||
uiState.errorMessage?.let { message ->
|
||||
item {
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppHeader(
|
||||
app: InstalledApp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
AppIcon(app = app, iconSize = 56.dp)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = app.label,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (app.isWorkProfile) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
WorkBadge()
|
||||
}
|
||||
if (app.isSystem) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
SystemBadge()
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = app.packageName,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BlockCard(
|
||||
isBlocked: Boolean,
|
||||
isBlocking: Boolean,
|
||||
firewallReady: Boolean,
|
||||
blockability: AppBlockability,
|
||||
warnAllProfiles: Boolean,
|
||||
onBlockedChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val blockable = blockability == AppBlockability.BLOCKABLE
|
||||
val reason = blockabilityReason(blockability)
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(R.string.detail_block_title),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.detail_block_description),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Switch(
|
||||
checked = isBlocked,
|
||||
onCheckedChange = onBlockedChange,
|
||||
enabled = blockable && firewallReady && !isBlocking,
|
||||
)
|
||||
}
|
||||
if (reason != null) {
|
||||
Text(
|
||||
text = reason,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else if (!firewallReady) {
|
||||
Text(
|
||||
text = stringResource(R.string.detail_firewall_not_ready),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (warnAllProfiles) {
|
||||
Text(
|
||||
text = stringResource(R.string.firewall_blocked_warning_all_profiles),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UsageCard(
|
||||
usage: UsageUiState,
|
||||
range: UsageRange,
|
||||
onRangeChange: (UsageRange) -> Unit,
|
||||
onGrantUsageAccess: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(modifier = modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.detail_usage_title),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
UsageRange.entries.forEach { option ->
|
||||
FilterChip(
|
||||
selected = option == range,
|
||||
onClick = { onRangeChange(option) },
|
||||
label = { Text(stringResource(option.labelRes)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
when (usage) {
|
||||
UsageUiState.Loading -> LoadingRow(
|
||||
text = stringResource(R.string.detail_usage_loading),
|
||||
)
|
||||
is UsageUiState.Loaded -> Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(32.dp),
|
||||
) {
|
||||
UsageStat(
|
||||
label = stringResource(R.string.detail_usage_download),
|
||||
bytes = usage.usage.receivedBytes,
|
||||
)
|
||||
UsageStat(
|
||||
label = stringResource(R.string.detail_usage_upload),
|
||||
bytes = usage.usage.sentBytes,
|
||||
)
|
||||
}
|
||||
UsageUiState.PermissionRequired -> Column {
|
||||
Text(
|
||||
text = stringResource(R.string.detail_usage_permission),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
TextButton(onClick = onGrantUsageAccess) {
|
||||
Text(stringResource(R.string.detail_usage_permission_action))
|
||||
}
|
||||
}
|
||||
is UsageUiState.Unavailable -> Text(
|
||||
text = usage.reason,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UsageStat(
|
||||
label: String,
|
||||
bytes: Long,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = Formatter.formatFileSize(context, bytes),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectionsHeader(
|
||||
connections: ConnectionsUiState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.detail_connections_title),
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
if (connections is ConnectionsUiState.Loaded && connections.active.isNotEmpty()) {
|
||||
Text(
|
||||
text = pluralStringResource(
|
||||
R.plurals.detail_connections_count,
|
||||
connections.active.size,
|
||||
connections.active.size,
|
||||
),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectionsCard(
|
||||
connections: ConnectionsUiState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(modifier = modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
when (connections) {
|
||||
ConnectionsUiState.Loading -> LoadingRow(
|
||||
text = stringResource(R.string.detail_connections_loading),
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
is ConnectionsUiState.Unavailable -> Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.detail_connections_unavailable),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = connections.reason,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
is ConnectionsUiState.Loaded -> {
|
||||
val hasActive = connections.active.isNotEmpty()
|
||||
val hasListening = connections.listening.isNotEmpty()
|
||||
if (!hasActive && !hasListening) {
|
||||
Text(
|
||||
text = stringResource(R.string.detail_connections_empty),
|
||||
modifier = Modifier.padding(16.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
connections.active.forEachIndexed { index, entry ->
|
||||
ConnectionRow(entry)
|
||||
if (index < connections.active.lastIndex) {
|
||||
HorizontalDivider(modifier = Modifier.padding(start = 16.dp))
|
||||
}
|
||||
}
|
||||
if (hasListening) {
|
||||
if (hasActive) HorizontalDivider()
|
||||
Text(
|
||||
text = stringResource(R.string.detail_connections_listening_title),
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 12.dp),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
connections.listening.forEachIndexed { index, entry ->
|
||||
ConnectionRow(entry)
|
||||
if (index < connections.listening.lastIndex) {
|
||||
HorizontalDivider(modifier = Modifier.padding(start = 16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (connections is ConnectionsUiState.Loaded) {
|
||||
HorizontalDivider()
|
||||
Text(
|
||||
text = stringResource(R.string.detail_connections_note),
|
||||
modifier = Modifier.padding(16.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectionRow(
|
||||
entry: SocketEntry,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = connectionEndpoint(entry),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = connectionDetail(entry),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
ProtocolBadge(entry.protocol)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun connectionEndpoint(entry: SocketEntry): String {
|
||||
if (entry.isListening) {
|
||||
return stringResource(R.string.detail_connections_listening, entry.localPort)
|
||||
}
|
||||
val host = entry.remoteAddress?.hostAddress
|
||||
?: return stringResource(R.string.detail_connections_unknown)
|
||||
val display = if (host.contains(':')) "[$host]" else host
|
||||
return "$display:${entry.remotePort}"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun connectionDetail(entry: SocketEntry): String {
|
||||
if (entry.isListening) {
|
||||
val address = entry.localAddress?.hostAddress
|
||||
return if (address != null) {
|
||||
stringResource(R.string.detail_connections_local_address, address)
|
||||
} else {
|
||||
stringResource(R.string.detail_connections_all_interfaces)
|
||||
}
|
||||
}
|
||||
val state = entry.state
|
||||
if (state != null) {
|
||||
return state.name
|
||||
.replace('_', ' ')
|
||||
.lowercase()
|
||||
.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
return stringResource(R.string.detail_connections_active)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProtocolBadge(
|
||||
protocol: SocketProtocol,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
) {
|
||||
Text(
|
||||
text = protocol.label,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingRow(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val UsageRange.labelRes: Int
|
||||
get() = when (this) {
|
||||
UsageRange.TODAY -> R.string.detail_usage_today
|
||||
UsageRange.WEEK -> R.string.detail_usage_week
|
||||
UsageRange.MONTH -> R.string.detail_usage_month
|
||||
}
|
||||
|
||||
private fun openUsageAccessSettings(context: Context) {
|
||||
val intent = Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
if (runCatching { context.startActivity(intent) }.isSuccess) return
|
||||
|
||||
val fallback = Intent(Settings.ACTION_SETTINGS)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
runCatching { context.startActivity(fallback) }
|
||||
}
|
||||
|
||||
private const val CONNECTION_POLL_INTERVAL_MS = 3_000L
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun AppDetailScreenPreview() {
|
||||
val app = InstalledApp(
|
||||
packageName = "com.example.browser",
|
||||
label = "Browser",
|
||||
isSystem = false,
|
||||
icon = null,
|
||||
uid = 10124,
|
||||
userId = 0,
|
||||
isWorkProfile = false,
|
||||
hasInternetPermission = true,
|
||||
)
|
||||
val remote = InetAddress.getByAddress(byteArrayOf(1, 1, 1, 1))
|
||||
KwallTheme {
|
||||
AppDetailScreen(
|
||||
app = app,
|
||||
uiState = AppDetailUiState(
|
||||
app = app,
|
||||
isBlocked = true,
|
||||
firewallReady = true,
|
||||
connections = ConnectionsUiState.Loaded(
|
||||
active = listOf(
|
||||
SocketEntry(
|
||||
uid = app.uid,
|
||||
protocol = SocketProtocol.TCP,
|
||||
localAddress = null,
|
||||
localPort = 41234,
|
||||
remoteAddress = remote,
|
||||
remotePort = 443,
|
||||
state = TcpState.ESTABLISHED,
|
||||
),
|
||||
),
|
||||
listening = emptyList(),
|
||||
),
|
||||
usage = UsageUiState.Loaded(DataUsage(receivedBytes = 1_234_567, sentBytes = 234_567)),
|
||||
),
|
||||
blockability = AppBlockability.BLOCKABLE,
|
||||
warnAllProfiles = false,
|
||||
onBack = {},
|
||||
onBlockedChange = {},
|
||||
onUsageRangeChange = {},
|
||||
onGrantUsageAccess = {},
|
||||
onRefresh = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package com.krrishg.kwall.ui.detail
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.krrishg.kwall.data.InstalledApp
|
||||
import com.krrishg.kwall.firewall.FirewallAvailability
|
||||
import com.krrishg.kwall.firewall.FirewallController
|
||||
import com.krrishg.kwall.network.ConnectionInspector
|
||||
import com.krrishg.kwall.network.ConnectionSnapshot
|
||||
import com.krrishg.kwall.network.DataUsage
|
||||
import com.krrishg.kwall.network.NetworkUsageSource
|
||||
import com.krrishg.kwall.network.SocketEntry
|
||||
import com.krrishg.kwall.network.UsageRange
|
||||
import com.krrishg.kwall.network.UsageResult
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
sealed interface ConnectionsUiState {
|
||||
data object Loading : ConnectionsUiState
|
||||
|
||||
data class Loaded(
|
||||
val active: List<SocketEntry>,
|
||||
val listening: List<SocketEntry>,
|
||||
) : ConnectionsUiState
|
||||
|
||||
data class Unavailable(val reason: String) : ConnectionsUiState
|
||||
}
|
||||
|
||||
sealed interface UsageUiState {
|
||||
data object Loading : UsageUiState
|
||||
|
||||
data class Loaded(val usage: DataUsage) : UsageUiState
|
||||
|
||||
data object PermissionRequired : UsageUiState
|
||||
|
||||
data class Unavailable(val reason: String) : UsageUiState
|
||||
}
|
||||
|
||||
data class AppDetailUiState(
|
||||
val app: InstalledApp? = null,
|
||||
val isBlocked: Boolean = false,
|
||||
val isBlocking: Boolean = false,
|
||||
val firewallReady: Boolean = false,
|
||||
val connections: ConnectionsUiState = ConnectionsUiState.Loading,
|
||||
val usage: UsageUiState = UsageUiState.Loading,
|
||||
val usageRange: UsageRange = UsageRange.TODAY,
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
||||
class AppDetailViewModel(
|
||||
private val connectionInspector: ConnectionInspector,
|
||||
private val usageSource: NetworkUsageSource,
|
||||
private val firewallController: FirewallController,
|
||||
) : ViewModel() {
|
||||
|
||||
private val connectionLock = Mutex()
|
||||
private var usageJob: Job? = null
|
||||
|
||||
@Volatile
|
||||
private var app: InstalledApp? = null
|
||||
|
||||
private val _uiState = MutableStateFlow(AppDetailUiState())
|
||||
val uiState: StateFlow<AppDetailUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
combine(
|
||||
firewallController.state,
|
||||
firewallController.availability,
|
||||
) { state, availability ->
|
||||
state.blockedApps to (availability is FirewallAvailability.Ready)
|
||||
}.collect { (blockedApps, ready) ->
|
||||
_uiState.update { current ->
|
||||
val currentApp = current.app ?: return@update current
|
||||
current.copy(
|
||||
isBlocked = currentApp.key in blockedApps,
|
||||
firewallReady = ready,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
firewallController.error.collect { error ->
|
||||
if (error != null) {
|
||||
_uiState.update { it.copy(errorMessage = error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun load(app: InstalledApp) {
|
||||
if (this.app?.key == app.key) return
|
||||
this.app = app
|
||||
_uiState.value = AppDetailUiState(
|
||||
app = app,
|
||||
isBlocked = firewallController.state.value.blockedApps.contains(app.key),
|
||||
firewallReady = firewallController.availability.value is FirewallAvailability.Ready,
|
||||
)
|
||||
refreshUsage()
|
||||
}
|
||||
|
||||
suspend fun refreshConnections() {
|
||||
val currentApp = app ?: return
|
||||
connectionLock.withLock {
|
||||
if (app?.key != currentApp.key) return@withLock
|
||||
val snapshot = connectionInspector.snapshot()
|
||||
if (app?.key != currentApp.key) return@withLock
|
||||
_uiState.update { current ->
|
||||
if (current.app?.key != currentApp.key) return@update current
|
||||
current.copy(connections = snapshot.toUiState(currentApp.uid))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshConnectionsNow() {
|
||||
viewModelScope.launch { refreshConnections() }
|
||||
}
|
||||
|
||||
fun selectUsageRange(range: UsageRange) {
|
||||
if (_uiState.value.usageRange == range) return
|
||||
_uiState.update { it.copy(usageRange = range) }
|
||||
refreshUsage()
|
||||
}
|
||||
|
||||
fun refreshUsage() {
|
||||
val currentApp = app ?: return
|
||||
val range = _uiState.value.usageRange
|
||||
usageJob?.cancel()
|
||||
usageJob = viewModelScope.launch {
|
||||
_uiState.update { current ->
|
||||
if (current.app?.key == currentApp.key) {
|
||||
current.copy(usage = UsageUiState.Loading)
|
||||
} else {
|
||||
current
|
||||
}
|
||||
}
|
||||
val result = usageSource.usageFor(currentApp.uid, range)
|
||||
if (app?.key != currentApp.key) return@launch
|
||||
_uiState.update { current ->
|
||||
if (current.app?.key != currentApp.key) return@update current
|
||||
current.copy(usage = result.toUiState())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setBlocked(blocked: Boolean) {
|
||||
val currentApp = app ?: return
|
||||
if (_uiState.value.isBlocking) return
|
||||
_uiState.update { it.copy(isBlocking = true, errorMessage = null) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
firewallController.setBlocked(currentApp, blocked)
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (error: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(errorMessage = error.message ?: "Could not update the firewall")
|
||||
}
|
||||
} finally {
|
||||
_uiState.update { it.copy(isBlocking = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ConnectionSnapshot.toUiState(uid: Int): ConnectionsUiState = when (this) {
|
||||
is ConnectionSnapshot.Available -> {
|
||||
val matching = this.sockets.filter { it.uid == uid }
|
||||
ConnectionsUiState.Loaded(
|
||||
active = matching
|
||||
.filterNot { it.isListening }
|
||||
.sortedWith(
|
||||
compareBy<SocketEntry> { it.remoteAddress?.hostAddress.orEmpty() }
|
||||
.thenBy { it.remotePort }
|
||||
.thenBy { it.protocol.label },
|
||||
),
|
||||
listening = matching.filter { it.isListening }.sortedBy { it.localPort },
|
||||
)
|
||||
}
|
||||
is ConnectionSnapshot.Unavailable -> ConnectionsUiState.Unavailable(reason)
|
||||
}
|
||||
|
||||
private fun UsageResult.toUiState(): UsageUiState = when (this) {
|
||||
is UsageResult.Available -> UsageUiState.Loaded(usage)
|
||||
UsageResult.PermissionRequired -> UsageUiState.PermissionRequired
|
||||
is UsageResult.Unavailable -> UsageUiState.Unavailable(reason)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun factory(
|
||||
firewallController: FirewallController,
|
||||
connectionInspector: ConnectionInspector,
|
||||
usageSource: NetworkUsageSource,
|
||||
): ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
AppDetailViewModel(
|
||||
connectionInspector = connectionInspector,
|
||||
usageSource = usageSource,
|
||||
firewallController = firewallController,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package com.krrishg.kwall.ui.profiles
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.krrishg.kwall.R
|
||||
import com.krrishg.kwall.firewall.ProfileNameIssue
|
||||
import com.krrishg.kwall.firewall.RuleProfile
|
||||
import com.krrishg.kwall.firewall.validateProfileName
|
||||
|
||||
private sealed interface ProfileNameMode {
|
||||
data object Create : ProfileNameMode
|
||||
|
||||
data class Rename(val profile: RuleProfile) : ProfileNameMode
|
||||
|
||||
data class Duplicate(val profile: RuleProfile) : ProfileNameMode
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfilesDialog(
|
||||
profiles: List<RuleProfile>,
|
||||
activeProfileId: String,
|
||||
isBusy: Boolean,
|
||||
errorMessage: String?,
|
||||
onSwitch: (String) -> Unit,
|
||||
onCreate: (String) -> Unit,
|
||||
onRename: (String, String) -> Unit,
|
||||
onDuplicate: (String, String) -> Unit,
|
||||
onDelete: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var nameMode by remember { mutableStateOf<ProfileNameMode?>(null) }
|
||||
var deleteTarget by remember { mutableStateOf<RuleProfile?>(null) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.profiles_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (errorMessage != null) {
|
||||
Text(
|
||||
text = errorMessage,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = Modifier.heightIn(max = 360.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items(items = profiles, key = { it.id }) { profile ->
|
||||
ProfileRow(
|
||||
profile = profile,
|
||||
active = profile.id == activeProfileId,
|
||||
isBusy = isBusy,
|
||||
canDelete = profiles.size > 1,
|
||||
onSwitch = { onSwitch(profile.id) },
|
||||
onRename = { nameMode = ProfileNameMode.Rename(profile) },
|
||||
onDuplicate = { nameMode = ProfileNameMode.Duplicate(profile) },
|
||||
onDelete = { deleteTarget = profile },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.close))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = { nameMode = ProfileNameMode.Create },
|
||||
enabled = !isBusy,
|
||||
) {
|
||||
Text(stringResource(R.string.profile_new))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
val mode = nameMode
|
||||
if (mode != null) {
|
||||
ProfileNameDialog(
|
||||
mode = mode,
|
||||
profiles = profiles,
|
||||
isBusy = isBusy,
|
||||
onConfirm = { name ->
|
||||
when (mode) {
|
||||
ProfileNameMode.Create -> onCreate(name)
|
||||
is ProfileNameMode.Rename -> onRename(mode.profile.id, name)
|
||||
is ProfileNameMode.Duplicate -> onDuplicate(mode.profile.id, name)
|
||||
}
|
||||
nameMode = null
|
||||
},
|
||||
onDismiss = { nameMode = null },
|
||||
)
|
||||
}
|
||||
|
||||
val target = deleteTarget
|
||||
if (target != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { deleteTarget = null },
|
||||
title = { Text(stringResource(R.string.profile_delete_title)) },
|
||||
text = { Text(stringResource(R.string.profile_delete_message, target.name)) },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onDelete(target.id)
|
||||
deleteTarget = null
|
||||
},
|
||||
enabled = !isBusy,
|
||||
) {
|
||||
Text(stringResource(R.string.profile_delete))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { deleteTarget = null }) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileRow(
|
||||
profile: RuleProfile,
|
||||
active: Boolean,
|
||||
isBusy: Boolean,
|
||||
canDelete: Boolean,
|
||||
onSwitch: () -> Unit,
|
||||
onRename: () -> Unit,
|
||||
onDuplicate: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = !isBusy, onClick = onSwitch)
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(
|
||||
selected = active,
|
||||
onClick = onSwitch,
|
||||
enabled = !isBusy,
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = profile.name,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Text(
|
||||
text = pluralStringResource(
|
||||
R.plurals.profile_blocked_apps,
|
||||
profile.blockedApps.size,
|
||||
profile.blockedApps.size,
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Box {
|
||||
IconButton(
|
||||
onClick = { menuExpanded = true },
|
||||
enabled = !isBusy,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.MoreVert,
|
||||
contentDescription = stringResource(R.string.profile_actions),
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = menuExpanded,
|
||||
onDismissRequest = { menuExpanded = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.profile_rename)) },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
onRename()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.profile_duplicate)) },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
onDuplicate()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.profile_delete)) },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
onDelete()
|
||||
},
|
||||
enabled = canDelete,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileNameDialog(
|
||||
mode: ProfileNameMode,
|
||||
profiles: List<RuleProfile>,
|
||||
isBusy: Boolean,
|
||||
onConfirm: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val initialName = when (mode) {
|
||||
ProfileNameMode.Create -> ""
|
||||
is ProfileNameMode.Rename -> mode.profile.name
|
||||
is ProfileNameMode.Duplicate -> stringResource(
|
||||
R.string.profile_duplicate_name,
|
||||
mode.profile.name,
|
||||
)
|
||||
}
|
||||
val excludingId = (mode as? ProfileNameMode.Rename)?.profile?.id
|
||||
val title = when (mode) {
|
||||
ProfileNameMode.Create -> stringResource(R.string.profile_create_title)
|
||||
is ProfileNameMode.Rename -> stringResource(R.string.profile_rename_title)
|
||||
is ProfileNameMode.Duplicate -> stringResource(R.string.profile_duplicate_title)
|
||||
}
|
||||
var name by rememberSaveable(initialName) { mutableStateOf(initialName) }
|
||||
val issue = validateProfileName(name, profiles, excludingId)
|
||||
val issueMessage = when (issue) {
|
||||
ProfileNameIssue.BLANK -> stringResource(R.string.profile_name_blank)
|
||||
ProfileNameIssue.TOO_LONG -> stringResource(R.string.profile_name_too_long)
|
||||
ProfileNameIssue.DUPLICATE -> stringResource(R.string.profile_name_duplicate)
|
||||
null -> null
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text(stringResource(R.string.profile_name_label)) },
|
||||
singleLine = true,
|
||||
isError = issueMessage != null,
|
||||
supportingText = issueMessage?.let { message ->
|
||||
{ Text(message) }
|
||||
},
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { onConfirm(name.trim()) },
|
||||
enabled = issueMessage == null && !isBusy,
|
||||
) {
|
||||
Text(stringResource(R.string.save))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,869 @@
|
||||
package com.krrishg.kwall.ui.schedules
|
||||
|
||||
import android.app.DatePickerDialog
|
||||
import android.app.TimePickerDialog
|
||||
import android.text.format.DateFormat
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.krrishg.kwall.R
|
||||
import com.krrishg.kwall.firewall.RuleProfile
|
||||
import com.krrishg.kwall.scheduler.MAX_SCHEDULE_NAME_LENGTH
|
||||
import com.krrishg.kwall.scheduler.ProfileSchedule
|
||||
import com.krrishg.kwall.scheduler.ScheduleEngine
|
||||
import com.krrishg.kwall.scheduler.ScheduleIssue
|
||||
import com.krrishg.kwall.scheduler.ScheduleRecurrence
|
||||
import java.time.DayOfWeek
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.time.format.TextStyle
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
|
||||
@Composable
|
||||
fun SchedulesRoute(
|
||||
viewModel: SchedulesViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
var editorOpen by remember { mutableStateOf(false) }
|
||||
var editorTarget by remember { mutableStateOf<ProfileSchedule?>(null) }
|
||||
var deleteTarget by remember { mutableStateOf<ProfileSchedule?>(null) }
|
||||
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
|
||||
viewModel.refreshExactAlarmStatus()
|
||||
}
|
||||
BackHandler(onBack = onBack)
|
||||
|
||||
SchedulesScreen(
|
||||
uiState = uiState,
|
||||
onBack = onBack,
|
||||
onAdd = {
|
||||
viewModel.clearError()
|
||||
editorTarget = null
|
||||
editorOpen = true
|
||||
},
|
||||
onEdit = { schedule ->
|
||||
viewModel.clearError()
|
||||
editorTarget = schedule
|
||||
editorOpen = true
|
||||
},
|
||||
onDelete = { schedule -> deleteTarget = schedule },
|
||||
onToggle = viewModel::setEnabled,
|
||||
onRequestExactAlarmPermission = viewModel::requestExactAlarmPermission,
|
||||
)
|
||||
|
||||
if (editorOpen) {
|
||||
key(editorTarget?.id) {
|
||||
ScheduleEditorDialog(
|
||||
existing = editorTarget,
|
||||
profiles = uiState.profiles,
|
||||
activeProfileId = uiState.activeProfileId,
|
||||
existingSchedules = uiState.schedules.map { it.schedule },
|
||||
onSave = viewModel::save,
|
||||
onDismiss = { editorOpen = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val target = deleteTarget
|
||||
if (target != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { deleteTarget = null },
|
||||
title = { Text(stringResource(R.string.schedule_delete_title)) },
|
||||
text = { Text(stringResource(R.string.schedule_delete_message, target.name)) },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
viewModel.delete(target.id)
|
||||
deleteTarget = null
|
||||
},
|
||||
) {
|
||||
Text(stringResource(R.string.schedule_delete))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { deleteTarget = null }) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SchedulesScreen(
|
||||
uiState: SchedulesUiState,
|
||||
onBack: () -> Unit,
|
||||
onAdd: () -> Unit,
|
||||
onEdit: (ProfileSchedule) -> Unit,
|
||||
onDelete: (ProfileSchedule) -> Unit,
|
||||
onToggle: (String, Boolean) -> Unit,
|
||||
onRequestExactAlarmPermission: () -> Unit,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.schedules_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.back),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = onAdd, enabled = uiState.profiles.isNotEmpty()) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Add,
|
||||
contentDescription = stringResource(R.string.schedule_add),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { contentPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(contentPadding),
|
||||
) {
|
||||
if (!uiState.exactAlarmsAllowed) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.schedule_exact_alarm_unavailable),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = onRequestExactAlarmPermission) {
|
||||
Text(stringResource(R.string.schedule_allow_exact_alarms))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val issue = uiState.errorIssue
|
||||
val error = uiState.errorMessage
|
||||
if (issue != null || error != null) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = issue?.let { scheduleIssueText(it) } ?: error.orEmpty(),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
if (uiState.isLoading) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(stringResource(R.string.schedule_loading))
|
||||
}
|
||||
} else if (uiState.schedules.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.schedule_empty),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(items = uiState.schedules, key = { it.schedule.id }) { row ->
|
||||
ScheduleRow(
|
||||
row = row,
|
||||
enabled = !uiState.isSaving,
|
||||
onEdit = { onEdit(row.schedule) },
|
||||
onDelete = { onDelete(row.schedule) },
|
||||
onToggle = { onToggle(row.schedule.id, it) },
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScheduleRow(
|
||||
row: ScheduleRowUiState,
|
||||
enabled: Boolean,
|
||||
onEdit: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
) {
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onEdit)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = row.schedule.name,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = row.profileName ?: stringResource(R.string.schedule_missing_profile),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (row.profileName == null) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
Text(
|
||||
text = scheduleSummary(row.schedule),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
val nextRun = row.nextRun
|
||||
if (nextRun != null) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.schedule_next_run,
|
||||
formatInstant(nextRun),
|
||||
),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
row.issues.firstOrNull()?.let { issue ->
|
||||
Text(
|
||||
text = scheduleIssueText(issue),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
Switch(
|
||||
checked = row.schedule.enabled,
|
||||
onCheckedChange = onToggle,
|
||||
enabled = enabled,
|
||||
)
|
||||
Box {
|
||||
IconButton(onClick = { menuExpanded = true }, enabled = enabled) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.MoreVert,
|
||||
contentDescription = stringResource(R.string.profile_actions),
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = menuExpanded,
|
||||
onDismissRequest = { menuExpanded = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.schedule_edit)) },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
onEdit()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.schedule_delete)) },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
onDelete()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScheduleEditorDialog(
|
||||
existing: ProfileSchedule?,
|
||||
profiles: List<RuleProfile>,
|
||||
activeProfileId: String,
|
||||
existingSchedules: List<ProfileSchedule>,
|
||||
onSave: (ProfileSchedule) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val defaultProfileId = existing?.profileId
|
||||
?: profiles.firstOrNull { it.id == activeProfileId }?.id
|
||||
?: profiles.firstOrNull()?.id
|
||||
.orEmpty()
|
||||
val defaultFallbackId = existing?.fallbackProfileId
|
||||
?: profiles.firstOrNull { it.id == activeProfileId }?.id
|
||||
?: profiles.firstOrNull()?.id
|
||||
.orEmpty()
|
||||
val initialRecurrence = existing?.recurrence
|
||||
val initialType = when (initialRecurrence) {
|
||||
is ScheduleRecurrence.Once -> RecurrenceType.ONCE
|
||||
is ScheduleRecurrence.Daily -> RecurrenceType.DAILY
|
||||
is ScheduleRecurrence.Weekly -> RecurrenceType.WEEKLY
|
||||
is ScheduleRecurrence.Monthly -> RecurrenceType.MONTHLY
|
||||
null -> RecurrenceType.DAILY
|
||||
}
|
||||
val initialAnchor = initialRecurrence?.anchorDate ?: LocalDate.now()
|
||||
val initialStart = existing?.startTime ?: LocalTime.of(9, 0)
|
||||
val initialEnd = existing?.endTime ?: LocalTime.of(17, 0)
|
||||
val initialInterval = when (initialRecurrence) {
|
||||
is ScheduleRecurrence.Daily -> initialRecurrence.intervalDays
|
||||
is ScheduleRecurrence.Weekly -> initialRecurrence.intervalWeeks
|
||||
is ScheduleRecurrence.Monthly -> initialRecurrence.intervalMonths
|
||||
else -> 1
|
||||
}
|
||||
val initialDays = (initialRecurrence as? ScheduleRecurrence.Weekly)
|
||||
?.daysOfWeek
|
||||
?: setOf(initialAnchor.dayOfWeek)
|
||||
val initialDayOfMonth = (initialRecurrence as? ScheduleRecurrence.Monthly)
|
||||
?.dayOfMonth
|
||||
?: initialAnchor.dayOfMonth
|
||||
|
||||
var name by remember { mutableStateOf(existing?.name.orEmpty()) }
|
||||
var profileId by remember { mutableStateOf(defaultProfileId) }
|
||||
var fallbackProfileId by remember { mutableStateOf(defaultFallbackId) }
|
||||
var type by remember { mutableStateOf(initialType) }
|
||||
var anchorDate by remember { mutableStateOf(initialAnchor.toString()) }
|
||||
var startTime by remember { mutableStateOf(formatTimeInput(initialStart)) }
|
||||
var endTimeEnabled by remember { mutableStateOf(existing?.endTime != null) }
|
||||
var endTime by remember { mutableStateOf(formatTimeInput(initialEnd)) }
|
||||
var interval by remember { mutableStateOf(initialInterval.toString()) }
|
||||
var daysOfWeek by remember { mutableStateOf(initialDays) }
|
||||
var dayOfMonth by remember { mutableStateOf(initialDayOfMonth.toString()) }
|
||||
var scheduleEnabled by remember { mutableStateOf(existing?.enabled ?: true) }
|
||||
var errorIssue by remember { mutableStateOf<ScheduleIssue?>(null) }
|
||||
|
||||
fun showDatePicker(current: LocalDate, onSelected: (LocalDate) -> Unit) {
|
||||
DatePickerDialog(
|
||||
context,
|
||||
{ _, year, month, day -> onSelected(LocalDate.of(year, month + 1, day)) },
|
||||
current.year,
|
||||
current.monthValue - 1,
|
||||
current.dayOfMonth,
|
||||
).show()
|
||||
}
|
||||
|
||||
fun showTimePicker(current: LocalTime, onSelected: (LocalTime) -> Unit) {
|
||||
TimePickerDialog(
|
||||
context,
|
||||
{ _, hour, minute -> onSelected(LocalTime.of(hour, minute)) },
|
||||
current.hour,
|
||||
current.minute,
|
||||
DateFormat.is24HourFormat(context),
|
||||
).show()
|
||||
}
|
||||
|
||||
fun buildSchedule(): ProfileSchedule? {
|
||||
val date = runCatching { LocalDate.parse(anchorDate) }.getOrNull() ?: return null
|
||||
val start = runCatching { LocalTime.parse(startTime) }.getOrNull() ?: return null
|
||||
val end = if (endTimeEnabled) {
|
||||
runCatching { LocalTime.parse(endTime) }.getOrNull() ?: return null
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val parsedInterval = interval.toIntOrNull()
|
||||
val recurrence = when (type) {
|
||||
RecurrenceType.ONCE -> ScheduleRecurrence.Once(date)
|
||||
RecurrenceType.DAILY -> ScheduleRecurrence.Daily(
|
||||
anchorDate = date,
|
||||
intervalDays = parsedInterval ?: 0,
|
||||
)
|
||||
RecurrenceType.WEEKLY -> ScheduleRecurrence.Weekly(
|
||||
anchorDate = date,
|
||||
daysOfWeek = daysOfWeek,
|
||||
intervalWeeks = parsedInterval ?: 0,
|
||||
)
|
||||
RecurrenceType.MONTHLY -> ScheduleRecurrence.Monthly(
|
||||
anchorDate = date,
|
||||
dayOfMonth = dayOfMonth.toIntOrNull() ?: 0,
|
||||
intervalMonths = parsedInterval ?: 0,
|
||||
)
|
||||
}
|
||||
val base = existing ?: ProfileSchedule(
|
||||
id = UUID.randomUUID().toString(),
|
||||
name = name.trim(),
|
||||
profileId = profileId,
|
||||
fallbackProfileId = fallbackProfileId,
|
||||
recurrence = recurrence,
|
||||
startTime = start,
|
||||
createdAtMillis = System.currentTimeMillis(),
|
||||
)
|
||||
return base.copy(
|
||||
name = name.trim(),
|
||||
profileId = profileId,
|
||||
fallbackProfileId = fallbackProfileId,
|
||||
recurrence = recurrence,
|
||||
startTime = start,
|
||||
endTime = end,
|
||||
enabled = scheduleEnabled,
|
||||
)
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(
|
||||
stringResource(
|
||||
if (existing == null) R.string.schedule_create_title else R.string.schedule_edit_title,
|
||||
),
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
if (profiles.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.schedule_needs_profile),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = {
|
||||
if (it.length <= MAX_SCHEDULE_NAME_LENGTH) name = it
|
||||
},
|
||||
label = { Text(stringResource(R.string.schedule_name_label)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
ProfilePicker(
|
||||
label = stringResource(R.string.schedule_profile_label),
|
||||
profiles = profiles,
|
||||
selectedId = profileId,
|
||||
onSelected = { profileId = it },
|
||||
)
|
||||
ProfilePicker(
|
||||
label = stringResource(R.string.schedule_fallback_label),
|
||||
profiles = profiles,
|
||||
selectedId = fallbackProfileId,
|
||||
onSelected = { fallbackProfileId = it },
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.schedule_repeat_label),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
RecurrenceType.entries.forEach { option ->
|
||||
FilterChip(
|
||||
selected = type == option,
|
||||
onClick = { type = option },
|
||||
label = { Text(recurrenceTypeLabel(option)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
showDatePicker(
|
||||
runCatching { LocalDate.parse(anchorDate) }.getOrDefault(LocalDate.now()),
|
||||
) { anchorDate = it.toString() }
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.schedule_date_value,
|
||||
formatDate(
|
||||
runCatching { LocalDate.parse(anchorDate) }
|
||||
.getOrDefault(LocalDate.now()),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (type != RecurrenceType.ONCE) {
|
||||
OutlinedTextField(
|
||||
value = interval,
|
||||
onValueChange = { interval = it.filter(Char::isDigit) },
|
||||
label = {
|
||||
Text(
|
||||
stringResource(
|
||||
when (type) {
|
||||
RecurrenceType.DAILY -> R.string.schedule_interval_days
|
||||
RecurrenceType.WEEKLY -> R.string.schedule_interval_weeks
|
||||
else -> R.string.schedule_interval_months
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
when (type) {
|
||||
RecurrenceType.WEEKLY -> {
|
||||
Text(
|
||||
text = stringResource(R.string.schedule_weekdays_label),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
DayOfWeek.entries.forEach { day ->
|
||||
FilterChip(
|
||||
selected = day in daysOfWeek,
|
||||
onClick = {
|
||||
daysOfWeek = if (day in daysOfWeek) {
|
||||
daysOfWeek - day
|
||||
} else {
|
||||
daysOfWeek + day
|
||||
}
|
||||
},
|
||||
label = {
|
||||
Text(day.getDisplayName(TextStyle.SHORT, Locale.getDefault()))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
RecurrenceType.MONTHLY -> {
|
||||
OutlinedTextField(
|
||||
value = dayOfMonth,
|
||||
onValueChange = { dayOfMonth = it.filter(Char::isDigit) },
|
||||
label = { Text(stringResource(R.string.schedule_day_of_month)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
showTimePicker(
|
||||
runCatching { LocalTime.parse(startTime) }.getOrDefault(LocalTime.of(9, 0)),
|
||||
) { startTime = formatTimeInput(it) }
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.schedule_start_time_value,
|
||||
formatTime(
|
||||
runCatching { LocalTime.parse(startTime) }
|
||||
.getOrDefault(LocalTime.of(9, 0)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Switch(
|
||||
checked = endTimeEnabled,
|
||||
onCheckedChange = { endTimeEnabled = it },
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.schedule_end_time_label),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
if (!endTimeEnabled) {
|
||||
Text(
|
||||
text = stringResource(R.string.schedule_no_end_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (endTimeEnabled) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
showTimePicker(
|
||||
runCatching { LocalTime.parse(endTime) }
|
||||
.getOrDefault(LocalTime.of(17, 0)),
|
||||
) { endTime = formatTimeInput(it) }
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.schedule_end_time_value,
|
||||
formatTime(
|
||||
runCatching { LocalTime.parse(endTime) }
|
||||
.getOrDefault(LocalTime.of(17, 0)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Switch(
|
||||
checked = scheduleEnabled,
|
||||
onCheckedChange = { scheduleEnabled = it },
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.schedule_enabled_label),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
errorIssue?.let { issue ->
|
||||
Text(
|
||||
text = scheduleIssueText(issue),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
val schedule = buildSchedule() ?: return@TextButton
|
||||
val issues = ScheduleEngine.validateSchedule(
|
||||
candidate = schedule,
|
||||
existing = existingSchedules,
|
||||
availableProfileIds = profiles.map { it.id }.toSet(),
|
||||
now = Instant.now(),
|
||||
zone = ZoneId.systemDefault(),
|
||||
)
|
||||
if (issues.isNotEmpty()) {
|
||||
errorIssue = issues.first()
|
||||
return@TextButton
|
||||
}
|
||||
onSave(schedule)
|
||||
onDismiss()
|
||||
},
|
||||
enabled = profiles.isNotEmpty(),
|
||||
) {
|
||||
Text(stringResource(R.string.save))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfilePicker(
|
||||
label: String,
|
||||
profiles: List<RuleProfile>,
|
||||
selectedId: String,
|
||||
onSelected: (String) -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val selectedName = profiles.firstOrNull { it.id == selectedId }?.name
|
||||
?: stringResource(R.string.schedule_select_profile)
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedButton(
|
||||
onClick = { expanded = true },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(text = selectedName, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
modifier = Modifier.width(280.dp),
|
||||
) {
|
||||
profiles.forEach { profile ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(profile.name) },
|
||||
onClick = {
|
||||
onSelected(profile.id)
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class RecurrenceType {
|
||||
ONCE,
|
||||
DAILY,
|
||||
WEEKLY,
|
||||
MONTHLY,
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun recurrenceTypeLabel(type: RecurrenceType): String = stringResource(
|
||||
when (type) {
|
||||
RecurrenceType.ONCE -> R.string.schedule_repeat_once
|
||||
RecurrenceType.DAILY -> R.string.schedule_repeat_daily
|
||||
RecurrenceType.WEEKLY -> R.string.schedule_repeat_weekly
|
||||
RecurrenceType.MONTHLY -> R.string.schedule_repeat_monthly
|
||||
},
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun scheduleSummary(schedule: ProfileSchedule): String {
|
||||
val start = formatTime(schedule.startTime)
|
||||
val base = when (val recurrence = schedule.recurrence) {
|
||||
is ScheduleRecurrence.Once -> stringResource(
|
||||
R.string.schedule_summary_once,
|
||||
formatDate(recurrence.date),
|
||||
start,
|
||||
)
|
||||
is ScheduleRecurrence.Daily -> if (recurrence.intervalDays == 1) {
|
||||
stringResource(R.string.schedule_summary_daily, start)
|
||||
} else {
|
||||
pluralStringResource(
|
||||
R.plurals.schedule_summary_every_days,
|
||||
recurrence.intervalDays,
|
||||
recurrence.intervalDays,
|
||||
start,
|
||||
)
|
||||
}
|
||||
is ScheduleRecurrence.Weekly -> {
|
||||
val days = recurrence.daysOfWeek
|
||||
.sortedBy { it.value }
|
||||
.joinToString(", ") { it.getDisplayName(TextStyle.SHORT, Locale.getDefault()) }
|
||||
if (recurrence.intervalWeeks == 1) {
|
||||
stringResource(R.string.schedule_summary_weekly, days, start)
|
||||
} else {
|
||||
pluralStringResource(
|
||||
R.plurals.schedule_summary_every_weeks,
|
||||
recurrence.intervalWeeks,
|
||||
recurrence.intervalWeeks,
|
||||
days,
|
||||
start,
|
||||
)
|
||||
}
|
||||
}
|
||||
is ScheduleRecurrence.Monthly -> if (recurrence.intervalMonths == 1) {
|
||||
stringResource(R.string.schedule_summary_monthly, recurrence.dayOfMonth, start)
|
||||
} else {
|
||||
pluralStringResource(
|
||||
R.plurals.schedule_summary_every_months,
|
||||
recurrence.intervalMonths,
|
||||
recurrence.dayOfMonth,
|
||||
recurrence.intervalMonths,
|
||||
start,
|
||||
)
|
||||
}
|
||||
}
|
||||
val end = schedule.endTime
|
||||
return if (end == null) {
|
||||
base
|
||||
} else {
|
||||
stringResource(R.string.schedule_summary_until, base, formatTime(end))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun scheduleIssueText(issue: ScheduleIssue): String = stringResource(
|
||||
when (issue) {
|
||||
ScheduleIssue.BLANK_NAME -> R.string.schedule_error_blank_name
|
||||
ScheduleIssue.NAME_TOO_LONG -> R.string.schedule_error_name_too_long
|
||||
ScheduleIssue.PROFILE_MISSING -> R.string.schedule_error_profile_missing
|
||||
ScheduleIssue.FALLBACK_MISSING -> R.string.schedule_error_fallback_missing
|
||||
ScheduleIssue.INVALID_INTERVAL -> R.string.schedule_error_invalid_interval
|
||||
ScheduleIssue.NO_WEEKDAYS -> R.string.schedule_error_no_weekdays
|
||||
ScheduleIssue.INVALID_DAY_OF_MONTH -> R.string.schedule_error_invalid_day
|
||||
ScheduleIssue.PAST_ONE_TIME -> R.string.schedule_error_past_time
|
||||
ScheduleIssue.OVERLAP -> R.string.schedule_error_overlap
|
||||
},
|
||||
)
|
||||
|
||||
private fun formatDate(date: LocalDate): String =
|
||||
date.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(Locale.getDefault()))
|
||||
|
||||
private fun formatTime(time: LocalTime): String =
|
||||
time.format(DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(Locale.getDefault()))
|
||||
|
||||
private fun formatInstant(instant: Instant): String =
|
||||
instant.atZone(ZoneId.systemDefault()).format(
|
||||
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)
|
||||
.withLocale(Locale.getDefault()),
|
||||
)
|
||||
|
||||
private fun formatTimeInput(time: LocalTime): String =
|
||||
String.format(Locale.ROOT, "%02d:%02d", time.hour, time.minute)
|
||||
@@ -0,0 +1,229 @@
|
||||
package com.krrishg.kwall.ui.schedules
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.krrishg.kwall.firewall.FirewallController
|
||||
import com.krrishg.kwall.firewall.RuleProfile
|
||||
import com.krrishg.kwall.scheduler.ProfileSchedule
|
||||
import com.krrishg.kwall.scheduler.ScheduleAlarms
|
||||
import com.krrishg.kwall.scheduler.ScheduleEngine
|
||||
import com.krrishg.kwall.scheduler.ScheduleIssue
|
||||
import com.krrishg.kwall.scheduler.ScheduleStore
|
||||
import com.krrishg.kwall.scheduler.validateScheduleBasics
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class ScheduleRowUiState(
|
||||
val schedule: ProfileSchedule,
|
||||
val profileName: String?,
|
||||
val fallbackProfileName: String?,
|
||||
val nextRun: Instant?,
|
||||
val issues: List<ScheduleIssue>,
|
||||
)
|
||||
|
||||
data class SchedulesUiState(
|
||||
val schedules: List<ScheduleRowUiState> = emptyList(),
|
||||
val profiles: List<RuleProfile> = emptyList(),
|
||||
val activeProfileId: String = "",
|
||||
val exactAlarmsAllowed: Boolean = true,
|
||||
val isLoading: Boolean = true,
|
||||
val isSaving: Boolean = false,
|
||||
val errorIssue: ScheduleIssue? = null,
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
||||
class SchedulesViewModel(
|
||||
private val scheduleStore: ScheduleStore,
|
||||
private val firewallController: FirewallController,
|
||||
private val alarms: ScheduleAlarms,
|
||||
private val clock: Clock = Clock.systemDefaultZone(),
|
||||
private val zoneProvider: () -> ZoneId = { ZoneId.systemDefault() },
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(SchedulesUiState())
|
||||
val uiState: StateFlow<SchedulesUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
combine(
|
||||
scheduleStore.schedules,
|
||||
firewallController.state,
|
||||
) { schedules, firewall -> schedules to firewall }
|
||||
.collect { (schedules, firewall) ->
|
||||
val profileIds = firewall.profiles.map { it.id }.toSet()
|
||||
val now = clock.instant()
|
||||
val zone = zoneProvider()
|
||||
val rows = schedules
|
||||
.sortedWith(
|
||||
compareBy<ProfileSchedule> { it.createdAtMillis }.thenBy { it.id },
|
||||
)
|
||||
.map { schedule ->
|
||||
val issues = validateScheduleBasics(schedule, profileIds)
|
||||
ScheduleRowUiState(
|
||||
schedule = schedule,
|
||||
profileName = firewall.profiles
|
||||
.firstOrNull { it.id == schedule.profileId }
|
||||
?.name,
|
||||
fallbackProfileName = firewall.profiles
|
||||
.firstOrNull { it.id == schedule.fallbackProfileId }
|
||||
?.name,
|
||||
nextRun = if (schedule.enabled && issues.isEmpty()) {
|
||||
ScheduleEngine.nextOccurrence(schedule, now, zone)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
issues = issues,
|
||||
)
|
||||
}
|
||||
_uiState.update { current ->
|
||||
current.copy(
|
||||
schedules = rows,
|
||||
profiles = firewall.profiles,
|
||||
activeProfileId = firewall.activeProfileId,
|
||||
isLoading = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
refreshExactAlarmStatus()
|
||||
}
|
||||
|
||||
fun refreshExactAlarmStatus() {
|
||||
_uiState.update { it.copy(exactAlarmsAllowed = alarms.canScheduleExactAlarms()) }
|
||||
}
|
||||
|
||||
fun requestExactAlarmPermission() {
|
||||
alarms.requestExactAlarmPermission()
|
||||
refreshExactAlarmStatus()
|
||||
}
|
||||
|
||||
fun save(schedule: ProfileSchedule) {
|
||||
if (_uiState.value.isSaving) return
|
||||
val current = _uiState.value
|
||||
val existing = current.schedules.map { it.schedule }
|
||||
val profileIds = current.profiles.map { it.id }.toSet()
|
||||
val issues = ScheduleEngine.validateSchedule(
|
||||
candidate = schedule,
|
||||
existing = existing,
|
||||
availableProfileIds = profileIds,
|
||||
now = clock.instant(),
|
||||
zone = zoneProvider(),
|
||||
)
|
||||
if (issues.isNotEmpty()) {
|
||||
_uiState.update { it.copy(errorIssue = issues.first(), errorMessage = null) }
|
||||
return
|
||||
}
|
||||
val isNew = existing.none { it.id == schedule.id }
|
||||
_uiState.update { it.copy(isSaving = true, errorIssue = null, errorMessage = null) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val saved = if (isNew) {
|
||||
scheduleStore.create(schedule)
|
||||
} else {
|
||||
scheduleStore.update(schedule)
|
||||
}
|
||||
if (!saved) {
|
||||
_uiState.update { it.copy(errorMessage = "Could not save the schedule") }
|
||||
}
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (error: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(errorMessage = error.message ?: "Could not save the schedule")
|
||||
}
|
||||
} finally {
|
||||
_uiState.update { it.copy(isSaving = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun delete(id: String) {
|
||||
if (_uiState.value.isSaving) return
|
||||
_uiState.update { it.copy(isSaving = true, errorIssue = null, errorMessage = null) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val deleted = scheduleStore.delete(id)
|
||||
if (!deleted) {
|
||||
_uiState.update { it.copy(errorMessage = "Could not delete the schedule") }
|
||||
}
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (error: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(errorMessage = error.message ?: "Could not delete the schedule")
|
||||
}
|
||||
} finally {
|
||||
_uiState.update { it.copy(isSaving = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setEnabled(id: String, enabled: Boolean) {
|
||||
if (_uiState.value.isSaving) return
|
||||
val current = _uiState.value
|
||||
val target = current.schedules.firstOrNull { it.schedule.id == id }?.schedule ?: return
|
||||
if (enabled) {
|
||||
val issues = ScheduleEngine.validateSchedule(
|
||||
candidate = target.copy(enabled = true),
|
||||
existing = current.schedules
|
||||
.map { it.schedule }
|
||||
.filterNot { it.id == id },
|
||||
availableProfileIds = current.profiles.map { it.id }.toSet(),
|
||||
now = clock.instant(),
|
||||
zone = zoneProvider(),
|
||||
)
|
||||
if (issues.isNotEmpty()) {
|
||||
_uiState.update { it.copy(errorIssue = issues.first(), errorMessage = null) }
|
||||
return
|
||||
}
|
||||
}
|
||||
_uiState.update { it.copy(isSaving = true, errorIssue = null, errorMessage = null) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val updated = scheduleStore.setEnabled(id, enabled)
|
||||
if (!updated) {
|
||||
_uiState.update { it.copy(errorMessage = "Could not update the schedule") }
|
||||
}
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (error: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(errorMessage = error.message ?: "Could not update the schedule")
|
||||
}
|
||||
} finally {
|
||||
_uiState.update { it.copy(isSaving = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_uiState.update { it.copy(errorIssue = null, errorMessage = null) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun factory(
|
||||
scheduleStore: ScheduleStore,
|
||||
firewallController: FirewallController,
|
||||
alarms: ScheduleAlarms,
|
||||
): ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
SchedulesViewModel(
|
||||
scheduleStore = scheduleStore,
|
||||
firewallController = firewallController,
|
||||
alarms = alarms,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.krrishg.kwall.ui.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.krrishg.kwall.R
|
||||
|
||||
@Composable
|
||||
fun AppSettingsDialog(
|
||||
appLockEnabled: Boolean,
|
||||
autoBlockNewApps: Boolean,
|
||||
onAppLockEnabledChange: (Boolean) -> Unit,
|
||||
onAutoBlockNewAppsChange: (Boolean) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.settings_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SettingSwitchRow(
|
||||
title = stringResource(R.string.settings_app_lock),
|
||||
description = stringResource(R.string.settings_app_lock_description),
|
||||
checked = appLockEnabled,
|
||||
onCheckedChange = onAppLockEnabledChange,
|
||||
)
|
||||
SettingSwitchRow(
|
||||
title = stringResource(R.string.settings_auto_block_new_apps),
|
||||
description = stringResource(R.string.settings_auto_block_new_apps_description),
|
||||
checked = autoBlockNewApps,
|
||||
onCheckedChange = onAutoBlockNewAppsChange,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.close))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingSwitchRow(
|
||||
title: String,
|
||||
description: String,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.toggleable(
|
||||
value = checked,
|
||||
onValueChange = onCheckedChange,
|
||||
role = Role.Switch,
|
||||
)
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.krrishg.kwall.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
private val LightColors = lightColorScheme(
|
||||
primary = Color(0xFF1B5E4A),
|
||||
secondary = Color(0xFF4B6358),
|
||||
tertiary = Color(0xFF3E6374),
|
||||
)
|
||||
|
||||
private val DarkColors = darkColorScheme(
|
||||
primary = Color(0xFF86D6B5),
|
||||
secondary = Color(0xFFB2CCBE),
|
||||
tertiary = Color(0xFFA6CDE0),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun KwallTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val colorScheme = if (darkTheme) DarkColors else LightColors
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = (view.context as Activity).window
|
||||
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
|
||||
}
|
||||
}
|
||||
MaterialTheme(colorScheme = colorScheme, content = content)
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M19,19H5V5h7V3H5c-1.11,0 -1.99,0.9 -1.99,2L3,19c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2v-7h-2v7zM14,3v2h3.59l-9.83,9.83 1.41,1.41L19,6.41V10h2V3h-7z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M6,19h4V5H6v14zM14,5v14h4V5h-4z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M8,5v14l11,-7z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FF101013</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,213 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Kwall</string>
|
||||
<string name="lock_title">Unlock Kwall</string>
|
||||
<string name="lock_subtitle">Use your device PIN, pattern, password, or biometrics</string>
|
||||
<string name="lock_prompt_title">Kwall is locked</string>
|
||||
<string name="lock_prompt_description">Authenticate with your device screen lock to continue.</string>
|
||||
<string name="lock_unlock">Unlock</string>
|
||||
<string name="lock_no_device_lock_title">Set up a screen lock</string>
|
||||
<string name="lock_no_device_lock_description">Kwall is protected by your device screen lock. Set a PIN, pattern, password, or biometrics to open the app.</string>
|
||||
<string name="lock_open_settings">Open security settings</string>
|
||||
<string name="lock_disable_app_lock">Turn off app lock</string>
|
||||
<string name="apps_title">Apps</string>
|
||||
<string name="settings_title">Settings</string>
|
||||
<string name="settings_app_lock">Require device unlock</string>
|
||||
<string name="settings_app_lock_description">Ask for your device PIN, pattern, password, or biometrics when opening Kwall.</string>
|
||||
<string name="settings_auto_block_new_apps">Auto-block new apps</string>
|
||||
<string name="settings_auto_block_new_apps_description">Select newly installed user apps for blocking in the active profile. The firewall switch controls when those rules take effect.</string>
|
||||
<string name="settings_open">Settings</string>
|
||||
<string name="profiles_title">Profiles</string>
|
||||
<string name="profile_default_name">Default</string>
|
||||
<string name="profile_new">New profile</string>
|
||||
<string name="profile_actions">Profile actions</string>
|
||||
<string name="profile_rename">Rename</string>
|
||||
<string name="profile_duplicate">Duplicate</string>
|
||||
<string name="profile_delete">Delete</string>
|
||||
<string name="profile_switch_description">Switch profile. Active profile: %1$s</string>
|
||||
<string name="profile_create_title">New profile</string>
|
||||
<string name="profile_rename_title">Rename profile</string>
|
||||
<string name="profile_duplicate_title">Duplicate profile</string>
|
||||
<string name="profile_name_label">Profile name</string>
|
||||
<string name="profile_name_blank">Enter a profile name</string>
|
||||
<string name="profile_name_too_long">Profile name must be 40 characters or fewer</string>
|
||||
<string name="profile_name_duplicate">A profile with this name already exists</string>
|
||||
<string name="profile_duplicate_name">%1$s copy</string>
|
||||
<string name="profile_delete_title">Delete profile?</string>
|
||||
<string name="profile_delete_message">Delete \"%1$s\"? Its blocked app selections will be removed. If this is the active profile, another profile becomes active.</string>
|
||||
<string name="save">Save</string>
|
||||
<string name="cancel">Cancel</string>
|
||||
<string name="close">Close</string>
|
||||
<string name="system_app_badge">System</string>
|
||||
<string name="work_profile_badge">Work</string>
|
||||
<string name="refresh">Refresh</string>
|
||||
<string name="search_apps">Search apps</string>
|
||||
<string name="clear_search">Clear search</string>
|
||||
<string name="filter_all">All</string>
|
||||
<string name="filter_blocked">Blocked</string>
|
||||
<string name="filter_unblocked">Unblocked</string>
|
||||
<string name="show_system_apps">Show system apps</string>
|
||||
<string name="expand_details">Show details</string>
|
||||
<string name="collapse_details">Hide details</string>
|
||||
<string name="loading_apps">Loading apps</string>
|
||||
<string name="load_error">Could not load apps</string>
|
||||
<string name="retry">Retry</string>
|
||||
<string name="user_apps_empty_title">No user apps found</string>
|
||||
<string name="user_apps_empty_description">Turn on system apps to see everything installed on this device.</string>
|
||||
<string name="all_apps_empty_title">No installed apps found</string>
|
||||
<string name="all_apps_empty_description">No apps matched this filter.</string>
|
||||
<plurals name="apps_count">
|
||||
<item quantity="one">%1$d app</item>
|
||||
<item quantity="other">%1$d apps</item>
|
||||
</plurals>
|
||||
<plurals name="profile_blocked_apps">
|
||||
<item quantity="one">%1$d blocked app</item>
|
||||
<item quantity="other">%1$d blocked apps</item>
|
||||
</plurals>
|
||||
|
||||
<string name="firewall_title">Firewall</string>
|
||||
<string name="firewall_info_open">Firewall status and messages</string>
|
||||
<string name="firewall_description">Block internet for selected apps</string>
|
||||
<string name="firewall_state_checking">Checking Shizuku…</string>
|
||||
<string name="firewall_state_off">Firewall off</string>
|
||||
<string name="firewall_state_no_apps">No apps blocked</string>
|
||||
<string name="firewall_state_android">Requires Android 13 or newer</string>
|
||||
<string name="firewall_state_shizuku_missing">Shizuku is not installed</string>
|
||||
<string name="firewall_state_shizuku_stopped">Shizuku is not running</string>
|
||||
<string name="firewall_state_permission">Shizuku permission is required</string>
|
||||
<string name="firewall_state_unsupported">This ROM does not support per-app blocking</string>
|
||||
<string name="firewall_action_install_shizuku">Install Shizuku</string>
|
||||
<string name="firewall_action_open_shizuku">Open Shizuku</string>
|
||||
<string name="firewall_action_grant">Grant permission</string>
|
||||
<string name="firewall_reset_notice">Blocking stops after a reboot. Once Shizuku is running, Kwall reapplies your selections automatically.</string>
|
||||
<string name="firewall_uninstall_notice">Turn the firewall off before uninstalling Kwall. If Shizuku is not running during uninstall, existing rules stay active until the next reboot.</string>
|
||||
<string name="firewall_selection_paused">Selections are paused while the firewall is off.</string>
|
||||
<string name="firewall_rules_persist">Existing rules stay active until reboot or until the firewall is turned off.</string>
|
||||
<string name="firewall_drift_checking">Consistency check running…</string>
|
||||
<string name="firewall_drift_in_sync">Consistency check passed</string>
|
||||
<string name="firewall_drift_repaired">Consistency check fixed changed rules</string>
|
||||
<string name="firewall_drift_detected">Consistency check found changes that could not be fixed</string>
|
||||
<string name="firewall_drift_unavailable">Consistency check is unavailable on this device</string>
|
||||
<string name="firewall_drift_automatic">Kwall checks its rules automatically and repairs changes.</string>
|
||||
<string name="firewall_channel_name">Firewall status</string>
|
||||
<string name="firewall_auto_block_channel_name">Auto-blocked apps</string>
|
||||
<string name="firewall_auto_block_title">App blocked automatically</string>
|
||||
<string name="firewall_auto_block_text">%1$s was selected for blocking because it was just installed.</string>
|
||||
<string name="firewall_notification_on_title">Firewall on</string>
|
||||
<string name="firewall_notification_attention_title">Firewall needs attention</string>
|
||||
<string name="firewall_notification_no_selected_apps">No apps selected</string>
|
||||
<string name="firewall_notification_error">Could not update firewall: %1$s</string>
|
||||
<string name="firewall_notification_pause">Pause</string>
|
||||
<string name="firewall_notification_resume">Resume</string>
|
||||
<string name="firewall_notification_open">Open</string>
|
||||
<string name="firewall_paused_title">Firewall paused</string>
|
||||
<string name="firewall_blocked_reason_system">System UID apps cannot be blocked</string>
|
||||
<string name="firewall_blocked_reason_shared">Shares a UID with other apps</string>
|
||||
<string name="firewall_blocked_reason_no_internet">No internet permission</string>
|
||||
<string name="firewall_blocked_reason_firewall">Required by the firewall</string>
|
||||
<string name="firewall_blocked_reason_work_only">This app is only in the work profile; per-profile blocking needs the binder path</string>
|
||||
<string name="firewall_blocked_reason_unresolved">Could not resolve this app</string>
|
||||
<string name="firewall_blocked_warning_all_profiles">This device only supports package-wide blocking; other profiles will also be blocked</string>
|
||||
<plurals name="firewall_blocked_apps">
|
||||
<item quantity="one">%1$d app blocked</item>
|
||||
<item quantity="other">%1$d apps blocked</item>
|
||||
</plurals>
|
||||
<plurals name="firewall_notification_selected_apps">
|
||||
<item quantity="one">%1$d app selected</item>
|
||||
<item quantity="other">%1$d apps selected</item>
|
||||
</plurals>
|
||||
|
||||
<string name="detail_back">Back</string>
|
||||
<string name="detail_refresh">Refresh network information</string>
|
||||
<string name="detail_block_title">Internet access</string>
|
||||
<string name="detail_block_description">Block all internet traffic for this app</string>
|
||||
<string name="detail_firewall_not_ready">Start Shizuku and grant permission to change this setting.</string>
|
||||
<string name="detail_usage_title">Data usage</string>
|
||||
<string name="detail_usage_today">Today</string>
|
||||
<string name="detail_usage_week">7 days</string>
|
||||
<string name="detail_usage_month">30 days</string>
|
||||
<string name="detail_usage_download">Downloaded</string>
|
||||
<string name="detail_usage_upload">Uploaded</string>
|
||||
<string name="detail_usage_loading">Loading usage</string>
|
||||
<string name="detail_usage_permission">Allow usage access to see data used by this app.</string>
|
||||
<string name="detail_usage_permission_action">Grant usage access</string>
|
||||
<string name="detail_connections_title">Connections</string>
|
||||
<string name="detail_connections_loading">Loading connections</string>
|
||||
<string name="detail_connections_empty">No active connections.</string>
|
||||
<string name="detail_connections_unavailable">Live connections are unavailable on this device.</string>
|
||||
<string name="detail_connections_listening">Listening on port %1$d</string>
|
||||
<string name="detail_connections_listening_title">Listening ports</string>
|
||||
<string name="detail_connections_local_address">Local address %1$s</string>
|
||||
<string name="detail_connections_all_interfaces">All interfaces</string>
|
||||
<string name="detail_connections_unknown">Unknown endpoint</string>
|
||||
<string name="detail_connections_active">Active</string>
|
||||
<string name="detail_connections_note">Existing connections may stay open after blocking this app.</string>
|
||||
<plurals name="detail_connections_count">
|
||||
<item quantity="one">%1$d active</item>
|
||||
<item quantity="other">%1$d active</item>
|
||||
</plurals>
|
||||
|
||||
<string name="back">Back</string>
|
||||
<string name="schedules_open">Schedules</string>
|
||||
<string name="schedules_title">Schedules</string>
|
||||
<string name="schedule_add">Add schedule</string>
|
||||
<string name="schedule_edit">Edit</string>
|
||||
<string name="schedule_delete">Delete</string>
|
||||
<string name="schedule_delete_title">Delete schedule?</string>
|
||||
<string name="schedule_delete_message">Delete \"%1$s\"?</string>
|
||||
<string name="schedule_create_title">New schedule</string>
|
||||
<string name="schedule_edit_title">Edit schedule</string>
|
||||
<string name="schedule_name_label">Schedule name</string>
|
||||
<string name="schedule_repeat_label">Repeat</string>
|
||||
<string name="schedule_repeat_once">Once</string>
|
||||
<string name="schedule_repeat_daily">Daily</string>
|
||||
<string name="schedule_repeat_weekly">Weekly</string>
|
||||
<string name="schedule_repeat_monthly">Monthly</string>
|
||||
<string name="schedule_date_value">Start date: %1$s</string>
|
||||
<string name="schedule_no_end_hint">Without an end time, this profile stays active until another schedule starts.</string>
|
||||
<string name="schedule_interval_days">Every N days</string>
|
||||
<string name="schedule_interval_weeks">Every N weeks</string>
|
||||
<string name="schedule_interval_months">Every N months</string>
|
||||
<string name="schedule_weekdays_label">Days of week</string>
|
||||
<string name="schedule_day_of_month">Day of month</string>
|
||||
<string name="schedule_start_time_value">Starts at %1$s</string>
|
||||
<string name="schedule_end_time_label">End time</string>
|
||||
<string name="schedule_end_time_value">Ends at %1$s</string>
|
||||
<string name="schedule_enabled_label">Schedule enabled</string>
|
||||
<string name="schedule_profile_label">Profile</string>
|
||||
<string name="schedule_fallback_label">Revert to</string>
|
||||
<string name="schedule_select_profile">Select a profile</string>
|
||||
<string name="schedule_missing_profile">Profile missing</string>
|
||||
<string name="schedule_next_run">Next: %1$s</string>
|
||||
<string name="schedule_loading">Loading schedules</string>
|
||||
<string name="schedule_empty">No schedules yet. Add one to activate a profile automatically.</string>
|
||||
<string name="schedule_needs_profile">Create a profile before adding a schedule.</string>
|
||||
<string name="schedule_exact_alarm_unavailable">Exact alarms are disabled. Scheduled profiles may start a few minutes late.</string>
|
||||
<string name="schedule_allow_exact_alarms">Allow</string>
|
||||
<string name="schedule_error_blank_name">Enter a schedule name</string>
|
||||
<string name="schedule_error_name_too_long">Schedule name is too long</string>
|
||||
<string name="schedule_error_profile_missing">Select a profile that still exists</string>
|
||||
<string name="schedule_error_fallback_missing">Select a fallback profile that still exists</string>
|
||||
<string name="schedule_error_invalid_interval">The interval must be at least 1</string>
|
||||
<string name="schedule_error_no_weekdays">Select at least one weekday</string>
|
||||
<string name="schedule_error_invalid_day">Choose a day between 1 and 31</string>
|
||||
<string name="schedule_error_past_time">Choose a date and time in the future</string>
|
||||
<string name="schedule_error_overlap">This schedule overlaps another schedule</string>
|
||||
<string name="schedule_summary_once">Once on %1$s at %2$s</string>
|
||||
<string name="schedule_summary_daily">Every day at %1$s</string>
|
||||
<plurals name="schedule_summary_every_days">
|
||||
<item quantity="one">Every %1$d day at %2$s</item>
|
||||
<item quantity="other">Every %1$d days at %2$s</item>
|
||||
</plurals>
|
||||
<string name="schedule_summary_weekly">Every week on %1$s at %2$s</string>
|
||||
<plurals name="schedule_summary_every_weeks">
|
||||
<item quantity="one">Every %1$d week on %2$s at %3$s</item>
|
||||
<item quantity="other">Every %1$d weeks on %2$s at %3$s</item>
|
||||
</plurals>
|
||||
<string name="schedule_summary_monthly">Day %1$d of every month at %2$s</string>
|
||||
<plurals name="schedule_summary_every_months">
|
||||
<item quantity="one">Day %1$d of every %2$d month at %3$s</item>
|
||||
<item quantity="other">Day %1$d of every %2$d months at %3$s</item>
|
||||
</plurals>
|
||||
<string name="schedule_summary_until">%1$s until %2$s</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.Kwall" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<exclude domain="root" path="." />
|
||||
<exclude domain="file" path="." />
|
||||
<exclude domain="database" path="." />
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="external" path="." />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="root" path="." />
|
||||
<exclude domain="file" path="." />
|
||||
<exclude domain="database" path="." />
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="external" path="." />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.krrishg.kwall
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestDispatcher
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.rules.TestWatcher
|
||||
import org.junit.runner.Description
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class MainDispatcherRule(
|
||||
private val dispatcher: TestDispatcher = UnconfinedTestDispatcher(),
|
||||
) : TestWatcher() {
|
||||
|
||||
override fun starting(description: Description) {
|
||||
Dispatchers.setMain(dispatcher)
|
||||
}
|
||||
|
||||
override fun finished(description: Description) {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.krrishg.kwall.firewall
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class AppKeyTest {
|
||||
|
||||
@Test
|
||||
fun `serializes and parses a personal key`() {
|
||||
val key = AppKey(0, "com.example.app")
|
||||
|
||||
assertEquals("0:com.example.app", key.toString())
|
||||
assertEquals(key, AppKey.parse("0:com.example.app"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `serializes and parses a work profile key`() {
|
||||
val key = AppKey(10, "com.example.app")
|
||||
|
||||
assertEquals("10:com.example.app", key.toString())
|
||||
assertEquals(key, AppKey.parse("10:com.example.app"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects malformed keys`() {
|
||||
assertNull(AppKey.parse(""))
|
||||
assertNull(AppKey.parse("com.example.app"))
|
||||
assertNull(AppKey.parse("x:com.example.app"))
|
||||
assertNull(AppKey.parse("0:"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
package com.krrishg.kwall.firewall
|
||||
|
||||
import com.krrishg.kwall.data.AppRepository
|
||||
import com.krrishg.kwall.data.InstalledApp
|
||||
import com.krrishg.kwall.settings.AppSettingsStore
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AutoBlockNewAppsTest {
|
||||
|
||||
private val notes = app("com.example.notes", uid = 10123)
|
||||
|
||||
@Test
|
||||
fun `does nothing when the setting is disabled`() = runTest {
|
||||
val repository = FakeAppRepository(listOf(notes))
|
||||
val controller = FakeFirewallController()
|
||||
val useCase = useCase(repository, controller, enabled = false)
|
||||
|
||||
val blocked = useCase.onPackageAdded(notes.packageName, notes.userId)
|
||||
|
||||
assertNull(blocked)
|
||||
assertEquals(0, repository.loadCount)
|
||||
assertTrue(controller.added.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blocks a newly installed blockable app`() = runTest {
|
||||
val repository = FakeAppRepository(listOf(notes))
|
||||
val controller = FakeFirewallController()
|
||||
val useCase = useCase(repository, controller)
|
||||
|
||||
val blocked = useCase.onPackageAdded(notes.packageName, notes.userId)
|
||||
|
||||
assertEquals(notes, blocked)
|
||||
assertEquals(listOf(notes), controller.added)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not block an app that is already selected`() = runTest {
|
||||
val repository = FakeAppRepository(listOf(notes))
|
||||
val controller = FakeFirewallController(
|
||||
initialState = state(blocked = setOf(notes.key)),
|
||||
)
|
||||
val useCase = useCase(repository, controller)
|
||||
|
||||
val blocked = useCase.onPackageAdded(notes.packageName, notes.userId)
|
||||
|
||||
assertNull(blocked)
|
||||
assertTrue(controller.added.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skips system apps`() = runTest {
|
||||
val system = app("com.example.system", uid = 10124, isSystem = true)
|
||||
val repository = FakeAppRepository(listOf(system))
|
||||
val controller = FakeFirewallController()
|
||||
val useCase = useCase(repository, controller)
|
||||
|
||||
val blocked = useCase.onPackageAdded(system.packageName, system.userId)
|
||||
|
||||
assertNull(blocked)
|
||||
assertTrue(controller.added.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skips apps without internet permission`() = runTest {
|
||||
val offline = app("com.example.offline", uid = 10124, hasInternetPermission = false)
|
||||
val repository = FakeAppRepository(listOf(offline))
|
||||
val controller = FakeFirewallController()
|
||||
val useCase = useCase(repository, controller)
|
||||
|
||||
val blocked = useCase.onPackageAdded(offline.packageName, offline.userId)
|
||||
|
||||
assertNull(blocked)
|
||||
assertTrue(controller.added.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skips apps that share a uid`() = runTest {
|
||||
val shared = app("com.example.shared", uid = 10124)
|
||||
val companion = app("com.example.companion", uid = 10124)
|
||||
val repository = FakeAppRepository(listOf(shared, companion))
|
||||
val controller = FakeFirewallController()
|
||||
val useCase = useCase(repository, controller)
|
||||
|
||||
val blocked = useCase.onPackageAdded(shared.packageName, shared.userId)
|
||||
|
||||
assertNull(blocked)
|
||||
assertTrue(controller.added.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skips firewall packages`() = runTest {
|
||||
val shizuku = app(SHIZUKU_PACKAGE, uid = 10124)
|
||||
val repository = FakeAppRepository(listOf(shizuku))
|
||||
val controller = FakeFirewallController()
|
||||
val useCase = useCase(repository, controller)
|
||||
|
||||
val blocked = useCase.onPackageAdded(shizuku.packageName, shizuku.userId)
|
||||
|
||||
assertNull(blocked)
|
||||
assertTrue(controller.added.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skips work-only apps when shell is the only transport`() = runTest {
|
||||
val work = app(
|
||||
packageName = "com.example.work",
|
||||
uid = 10 * PER_USER_RANGE + 10123,
|
||||
userId = 10,
|
||||
isWorkProfile = true,
|
||||
)
|
||||
val repository = FakeAppRepository(listOf(work))
|
||||
val controller = FakeFirewallController(
|
||||
initialAvailability = FirewallAvailability.Ready(
|
||||
rulesTransport = RulesTransport.SHELL,
|
||||
chainTransport = ChainTransport.SHELL,
|
||||
),
|
||||
)
|
||||
val useCase = useCase(repository, controller)
|
||||
|
||||
val blocked = useCase.onPackageAdded(work.packageName, work.userId)
|
||||
|
||||
assertNull(blocked)
|
||||
assertTrue(controller.added.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skips work-only apps while the transport is unknown`() = runTest {
|
||||
val work = app(
|
||||
packageName = "com.example.work",
|
||||
uid = 10 * PER_USER_RANGE + 10123,
|
||||
userId = 10,
|
||||
isWorkProfile = true,
|
||||
)
|
||||
val repository = FakeAppRepository(listOf(work))
|
||||
val controller = FakeFirewallController(
|
||||
initialAvailability = FirewallAvailability.Checking,
|
||||
)
|
||||
val useCase = useCase(repository, controller)
|
||||
|
||||
val blocked = useCase.onPackageAdded(work.packageName, work.userId)
|
||||
|
||||
assertNull(blocked)
|
||||
assertTrue(controller.added.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blocks work profile apps when binder is available`() = runTest {
|
||||
val work = app(
|
||||
packageName = "com.example.work",
|
||||
uid = 10 * PER_USER_RANGE + 10123,
|
||||
userId = 10,
|
||||
isWorkProfile = true,
|
||||
)
|
||||
val repository = FakeAppRepository(listOf(work))
|
||||
val controller = FakeFirewallController()
|
||||
val useCase = useCase(repository, controller)
|
||||
|
||||
val blocked = useCase.onPackageAdded(work.packageName, work.userId)
|
||||
|
||||
assertEquals(work, blocked)
|
||||
assertEquals(listOf(work), controller.added)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `matches the installed app to the requested user`() = runTest {
|
||||
val personal = app("com.example.chat", uid = 10123)
|
||||
val work = app(
|
||||
packageName = "com.example.chat",
|
||||
uid = 10 * PER_USER_RANGE + 10123,
|
||||
userId = 10,
|
||||
isWorkProfile = true,
|
||||
)
|
||||
val repository = FakeAppRepository(listOf(personal, work))
|
||||
val controller = FakeFirewallController()
|
||||
val useCase = useCase(repository, controller)
|
||||
|
||||
val blocked = useCase.onPackageAdded(work.packageName, work.userId)
|
||||
|
||||
assertEquals(work, blocked)
|
||||
assertEquals(listOf(work), controller.added)
|
||||
}
|
||||
|
||||
private fun useCase(
|
||||
repository: AppRepository,
|
||||
controller: FirewallController,
|
||||
enabled: Boolean = true,
|
||||
) = AutoBlockNewApps(
|
||||
settingsStore = FakeSettingsStore(enabled),
|
||||
repository = repository,
|
||||
firewallController = controller,
|
||||
)
|
||||
|
||||
private fun app(
|
||||
packageName: String,
|
||||
uid: Int,
|
||||
userId: Int = 0,
|
||||
isSystem: Boolean = false,
|
||||
isWorkProfile: Boolean = false,
|
||||
hasInternetPermission: Boolean = true,
|
||||
) = InstalledApp(
|
||||
packageName = packageName,
|
||||
label = packageName,
|
||||
isSystem = isSystem,
|
||||
icon = null,
|
||||
uid = uid,
|
||||
userId = userId,
|
||||
isWorkProfile = isWorkProfile,
|
||||
hasInternetPermission = hasInternetPermission,
|
||||
)
|
||||
|
||||
private class FakeSettingsStore(
|
||||
autoBlockEnabled: Boolean,
|
||||
) : AppSettingsStore {
|
||||
|
||||
private val autoBlock = MutableStateFlow(autoBlockEnabled)
|
||||
override val appLockEnabled = MutableStateFlow(true)
|
||||
override val autoBlockNewApps = autoBlock
|
||||
|
||||
override suspend fun setAppLockEnabled(enabled: Boolean) {
|
||||
appLockEnabled.value = enabled
|
||||
}
|
||||
|
||||
override suspend fun setAutoBlockNewApps(enabled: Boolean) {
|
||||
autoBlock.value = enabled
|
||||
}
|
||||
}
|
||||
|
||||
private fun state(
|
||||
enabled: Boolean = false,
|
||||
blocked: Set<AppKey> = emptySet(),
|
||||
): FirewallState = FirewallState(
|
||||
enabled = enabled,
|
||||
profiles = listOf(defaultRuleProfile(blockedApps = blocked, createdAtMillis = 1L)),
|
||||
activeProfileId = DEFAULT_PROFILE_ID,
|
||||
)
|
||||
|
||||
private class FakeAppRepository(
|
||||
private val apps: List<InstalledApp>,
|
||||
) : AppRepository {
|
||||
|
||||
var loadCount = 0
|
||||
|
||||
override suspend fun loadInstalledApps(): List<InstalledApp> {
|
||||
loadCount++
|
||||
return apps
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeFirewallController(
|
||||
initialState: FirewallState = FirewallState(),
|
||||
initialAvailability: FirewallAvailability = FirewallAvailability.Ready(
|
||||
rulesTransport = RulesTransport.BINDER,
|
||||
chainTransport = ChainTransport.SHELL,
|
||||
),
|
||||
) : FirewallController {
|
||||
|
||||
private val stateFlow = MutableStateFlow(initialState)
|
||||
override val state: StateFlow<FirewallState> = stateFlow
|
||||
override val availability = MutableStateFlow(initialAvailability)
|
||||
override val error = MutableStateFlow<String?>(null)
|
||||
override val drift = MutableStateFlow(FirewallDriftState())
|
||||
|
||||
val added = mutableListOf<InstalledApp>()
|
||||
|
||||
override suspend fun setBlocked(app: InstalledApp, blocked: Boolean) = Unit
|
||||
|
||||
override suspend fun addBlockedSelection(app: InstalledApp): Boolean {
|
||||
if (app.key in stateFlow.value.blockedApps) return false
|
||||
added += app
|
||||
stateFlow.update { current ->
|
||||
current.updateActiveApps { apps -> apps + app.key }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun setEnabled(enabled: Boolean) = Unit
|
||||
|
||||
override suspend fun applyScheduledProfile(
|
||||
profileId: String,
|
||||
enableFirewall: Boolean?,
|
||||
): Boolean = true
|
||||
|
||||
override suspend fun restore() = Unit
|
||||
|
||||
override suspend fun checkForDrift() = Unit
|
||||
|
||||
override suspend fun refreshAvailability() = Unit
|
||||
|
||||
override fun requestPermission() = Unit
|
||||
|
||||
override suspend fun removeBlocked(key: AppKey) = Unit
|
||||
|
||||
override suspend fun removeBlockedForUser(userId: Int) = Unit
|
||||
|
||||
override suspend fun switchProfile(id: String) = Unit
|
||||
|
||||
override suspend fun createProfile(name: String) = false
|
||||
|
||||
override suspend fun duplicateProfile(sourceId: String, name: String) = false
|
||||
|
||||
override suspend fun renameProfile(id: String, name: String) = false
|
||||
|
||||
override suspend fun deleteProfile(id: String) = false
|
||||
|
||||
override suspend fun removeBlockedEverywhere(key: AppKey) = Unit
|
||||
|
||||
override suspend fun removeBlockedForUserEverywhere(userId: Int) = Unit
|
||||
|
||||
private fun FirewallState.updateActiveApps(
|
||||
transform: (Set<AppKey>) -> Set<AppKey>,
|
||||
): FirewallState {
|
||||
val activeId = activeProfile.id
|
||||
return copy(
|
||||
profiles = profiles.map { profile ->
|
||||
if (profile.id == activeId) {
|
||||
profile.copy(blockedApps = transform(profile.blockedApps))
|
||||
} else {
|
||||
profile
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PER_USER_RANGE = 100_000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package com.krrishg.kwall.firewall
|
||||
|
||||
import com.krrishg.kwall.data.InstalledApp
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class BlockabilityTest {
|
||||
|
||||
private val firewallPackages = setOf("moe.shizuku.privileged.api")
|
||||
|
||||
@Test
|
||||
fun `normal app is blockable`() {
|
||||
val app = app(uid = 10123)
|
||||
|
||||
assertEquals(AppBlockability.BLOCKABLE, blockabilityOf(app, mapOf(10123 to 1), firewallPackages))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `system uid is not blockable`() {
|
||||
val app = app(uid = 1000)
|
||||
|
||||
assertEquals(AppBlockability.SYSTEM_UID, blockabilityOf(app, mapOf(1000 to 1), firewallPackages))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `work profile app is blockable by its own uid`() {
|
||||
val app = app(uid = 10 * PER_USER_RANGE + 10123, userId = 10, isWorkProfile = true)
|
||||
|
||||
assertEquals(
|
||||
AppBlockability.BLOCKABLE,
|
||||
blockabilityOf(app, mapOf(10 * PER_USER_RANGE + 10123 to 1), firewallPackages),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `work profile system uid is not blockable`() {
|
||||
val app = app(uid = 10 * PER_USER_RANGE + 1000, userId = 10, isWorkProfile = true)
|
||||
|
||||
assertEquals(
|
||||
AppBlockability.SYSTEM_UID,
|
||||
blockabilityOf(app, mapOf(app.uid to 1), firewallPackages),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared uid is not blockable per app`() {
|
||||
val app = app(uid = 10124)
|
||||
|
||||
assertEquals(AppBlockability.SHARED_UID, blockabilityOf(app, mapOf(10124 to 2), firewallPackages))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `app without internet permission is not blockable`() {
|
||||
val app = app(uid = 10125, hasInternetPermission = false)
|
||||
|
||||
assertEquals(
|
||||
AppBlockability.NO_INTERNET,
|
||||
blockabilityOf(app, mapOf(10125 to 1), firewallPackages),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shizuku package is reserved`() {
|
||||
val app = app(packageName = "moe.shizuku.privileged.api", uid = 10126)
|
||||
|
||||
assertEquals(
|
||||
AppBlockability.FIREWALL_APP,
|
||||
blockabilityOf(app, mapOf(10126 to 1), firewallPackages),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unresolved uid is not blockable`() {
|
||||
val app = app(uid = 0)
|
||||
|
||||
assertEquals(AppBlockability.UNRESOLVED, blockabilityOf(app, emptyMap(), firewallPackages))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `work profile app is only blockable with binder`() {
|
||||
val app = app(uid = 10 * PER_USER_RANGE + 10123, userId = 10, isWorkProfile = true)
|
||||
|
||||
val shell = blockabilityFor(
|
||||
app = app,
|
||||
uidCounts = mapOf(app.uid to 1),
|
||||
packageCounts = mapOf(app.packageName to 1),
|
||||
rulesTransport = RulesTransport.SHELL,
|
||||
firewallPackages = firewallPackages,
|
||||
)
|
||||
val binder = blockabilityFor(
|
||||
app = app,
|
||||
uidCounts = mapOf(app.uid to 1),
|
||||
packageCounts = mapOf(app.packageName to 1),
|
||||
rulesTransport = RulesTransport.BINDER,
|
||||
firewallPackages = firewallPackages,
|
||||
)
|
||||
|
||||
assertEquals(AppBlockability.WORK_PROFILE_ONLY, shell.blockability)
|
||||
assertEquals(AppBlockability.BLOCKABLE, binder.blockability)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shell transport warns when the package exists in multiple profiles`() {
|
||||
val personal = app(packageName = "com.example.chat", uid = 10123)
|
||||
val work = app(
|
||||
packageName = "com.example.chat",
|
||||
uid = 10 * PER_USER_RANGE + 10123,
|
||||
userId = 10,
|
||||
isWorkProfile = true,
|
||||
)
|
||||
|
||||
val assessment = blockabilityFor(
|
||||
app = personal,
|
||||
uidCounts = mapOf(personal.uid to 1, work.uid to 1),
|
||||
packageCounts = mapOf(personal.packageName to 2),
|
||||
rulesTransport = RulesTransport.SHELL,
|
||||
firewallPackages = firewallPackages,
|
||||
)
|
||||
|
||||
assertEquals(AppBlockability.BLOCKABLE, assessment.blockability)
|
||||
assertEquals(true, assessment.warnAllProfiles)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `binder transport does not warn about other profiles`() {
|
||||
val app = app(uid = 10123)
|
||||
|
||||
val assessment = blockabilityFor(
|
||||
app = app,
|
||||
uidCounts = mapOf(app.uid to 1),
|
||||
packageCounts = mapOf(app.packageName to 2),
|
||||
rulesTransport = RulesTransport.BINDER,
|
||||
firewallPackages = firewallPackages,
|
||||
)
|
||||
|
||||
assertEquals(false, assessment.warnAllProfiles)
|
||||
}
|
||||
|
||||
private fun app(
|
||||
packageName: String = "com.example.app",
|
||||
uid: Int,
|
||||
userId: Int = 0,
|
||||
isWorkProfile: Boolean = false,
|
||||
hasInternetPermission: Boolean = true,
|
||||
) = InstalledApp(
|
||||
packageName = packageName,
|
||||
label = packageName,
|
||||
isSystem = false,
|
||||
icon = null,
|
||||
uid = uid,
|
||||
userId = userId,
|
||||
isWorkProfile = isWorkProfile,
|
||||
hasInternetPermission = hasInternetPermission,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val PER_USER_RANGE = 100_000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.krrishg.kwall.firewall
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class FirewallCommandsTest {
|
||||
|
||||
@Test
|
||||
fun `chain commands target chain 3`() {
|
||||
assertEquals(
|
||||
"cmd connectivity set-chain3-enabled true",
|
||||
FirewallCommands.chainEnabled(true),
|
||||
)
|
||||
assertEquals(
|
||||
"cmd connectivity set-chain3-enabled false",
|
||||
FirewallCommands.chainEnabled(false),
|
||||
)
|
||||
assertEquals("cmd connectivity get-chain3-enabled", FirewallCommands.chainState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `package commands use the expected syntax`() {
|
||||
assertEquals(
|
||||
"cmd connectivity set-package-networking-enabled false com.example.app",
|
||||
FirewallCommands.packageNetworking("com.example.app", false),
|
||||
)
|
||||
assertEquals(
|
||||
"cmd connectivity set-package-networking-enabled true com.example.app",
|
||||
FirewallCommands.packageNetworking("com.example.app", true),
|
||||
)
|
||||
assertEquals(
|
||||
"cmd connectivity get-package-networking-enabled com.example.app",
|
||||
FirewallCommands.packageState("com.example.app"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun `package commands reject invalid names`() {
|
||||
FirewallCommands.packageNetworking("com.example.app;rm -rf /", false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chain and rule constants match the platform`() {
|
||||
assertEquals(9, FirewallCommands.CHAIN)
|
||||
assertEquals(0, FirewallCommands.RULE_DEFAULT)
|
||||
assertEquals(2, FirewallCommands.RULE_DENY)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shell result recognises unsupported commands`() {
|
||||
val result = ShellResult(0, "Unknown command: set-chain3-enabled", "")
|
||||
|
||||
assertTrue(result.isUnknownCommand)
|
||||
assertTrue(result.isUnsupportedPlatform)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shell result recognises system app failures`() {
|
||||
val result = ShellResult(1, "", "Can't set package firewall rule for system app android")
|
||||
|
||||
assertTrue(result.isSystemAppError)
|
||||
assertFalse(result.isUnknownCommand)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shell result treats a missing uid owner entry as success`() {
|
||||
val result = ShellResult(1, "", "suidownermap does not have entry for uid")
|
||||
|
||||
assertTrue(result.isUidOwnerMapMissing)
|
||||
assertTrue(result.isEffectivelySuccessful)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.krrishg.kwall.firewall
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class FirewallDriftDetectorTest {
|
||||
|
||||
private val notes = BlockedApp(AppKey(0, "com.example.notes"), 10123)
|
||||
private val browser = BlockedApp(AppKey(0, "com.example.browser"), 10124)
|
||||
|
||||
@Test
|
||||
fun `matching runtime has no drift`() {
|
||||
val drift = detect(
|
||||
expectedEnabled = true,
|
||||
expectedBlocked = setOf(notes),
|
||||
candidates = setOf(notes),
|
||||
observation = FirewallObservation(true, mapOf(notes.key to true)),
|
||||
)
|
||||
|
||||
assertFalse(drift.detected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chain mismatch is drift`() {
|
||||
val drift = detect(
|
||||
expectedEnabled = true,
|
||||
expectedBlocked = setOf(notes),
|
||||
candidates = setOf(notes),
|
||||
observation = FirewallObservation(false, mapOf(notes.key to true)),
|
||||
)
|
||||
|
||||
assertTrue(drift.chainMismatch)
|
||||
assertTrue(drift.detected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing deny rules are drift`() {
|
||||
val drift = detect(
|
||||
expectedEnabled = true,
|
||||
expectedBlocked = setOf(notes, browser),
|
||||
candidates = setOf(notes, browser),
|
||||
observation = FirewallObservation(
|
||||
chainEnabled = true,
|
||||
appStates = mapOf(notes.key to true, browser.key to false),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(setOf(browser.key), drift.missingRules)
|
||||
assertTrue(drift.detected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stale deny rules are drift when the firewall is disabled`() {
|
||||
val drift = detect(
|
||||
expectedEnabled = false,
|
||||
expectedBlocked = emptySet(),
|
||||
candidates = setOf(notes),
|
||||
observation = FirewallObservation(false, mapOf(notes.key to true)),
|
||||
)
|
||||
|
||||
assertEquals(setOf(notes.key), drift.staleRules)
|
||||
assertTrue(drift.detected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown app state is not treated as a missing rule`() {
|
||||
val drift = detect(
|
||||
expectedEnabled = true,
|
||||
expectedBlocked = setOf(notes, browser),
|
||||
candidates = setOf(notes, browser),
|
||||
observation = FirewallObservation(
|
||||
chainEnabled = true,
|
||||
appStates = mapOf(notes.key to true),
|
||||
),
|
||||
)
|
||||
|
||||
assertFalse(drift.detected)
|
||||
}
|
||||
|
||||
private fun detect(
|
||||
expectedEnabled: Boolean,
|
||||
expectedBlocked: Set<BlockedApp>,
|
||||
candidates: Set<BlockedApp>,
|
||||
observation: FirewallObservation,
|
||||
): FirewallDrift = FirewallDriftDetector.detect(
|
||||
expectedEnabled = expectedEnabled,
|
||||
expectedBlocked = expectedBlocked,
|
||||
candidates = candidates,
|
||||
observation = observation,
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
package com.krrishg.kwall.firewall
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class FirewallObservationParserTest {
|
||||
|
||||
@Test
|
||||
fun `parses the chain enabled state`() {
|
||||
assertEquals(null, FirewallObservationParser.chainEnabled(ShellResult(0, "", "")))
|
||||
assertEquals(true, FirewallObservationParser.chainEnabled(ShellResult(0, "chain:enabled\n", "")))
|
||||
assertEquals(false, FirewallObservationParser.chainEnabled(ShellResult(0, "chain:disabled\n", "")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses the package deny state`() {
|
||||
val denied = FirewallObservationParser.packageDenied(
|
||||
ShellResult(0, "com.example.app:deny\n", ""),
|
||||
"com.example.app",
|
||||
)
|
||||
val allowed = FirewallObservationParser.packageDenied(
|
||||
ShellResult(0, "com.example.app:allow\n", ""),
|
||||
"com.example.app",
|
||||
)
|
||||
|
||||
assertEquals(true, denied)
|
||||
assertEquals(false, allowed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ignores unknown or failed observations`() {
|
||||
assertNull(
|
||||
FirewallObservationParser.chainEnabled(
|
||||
ShellResult(1, "chain:enabled", "Permission denied"),
|
||||
),
|
||||
)
|
||||
assertNull(FirewallObservationParser.chainEnabled(ShellResult(0, "wat", "")))
|
||||
assertNull(
|
||||
FirewallObservationParser.packageDenied(
|
||||
ShellResult(0, "other.app:deny", ""),
|
||||
"com.example.app",
|
||||
),
|
||||
)
|
||||
assertNull(
|
||||
FirewallObservationParser.packageDenied(
|
||||
ShellResult(0, "com.example.app:maybe", ""),
|
||||
"com.example.app",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.krrishg.kwall.firewall
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class RuleProfileTest {
|
||||
|
||||
private val default = defaultRuleProfile(createdAtMillis = 1L)
|
||||
private val work = RuleProfile(
|
||||
id = "work",
|
||||
name = "Work",
|
||||
createdAtMillis = 2L,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `blank names are rejected`() {
|
||||
assertEquals(
|
||||
ProfileNameIssue.BLANK,
|
||||
validateProfileName(" ", listOf(default, work)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `names longer than the limit are rejected`() {
|
||||
assertEquals(
|
||||
ProfileNameIssue.TOO_LONG,
|
||||
validateProfileName("a".repeat(MAX_PROFILE_NAME_LENGTH + 1), listOf(default)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate names are rejected case-insensitively`() {
|
||||
assertEquals(
|
||||
ProfileNameIssue.DUPLICATE,
|
||||
validateProfileName("work", listOf(default, work)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a profile may keep its own name when renamed`() {
|
||||
assertNull(
|
||||
validateProfileName("Work", listOf(default, work), excludingId = work.id),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a valid unique name is accepted`() {
|
||||
assertNull(validateProfileName(" Travel ", listOf(default, work)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.krrishg.kwall.network
|
||||
|
||||
import com.krrishg.kwall.firewall.ShellExecutor
|
||||
import com.krrishg.kwall.firewall.ShellResult
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ProcNetConnectionInspectorTest {
|
||||
|
||||
@Test
|
||||
fun `parses every protocol section`() = runTest {
|
||||
val inspector = ProcNetConnectionInspector(
|
||||
FakeShell(
|
||||
ShellResult(
|
||||
exitCode = 0,
|
||||
stdout = """
|
||||
=== tcp ===
|
||||
sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
|
||||
0: 0100007F:1F90 5DB8D822:01BB 01 00000000:00000000 00:00000000 00000000 10123 0 12345 1 0000000000000000 100 0 0 10 0
|
||||
=== tcp6 ===
|
||||
sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
|
||||
1: 00000000000000000000000001000000:0050 00000000000000000000000001000000:01BB 01 00000000:00000000 00:00000000 00000000 10124 0 23456 1 0000000000000000 100 0 0 10 0
|
||||
=== udp ===
|
||||
sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
|
||||
0: 0100007F:14E9 00000000:0000 07 00000000:00000000 00:00000000 00000000 10126 0 45678 1 0000000000000000 100 0 0 10 0
|
||||
=== udp6 ===
|
||||
""".trimIndent(),
|
||||
stderr = "",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val snapshot = inspector.snapshot()
|
||||
|
||||
assertTrue(snapshot is ConnectionSnapshot.Available)
|
||||
val sockets = (snapshot as ConnectionSnapshot.Available).sockets
|
||||
assertEquals(3, sockets.size)
|
||||
assertEquals(
|
||||
listOf(SocketProtocol.TCP, SocketProtocol.TCP6, SocketProtocol.UDP),
|
||||
sockets.map { it.protocol },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skips unreadable sections but keeps the rest`() = runTest {
|
||||
val inspector = ProcNetConnectionInspector(
|
||||
FakeShell(
|
||||
ShellResult(
|
||||
exitCode = 1,
|
||||
stdout = """
|
||||
=== tcp ===
|
||||
cat: /proc/net/tcp: Permission denied
|
||||
=== tcp6 ===
|
||||
sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
|
||||
0: 00000000:0050 00000000:0000 0A 00000000:00000000 00:00000000 00000000 10125 0 34567 1 0000000000000000 100 0 0 10 0
|
||||
=== udp ===
|
||||
cat: /proc/net/udp: Permission denied
|
||||
=== udp6 ===
|
||||
cat: /proc/net/udp6: Permission denied
|
||||
""".trimIndent(),
|
||||
stderr = "",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val snapshot = inspector.snapshot()
|
||||
|
||||
assertTrue(snapshot is ConnectionSnapshot.Available)
|
||||
val sockets = (snapshot as ConnectionSnapshot.Available).sockets
|
||||
assertEquals(1, sockets.size)
|
||||
assertEquals(SocketProtocol.TCP6, sockets.single().protocol)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reports unavailable when every section is unreadable`() = runTest {
|
||||
val inspector = ProcNetConnectionInspector(
|
||||
FakeShell(
|
||||
ShellResult(
|
||||
exitCode = 1,
|
||||
stdout = """
|
||||
=== tcp ===
|
||||
cat: /proc/net/tcp: Permission denied
|
||||
=== tcp6 ===
|
||||
cat: /proc/net/tcp6: Permission denied
|
||||
=== udp ===
|
||||
cat: /proc/net/udp: Permission denied
|
||||
=== udp6 ===
|
||||
cat: /proc/net/udp6: Permission denied
|
||||
""".trimIndent(),
|
||||
stderr = "",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val snapshot = inspector.snapshot()
|
||||
|
||||
assertTrue(snapshot is ConnectionSnapshot.Unavailable)
|
||||
assertTrue(
|
||||
(snapshot as ConnectionSnapshot.Unavailable).reason.contains("does not allow"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reports unavailable when shizuku is not running`() = runTest {
|
||||
val inspector = ProcNetConnectionInspector(
|
||||
FakeShell(ShellResult(-1, "", "Shizuku is not running")),
|
||||
)
|
||||
|
||||
val snapshot = inspector.snapshot()
|
||||
|
||||
assertTrue(snapshot is ConnectionSnapshot.Unavailable)
|
||||
assertEquals("Shizuku is not running", (snapshot as ConnectionSnapshot.Unavailable).reason)
|
||||
}
|
||||
|
||||
private class FakeShell(private val result: ShellResult) : ShellExecutor {
|
||||
override suspend fun exec(command: String): ShellResult = result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.krrishg.kwall.network
|
||||
|
||||
import java.net.InetAddress
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ProcNetParserTest {
|
||||
|
||||
@Test
|
||||
fun `parses established ipv4 tcp connections`() {
|
||||
val output = """
|
||||
sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
|
||||
0: 0100007F:1F90 5DB8D822:01BB 01 00000000:00000000 00:00000000 00000000 10123 0 12345 1 0000000000000000 100 0 0 10 0
|
||||
""".trimIndent()
|
||||
|
||||
val sockets = ProcNetParser.parse(SocketProtocol.TCP, output)
|
||||
|
||||
assertEquals(1, sockets.size)
|
||||
val socket = sockets.single()
|
||||
assertEquals(10123, socket.uid)
|
||||
assertEquals(SocketProtocol.TCP, socket.protocol)
|
||||
assertEquals(InetAddress.getByName("127.0.0.1"), socket.localAddress)
|
||||
assertEquals(8080, socket.localPort)
|
||||
assertEquals(InetAddress.getByName("34.216.184.93"), socket.remoteAddress)
|
||||
assertEquals(443, socket.remotePort)
|
||||
assertEquals(TcpState.ESTABLISHED, socket.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses ipv6 addresses`() {
|
||||
val output = """
|
||||
sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
|
||||
1: 00000000000000000000000001000000:0050 00000000000000000000000001000000:01BB 01 00000000:00000000 00:00000000 00000000 10124 0 23456 1 0000000000000000 100 0 0 10 0
|
||||
""".trimIndent()
|
||||
|
||||
val sockets = ProcNetParser.parse(SocketProtocol.TCP6, output)
|
||||
|
||||
assertEquals(1, sockets.size)
|
||||
val socket = sockets.single()
|
||||
assertEquals(InetAddress.getByName("::1"), socket.localAddress)
|
||||
assertEquals(80, socket.localPort)
|
||||
assertEquals(InetAddress.getByName("::1"), socket.remoteAddress)
|
||||
assertEquals(443, socket.remotePort)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeps listening sockets with no remote endpoint`() {
|
||||
val output = """
|
||||
sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
|
||||
0: 00000000:0050 00000000:0000 0A 00000000:00000000 00:00000000 00000000 10125 0 34567 1 0000000000000000 100 0 0 10 0
|
||||
""".trimIndent()
|
||||
|
||||
val socket = ProcNetParser.parse(SocketProtocol.TCP, output).single()
|
||||
|
||||
assertNull(socket.localAddress)
|
||||
assertEquals(80, socket.localPort)
|
||||
assertNull(socket.remoteAddress)
|
||||
assertEquals(0, socket.remotePort)
|
||||
assertEquals(TcpState.LISTEN, socket.state)
|
||||
assertTrue(socket.isListening)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `udp sockets have no tcp state`() {
|
||||
val output = """
|
||||
sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
|
||||
0: 0100007F:14E9 00000000:0000 07 00000000:00000000 00:00000000 00000000 10126 0 45678 1 0000000000000000 100 0 0 10 0
|
||||
""".trimIndent()
|
||||
|
||||
val socket = ProcNetParser.parse(SocketProtocol.UDP, output).single()
|
||||
|
||||
assertEquals(5353, socket.localPort)
|
||||
assertNull(socket.state)
|
||||
assertNull(socket.remoteAddress)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ignores malformed lines`() {
|
||||
val output = """
|
||||
sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
|
||||
broken line
|
||||
0: 0100007F:1F90 5DB8D822:01BB 01 00000000:00000000 00:00000000 00000000 0 0 12345 1 0000000000000000 100 0 0 10 0
|
||||
1: not-an-address:0050 00000000:0000 0A 00000000:00000000 00:00000000 00000000 10123 0 34567 1 0000000000000000 100 0 0 10 0
|
||||
""".trimIndent()
|
||||
|
||||
val sockets = ProcNetParser.parse(SocketProtocol.TCP, output)
|
||||
|
||||
assertEquals(1, sockets.size)
|
||||
assertEquals(0, sockets.single().uid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tcp state is decoded from its hex code`() {
|
||||
assertEquals(TcpState.ESTABLISHED, TcpState.fromHex("01"))
|
||||
assertEquals(TcpState.LISTEN, TcpState.fromHex("0a"))
|
||||
assertEquals(TcpState.TIME_WAIT, TcpState.fromHex("06"))
|
||||
assertEquals(TcpState.UNKNOWN, TcpState.fromHex("FF"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.krrishg.kwall.network
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class UsageWindowTest {
|
||||
|
||||
@Test
|
||||
fun `ranges start at midnight in utc`() {
|
||||
val now = Instant.parse("2026-10-25T15:30:00Z").toEpochMilli()
|
||||
val zone = ZoneId.of("UTC")
|
||||
|
||||
assertEquals(
|
||||
Instant.parse("2026-10-25T00:00:00Z").toEpochMilli(),
|
||||
UsageWindow.start(UsageRange.TODAY, now, zone),
|
||||
)
|
||||
assertEquals(
|
||||
Instant.parse("2026-10-19T00:00:00Z").toEpochMilli(),
|
||||
UsageWindow.start(UsageRange.WEEK, now, zone),
|
||||
)
|
||||
assertEquals(
|
||||
Instant.parse("2026-09-26T00:00:00Z").toEpochMilli(),
|
||||
UsageWindow.start(UsageRange.MONTH, now, zone),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `today follows the local time zone`() {
|
||||
val now = Instant.parse("2026-10-24T20:00:00Z").toEpochMilli()
|
||||
val zone = ZoneId.of("Asia/Kolkata")
|
||||
|
||||
assertEquals(
|
||||
Instant.parse("2026-10-24T18:30:00Z").toEpochMilli(),
|
||||
UsageWindow.start(UsageRange.TODAY, now, zone),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.krrishg.kwall.notifications
|
||||
|
||||
import com.krrishg.kwall.firewall.ChainTransport
|
||||
import com.krrishg.kwall.firewall.FirewallAvailability
|
||||
import com.krrishg.kwall.firewall.RulesTransport
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class FirewallNotificationContentTest {
|
||||
|
||||
private val ready = FirewallAvailability.Ready(
|
||||
rulesTransport = RulesTransport.BINDER,
|
||||
chainTransport = ChainTransport.SHELL,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun enabledFirewallShowsBlockedCountAndPauseAction() {
|
||||
val content = firewallNotificationContent(
|
||||
enabled = true,
|
||||
appCount = 3,
|
||||
availability = ready,
|
||||
error = null,
|
||||
appLockEnabled = false,
|
||||
)
|
||||
|
||||
assertEquals(FirewallNotificationTitle.ON, content.title)
|
||||
assertEquals(FirewallNotificationText.BlockedApps(3), content.text)
|
||||
assertEquals(FirewallNotificationAction.PAUSE, content.action)
|
||||
assertTrue(content.directToggle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun enabledFirewallWithoutAppsShowsNoBlockedApps() {
|
||||
val content = firewallNotificationContent(
|
||||
enabled = true,
|
||||
appCount = 0,
|
||||
availability = ready,
|
||||
error = null,
|
||||
appLockEnabled = false,
|
||||
)
|
||||
|
||||
assertEquals(FirewallNotificationText.NoBlockedApps, content.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun disabledFirewallShowsSelectedCountAndResumeAction() {
|
||||
val content = firewallNotificationContent(
|
||||
enabled = false,
|
||||
appCount = 2,
|
||||
availability = ready,
|
||||
error = null,
|
||||
appLockEnabled = false,
|
||||
)
|
||||
|
||||
assertEquals(FirewallNotificationTitle.OFF, content.title)
|
||||
assertEquals(FirewallNotificationText.SelectedApps(2), content.text)
|
||||
assertEquals(FirewallNotificationAction.RESUME, content.action)
|
||||
assertTrue(content.directToggle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun disabledFirewallWithoutAppsShowsNoSelectedApps() {
|
||||
val content = firewallNotificationContent(
|
||||
enabled = false,
|
||||
appCount = 0,
|
||||
availability = ready,
|
||||
error = null,
|
||||
appLockEnabled = false,
|
||||
)
|
||||
|
||||
assertEquals(FirewallNotificationText.NoSelectedApps, content.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appLockDisablesDirectToggle() {
|
||||
val content = firewallNotificationContent(
|
||||
enabled = true,
|
||||
appCount = 1,
|
||||
availability = ready,
|
||||
error = null,
|
||||
appLockEnabled = true,
|
||||
)
|
||||
|
||||
assertEquals(FirewallNotificationAction.PAUSE, content.action)
|
||||
assertFalse(content.directToggle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unavailableShizukuShowsOpenAction() {
|
||||
val content = firewallNotificationContent(
|
||||
enabled = true,
|
||||
appCount = 1,
|
||||
availability = FirewallAvailability.ShizukuNotRunning,
|
||||
error = null,
|
||||
appLockEnabled = false,
|
||||
)
|
||||
|
||||
assertEquals(FirewallNotificationTitle.PAUSED, content.title)
|
||||
assertEquals(
|
||||
FirewallNotificationText.Availability(FirewallAvailability.ShizukuNotRunning),
|
||||
content.text,
|
||||
)
|
||||
assertEquals(FirewallNotificationAction.OPEN, content.action)
|
||||
assertFalse(content.directToggle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkingShizukuShowsOpenAction() {
|
||||
val content = firewallNotificationContent(
|
||||
enabled = false,
|
||||
appCount = 0,
|
||||
availability = FirewallAvailability.Checking,
|
||||
error = null,
|
||||
appLockEnabled = false,
|
||||
)
|
||||
|
||||
assertEquals(FirewallNotificationTitle.FIREWALL, content.title)
|
||||
assertEquals(
|
||||
FirewallNotificationText.Availability(FirewallAvailability.Checking),
|
||||
content.text,
|
||||
)
|
||||
assertEquals(FirewallNotificationAction.OPEN, content.action)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun errorShowsAttentionAndOpenAction() {
|
||||
val content = firewallNotificationContent(
|
||||
enabled = true,
|
||||
appCount = 1,
|
||||
availability = ready,
|
||||
error = "Could not change firewall chain",
|
||||
appLockEnabled = false,
|
||||
)
|
||||
|
||||
assertEquals(FirewallNotificationTitle.ATTENTION, content.title)
|
||||
assertEquals(
|
||||
FirewallNotificationText.Error("Could not change firewall chain"),
|
||||
content.text,
|
||||
)
|
||||
assertEquals(FirewallNotificationAction.OPEN, content.action)
|
||||
assertFalse(content.directToggle)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package com.krrishg.kwall.scheduler
|
||||
|
||||
import com.krrishg.kwall.data.InstalledApp
|
||||
import com.krrishg.kwall.firewall.AppKey
|
||||
import com.krrishg.kwall.firewall.DEFAULT_PROFILE_ID
|
||||
import com.krrishg.kwall.firewall.FirewallAvailability
|
||||
import com.krrishg.kwall.firewall.FirewallController
|
||||
import com.krrishg.kwall.firewall.FirewallDriftState
|
||||
import com.krrishg.kwall.firewall.FirewallState
|
||||
import com.krrishg.kwall.firewall.RuleProfile
|
||||
import com.krrishg.kwall.firewall.defaultRuleProfile
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ProfileSchedulerTest {
|
||||
|
||||
private val work = RuleProfile(
|
||||
id = "work",
|
||||
name = "Work",
|
||||
createdAtMillis = 2L,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `entering a window activates the profile and enables the firewall`() = runTest {
|
||||
val now = Instant.parse("2026-01-05T12:00:00Z")
|
||||
val schedule = schedule(end = LocalTime.of(17, 0))
|
||||
val store = FakeScheduleStore(listOf(schedule))
|
||||
val controller = FakeController()
|
||||
val alarms = FakeAlarms()
|
||||
val scheduler = ProfileScheduler(
|
||||
store,
|
||||
controller,
|
||||
alarms,
|
||||
MutableClock(now),
|
||||
{ ZoneOffset.UTC },
|
||||
)
|
||||
|
||||
scheduler.onSystemEvent()
|
||||
|
||||
assertEquals(listOf(Activation(work.id, true)), controller.activations)
|
||||
assertTrue(controller.state.value.enabled)
|
||||
assertEquals(work.id, controller.state.value.activeProfileId)
|
||||
assertEquals(Instant.parse("2026-01-05T17:00:00Z"), alarms.next)
|
||||
assertEquals(schedule.id, store.runtime.activeScheduleId)
|
||||
assertEquals(false, store.runtime.firewallWasEnabledBefore)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `leaving a window restores the fallback profile and firewall state`() = runTest {
|
||||
val clock = MutableClock(Instant.parse("2026-01-05T12:00:00Z"))
|
||||
val schedule = schedule(end = LocalTime.of(17, 0))
|
||||
val store = FakeScheduleStore(listOf(schedule))
|
||||
val controller = FakeController()
|
||||
val alarms = FakeAlarms()
|
||||
val scheduler = ProfileScheduler(
|
||||
store,
|
||||
controller,
|
||||
alarms,
|
||||
clock,
|
||||
{ ZoneOffset.UTC },
|
||||
)
|
||||
|
||||
scheduler.onSystemEvent()
|
||||
clock.current = Instant.parse("2026-01-05T18:00:00Z")
|
||||
scheduler.onSystemEvent()
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
Activation(work.id, true),
|
||||
Activation(DEFAULT_PROFILE_ID, false),
|
||||
),
|
||||
controller.activations,
|
||||
)
|
||||
assertFalse(controller.state.value.enabled)
|
||||
assertEquals(DEFAULT_PROFILE_ID, controller.state.value.activeProfileId)
|
||||
assertNull(store.runtime.activeScheduleId)
|
||||
assertNull(alarms.next)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an enabled firewall remains enabled after a window ends`() = runTest {
|
||||
val clock = MutableClock(Instant.parse("2026-01-05T12:00:00Z"))
|
||||
val schedule = schedule(end = LocalTime.of(17, 0))
|
||||
val store = FakeScheduleStore(listOf(schedule))
|
||||
val controller = FakeController(enabled = true)
|
||||
val scheduler = ProfileScheduler(
|
||||
store,
|
||||
controller,
|
||||
FakeAlarms(),
|
||||
clock,
|
||||
{ ZoneOffset.UTC },
|
||||
)
|
||||
|
||||
scheduler.onSystemEvent()
|
||||
clock.current = Instant.parse("2026-01-05T18:00:00Z")
|
||||
scheduler.onSystemEvent()
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
Activation(work.id, true),
|
||||
Activation(DEFAULT_PROFILE_ID, null),
|
||||
),
|
||||
controller.activations,
|
||||
)
|
||||
assertTrue(controller.state.value.enabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the next activation is scheduled when no window is active`() = runTest {
|
||||
val schedule = schedule(
|
||||
date = LocalDate.of(2026, 1, 6),
|
||||
end = null,
|
||||
)
|
||||
val store = FakeScheduleStore(listOf(schedule))
|
||||
val alarms = FakeAlarms()
|
||||
val scheduler = ProfileScheduler(
|
||||
store,
|
||||
FakeController(),
|
||||
alarms,
|
||||
MutableClock(Instant.parse("2026-01-05T12:00:00Z")),
|
||||
{ ZoneOffset.UTC },
|
||||
)
|
||||
|
||||
scheduler.onSystemEvent()
|
||||
|
||||
assertEquals(Instant.parse("2026-01-06T09:00:00Z"), alarms.next)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an open ended schedule is not deactivated automatically`() = runTest {
|
||||
val clock = MutableClock(Instant.parse("2026-01-05T12:00:00Z"))
|
||||
val schedule = schedule(end = null)
|
||||
val store = FakeScheduleStore(listOf(schedule))
|
||||
val controller = FakeController()
|
||||
val scheduler = ProfileScheduler(
|
||||
store,
|
||||
controller,
|
||||
FakeAlarms(),
|
||||
clock,
|
||||
{ ZoneOffset.UTC },
|
||||
)
|
||||
|
||||
scheduler.onSystemEvent()
|
||||
clock.current = Instant.parse("2026-01-10T12:00:00Z")
|
||||
scheduler.onSystemEvent()
|
||||
|
||||
assertEquals(listOf(Activation(work.id, true)), controller.activations)
|
||||
assertEquals(work.id, controller.state.value.activeProfileId)
|
||||
assertEquals(schedule.id, store.runtime.activeScheduleId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `schedules with missing profiles are ignored`() = runTest {
|
||||
val store = FakeScheduleStore(
|
||||
listOf(schedule(profileId = "missing", end = LocalTime.of(17, 0))),
|
||||
)
|
||||
val controller = FakeController()
|
||||
val scheduler = ProfileScheduler(
|
||||
store,
|
||||
controller,
|
||||
FakeAlarms(),
|
||||
MutableClock(Instant.parse("2026-01-05T12:00:00Z")),
|
||||
{ ZoneOffset.UTC },
|
||||
)
|
||||
|
||||
scheduler.onSystemEvent()
|
||||
|
||||
assertTrue(controller.activations.isEmpty())
|
||||
assertNull(store.runtime.activeScheduleId)
|
||||
}
|
||||
|
||||
private fun schedule(
|
||||
date: LocalDate = LocalDate.of(2026, 1, 5),
|
||||
profileId: String = work.id,
|
||||
end: LocalTime?,
|
||||
): ProfileSchedule = ProfileSchedule(
|
||||
id = "schedule",
|
||||
name = "Work hours",
|
||||
profileId = profileId,
|
||||
fallbackProfileId = DEFAULT_PROFILE_ID,
|
||||
recurrence = ScheduleRecurrence.Once(date),
|
||||
startTime = LocalTime.of(9, 0),
|
||||
endTime = end,
|
||||
createdAtMillis = 3L,
|
||||
)
|
||||
|
||||
private data class Activation(val profileId: String, val enableFirewall: Boolean?)
|
||||
|
||||
private class MutableClock(var current: Instant) : Clock() {
|
||||
override fun getZone(): ZoneId = ZoneOffset.UTC
|
||||
|
||||
override fun withZone(zone: ZoneId): Clock = this
|
||||
|
||||
override fun instant(): Instant = current
|
||||
}
|
||||
|
||||
private class FakeAlarms : ScheduleAlarms {
|
||||
var next: Instant? = null
|
||||
var cancelled = false
|
||||
|
||||
override fun canScheduleExactAlarms(): Boolean = true
|
||||
|
||||
override fun scheduleNext(at: Instant) {
|
||||
next = at
|
||||
}
|
||||
|
||||
override fun cancel() {
|
||||
cancelled = true
|
||||
next = null
|
||||
}
|
||||
|
||||
override fun requestExactAlarmPermission() = Unit
|
||||
}
|
||||
|
||||
private class FakeScheduleStore(
|
||||
initial: List<ProfileSchedule>,
|
||||
) : ScheduleStore {
|
||||
private val scheduleFlow = MutableStateFlow(initial)
|
||||
override val schedules: StateFlow<List<ProfileSchedule>> = scheduleFlow
|
||||
var runtime = SchedulerRuntime()
|
||||
|
||||
override suspend fun create(schedule: ProfileSchedule): Boolean {
|
||||
scheduleFlow.update { it + schedule }
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun update(schedule: ProfileSchedule): Boolean {
|
||||
scheduleFlow.update { schedules ->
|
||||
schedules.map { if (it.id == schedule.id) schedule else it }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun delete(id: String): Boolean {
|
||||
scheduleFlow.update { schedules -> schedules.filterNot { it.id == id } }
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun setEnabled(id: String, enabled: Boolean): Boolean {
|
||||
scheduleFlow.update { schedules ->
|
||||
schedules.map { if (it.id == id) it.copy(enabled = enabled) else it }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun readRuntime(): SchedulerRuntime = runtime
|
||||
|
||||
override suspend fun writeRuntime(runtime: SchedulerRuntime) {
|
||||
this.runtime = runtime
|
||||
}
|
||||
|
||||
override suspend fun clearRuntime() {
|
||||
runtime = SchedulerRuntime()
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeController(
|
||||
enabled: Boolean = false,
|
||||
) : FirewallController {
|
||||
private val stateFlow = MutableStateFlow(
|
||||
FirewallState(
|
||||
enabled = enabled,
|
||||
profiles = listOf(
|
||||
defaultRuleProfile(createdAtMillis = 1L),
|
||||
RuleProfile(id = "work", name = "Work", createdAtMillis = 2L),
|
||||
),
|
||||
activeProfileId = DEFAULT_PROFILE_ID,
|
||||
),
|
||||
)
|
||||
override val state: StateFlow<FirewallState> = stateFlow
|
||||
override val availability: StateFlow<FirewallAvailability> =
|
||||
MutableStateFlow(FirewallAvailability.Checking)
|
||||
override val error = MutableStateFlow<String?>(null)
|
||||
override val drift = MutableStateFlow(FirewallDriftState())
|
||||
val activations = mutableListOf<Activation>()
|
||||
|
||||
override suspend fun applyScheduledProfile(
|
||||
profileId: String,
|
||||
enableFirewall: Boolean?,
|
||||
): Boolean {
|
||||
activations += Activation(profileId, enableFirewall)
|
||||
stateFlow.update { current ->
|
||||
current.copy(
|
||||
enabled = enableFirewall ?: current.enabled,
|
||||
activeProfileId = profileId,
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun setBlocked(app: InstalledApp, blocked: Boolean) = Unit
|
||||
override suspend fun addBlockedSelection(app: InstalledApp): Boolean = false
|
||||
override suspend fun setEnabled(enabled: Boolean) = Unit
|
||||
override suspend fun restore() = Unit
|
||||
override suspend fun checkForDrift() = Unit
|
||||
override suspend fun refreshAvailability() = Unit
|
||||
override fun requestPermission() = Unit
|
||||
override suspend fun removeBlocked(key: AppKey) = Unit
|
||||
override suspend fun removeBlockedForUser(userId: Int) = Unit
|
||||
override suspend fun switchProfile(id: String) = Unit
|
||||
override suspend fun createProfile(name: String): Boolean = false
|
||||
override suspend fun duplicateProfile(sourceId: String, name: String): Boolean = false
|
||||
override suspend fun renameProfile(id: String, name: String): Boolean = false
|
||||
override suspend fun deleteProfile(id: String): Boolean = false
|
||||
override suspend fun removeBlockedEverywhere(key: AppKey) = Unit
|
||||
override suspend fun removeBlockedForUserEverywhere(userId: Int) = Unit
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package com.krrishg.kwall.scheduler
|
||||
|
||||
import java.time.DayOfWeek
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneOffset
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ScheduleEngineTest {
|
||||
|
||||
private val zone = ZoneOffset.UTC
|
||||
private val profiles = setOf("p", "f")
|
||||
|
||||
@Test
|
||||
fun `daily schedule advances by its interval`() {
|
||||
val schedule = schedule(
|
||||
recurrence = ScheduleRecurrence.Daily(LocalDate.of(2026, 1, 1), intervalDays = 2),
|
||||
start = LocalTime.of(9, 0),
|
||||
)
|
||||
|
||||
val next = ScheduleEngine.nextOccurrence(
|
||||
schedule,
|
||||
Instant.parse("2026-01-02T10:00:00Z"),
|
||||
zone,
|
||||
)
|
||||
|
||||
assertEquals(Instant.parse("2026-01-03T09:00:00Z"), next)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `weekly schedule selects configured weekdays`() {
|
||||
val schedule = schedule(
|
||||
recurrence = ScheduleRecurrence.Weekly(
|
||||
anchorDate = LocalDate.of(2026, 1, 5),
|
||||
daysOfWeek = setOf(DayOfWeek.WEDNESDAY, DayOfWeek.FRIDAY),
|
||||
intervalWeeks = 1,
|
||||
),
|
||||
start = LocalTime.of(8, 30),
|
||||
)
|
||||
|
||||
val next = ScheduleEngine.nextOccurrence(
|
||||
schedule,
|
||||
Instant.parse("2026-01-07T09:00:00Z"),
|
||||
zone,
|
||||
)
|
||||
|
||||
assertEquals(Instant.parse("2026-01-09T08:30:00Z"), next)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `weekly schedule respects multi-week intervals`() {
|
||||
val schedule = schedule(
|
||||
recurrence = ScheduleRecurrence.Weekly(
|
||||
anchorDate = LocalDate.of(2026, 1, 5),
|
||||
daysOfWeek = setOf(DayOfWeek.MONDAY),
|
||||
intervalWeeks = 2,
|
||||
),
|
||||
start = LocalTime.of(8, 0),
|
||||
)
|
||||
|
||||
val next = ScheduleEngine.nextOccurrence(
|
||||
schedule,
|
||||
Instant.parse("2026-01-06T00:00:00Z"),
|
||||
zone,
|
||||
)
|
||||
|
||||
assertEquals(Instant.parse("2026-01-19T08:00:00Z"), next)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `weekly schedule before the anchor uses the anchor week`() {
|
||||
val schedule = schedule(
|
||||
recurrence = ScheduleRecurrence.Weekly(
|
||||
anchorDate = LocalDate.of(2026, 1, 5),
|
||||
daysOfWeek = setOf(DayOfWeek.WEDNESDAY),
|
||||
intervalWeeks = 2,
|
||||
),
|
||||
start = LocalTime.of(8, 0),
|
||||
)
|
||||
|
||||
val next = ScheduleEngine.nextOccurrence(
|
||||
schedule,
|
||||
Instant.parse("2026-01-01T00:00:00Z"),
|
||||
zone,
|
||||
)
|
||||
|
||||
assertEquals(Instant.parse("2026-01-07T08:00:00Z"), next)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `monthly schedule clamps to the end of shorter months`() {
|
||||
val schedule = schedule(
|
||||
recurrence = ScheduleRecurrence.Monthly(
|
||||
anchorDate = LocalDate.of(2026, 1, 31),
|
||||
dayOfMonth = 31,
|
||||
intervalMonths = 1,
|
||||
),
|
||||
start = LocalTime.of(12, 0),
|
||||
)
|
||||
|
||||
val next = ScheduleEngine.nextOccurrence(
|
||||
schedule,
|
||||
Instant.parse("2026-01-31T13:00:00Z"),
|
||||
zone,
|
||||
)
|
||||
|
||||
assertEquals(Instant.parse("2026-02-28T12:00:00Z"), next)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an overnight window ends on the following day`() {
|
||||
val schedule = schedule(
|
||||
recurrence = ScheduleRecurrence.Once(LocalDate.of(2026, 1, 5)),
|
||||
start = LocalTime.of(23, 0),
|
||||
end = LocalTime.of(2, 0),
|
||||
)
|
||||
|
||||
val window = ScheduleEngine.windowFor(schedule, LocalDate.of(2026, 1, 5), zone)
|
||||
|
||||
assertEquals(Instant.parse("2026-01-05T23:00:00Z"), window?.start)
|
||||
assertEquals(Instant.parse("2026-01-06T02:00:00Z"), window?.end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `active schedule is reported inside a window and cleared after the end`() {
|
||||
val schedule = schedule(
|
||||
recurrence = ScheduleRecurrence.Once(LocalDate.of(2026, 1, 5)),
|
||||
start = LocalTime.of(9, 0),
|
||||
end = LocalTime.of(17, 0),
|
||||
)
|
||||
|
||||
val active = ScheduleEngine.activeSchedule(
|
||||
listOf(schedule),
|
||||
Instant.parse("2026-01-05T12:00:00Z"),
|
||||
zone,
|
||||
)
|
||||
val ended = ScheduleEngine.activeSchedule(
|
||||
listOf(schedule),
|
||||
Instant.parse("2026-01-05T18:00:00Z"),
|
||||
zone,
|
||||
)
|
||||
|
||||
assertEquals(schedule.id, active?.schedule?.id)
|
||||
assertNull(ended)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an open ended schedule stays active until another schedule starts`() {
|
||||
val first = schedule(
|
||||
id = "first",
|
||||
recurrence = ScheduleRecurrence.Once(LocalDate.of(2026, 1, 5)),
|
||||
start = LocalTime.of(9, 0),
|
||||
)
|
||||
val second = schedule(
|
||||
id = "second",
|
||||
recurrence = ScheduleRecurrence.Once(LocalDate.of(2026, 1, 6)),
|
||||
start = LocalTime.of(9, 0),
|
||||
)
|
||||
|
||||
val active = ScheduleEngine.activeSchedule(
|
||||
listOf(first, second),
|
||||
Instant.parse("2026-01-06T12:00:00Z"),
|
||||
zone,
|
||||
)
|
||||
|
||||
assertEquals(second.id, active?.schedule?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `next transition includes the end of an active window`() {
|
||||
val schedule = schedule(
|
||||
recurrence = ScheduleRecurrence.Once(LocalDate.of(2026, 1, 5)),
|
||||
start = LocalTime.of(9, 0),
|
||||
end = LocalTime.of(17, 0),
|
||||
)
|
||||
|
||||
val next = ScheduleEngine.nextTransition(
|
||||
listOf(schedule),
|
||||
Instant.parse("2026-01-05T12:00:00Z"),
|
||||
zone,
|
||||
)
|
||||
|
||||
assertEquals(Instant.parse("2026-01-05T17:00:00Z"), next)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `overlapping windows are rejected`() {
|
||||
val existing = schedule(
|
||||
recurrence = ScheduleRecurrence.Once(LocalDate.of(2026, 1, 5)),
|
||||
start = LocalTime.of(9, 0),
|
||||
end = LocalTime.of(12, 0),
|
||||
)
|
||||
val candidate = schedule(
|
||||
id = "candidate",
|
||||
recurrence = ScheduleRecurrence.Once(LocalDate.of(2026, 1, 5)),
|
||||
start = LocalTime.of(11, 0),
|
||||
end = LocalTime.of(13, 0),
|
||||
)
|
||||
|
||||
val issues = ScheduleEngine.validateSchedule(
|
||||
candidate,
|
||||
listOf(existing),
|
||||
profiles,
|
||||
Instant.parse("2026-01-01T00:00:00Z"),
|
||||
zone,
|
||||
)
|
||||
|
||||
assertTrue(ScheduleIssue.OVERLAP in issues)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open ended activations cannot start inside another window`() {
|
||||
val existing = schedule(
|
||||
recurrence = ScheduleRecurrence.Once(LocalDate.of(2026, 1, 5)),
|
||||
start = LocalTime.of(9, 0),
|
||||
end = LocalTime.of(12, 0),
|
||||
)
|
||||
val candidate = schedule(
|
||||
id = "candidate",
|
||||
recurrence = ScheduleRecurrence.Once(LocalDate.of(2026, 1, 5)),
|
||||
start = LocalTime.of(10, 0),
|
||||
)
|
||||
|
||||
val issues = ScheduleEngine.validateSchedule(
|
||||
candidate,
|
||||
listOf(existing),
|
||||
profiles,
|
||||
Instant.parse("2026-01-01T00:00:00Z"),
|
||||
zone,
|
||||
)
|
||||
|
||||
assertTrue(ScheduleIssue.OVERLAP in issues)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one time schedules in the past are rejected`() {
|
||||
val candidate = schedule(
|
||||
recurrence = ScheduleRecurrence.Once(LocalDate.of(2025, 1, 5)),
|
||||
start = LocalTime.of(9, 0),
|
||||
)
|
||||
|
||||
val issues = ScheduleEngine.validateSchedule(
|
||||
candidate,
|
||||
emptyList(),
|
||||
profiles,
|
||||
Instant.parse("2026-01-01T00:00:00Z"),
|
||||
zone,
|
||||
)
|
||||
|
||||
assertTrue(ScheduleIssue.PAST_ONE_TIME in issues)
|
||||
}
|
||||
|
||||
private fun schedule(
|
||||
id: String = "schedule",
|
||||
recurrence: ScheduleRecurrence,
|
||||
start: LocalTime,
|
||||
end: LocalTime? = null,
|
||||
): ProfileSchedule = ProfileSchedule(
|
||||
id = id,
|
||||
name = "Schedule",
|
||||
profileId = "p",
|
||||
fallbackProfileId = "f",
|
||||
recurrence = recurrence,
|
||||
startTime = start,
|
||||
endTime = end,
|
||||
enabled = true,
|
||||
createdAtMillis = 1L,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.krrishg.kwall.scheduler
|
||||
|
||||
import java.time.DayOfWeek
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ScheduleJsonCodecTest {
|
||||
|
||||
@Test
|
||||
fun `schedules survive a json round trip`() {
|
||||
val schedules = listOf(
|
||||
ProfileSchedule(
|
||||
id = "weekly",
|
||||
name = "Work week",
|
||||
profileId = "work",
|
||||
fallbackProfileId = "default",
|
||||
recurrence = ScheduleRecurrence.Weekly(
|
||||
anchorDate = LocalDate.of(2026, 1, 5),
|
||||
daysOfWeek = setOf(DayOfWeek.MONDAY, DayOfWeek.FRIDAY),
|
||||
intervalWeeks = 2,
|
||||
),
|
||||
startTime = LocalTime.of(9, 0),
|
||||
endTime = LocalTime.of(17, 30),
|
||||
enabled = false,
|
||||
createdAtMillis = 42L,
|
||||
),
|
||||
ProfileSchedule(
|
||||
id = "monthly",
|
||||
name = "Month end",
|
||||
profileId = "strict",
|
||||
fallbackProfileId = "default",
|
||||
recurrence = ScheduleRecurrence.Monthly(
|
||||
anchorDate = LocalDate.of(2026, 1, 31),
|
||||
dayOfMonth = 31,
|
||||
intervalMonths = 1,
|
||||
),
|
||||
startTime = LocalTime.of(20, 0),
|
||||
createdAtMillis = 43L,
|
||||
),
|
||||
)
|
||||
|
||||
val decoded = ScheduleJsonCodec.decode(ScheduleJsonCodec.encode(schedules))
|
||||
|
||||
assertEquals(schedules, decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `corrupt json decodes to an empty list`() {
|
||||
assertTrue(ScheduleJsonCodec.decode("{not json").isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown recurrence records are ignored`() {
|
||||
val raw = """
|
||||
{"version":1,"schedules":[
|
||||
{"id":"x","name":"x","profileId":"p","fallbackProfileId":"f",
|
||||
"type":"unknown","anchorDate":"2026-01-01","startTime":"09:00"}
|
||||
]}
|
||||
""".trimIndent()
|
||||
|
||||
assertTrue(ScheduleJsonCodec.decode(raw).isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,705 @@
|
||||
package com.krrishg.kwall.ui.apps
|
||||
|
||||
import com.krrishg.kwall.AppChangeNotifier
|
||||
import com.krrishg.kwall.MainDispatcherRule
|
||||
import com.krrishg.kwall.data.AppRepository
|
||||
import com.krrishg.kwall.data.InstalledApp
|
||||
import com.krrishg.kwall.firewall.AppBlockability
|
||||
import com.krrishg.kwall.firewall.AppKey
|
||||
import com.krrishg.kwall.firewall.ChainTransport
|
||||
import com.krrishg.kwall.firewall.DEFAULT_PROFILE_ID
|
||||
import com.krrishg.kwall.firewall.FirewallAvailability
|
||||
import com.krrishg.kwall.firewall.FirewallController
|
||||
import com.krrishg.kwall.firewall.FirewallDriftState
|
||||
import com.krrishg.kwall.firewall.FirewallState
|
||||
import com.krrishg.kwall.firewall.RuleProfile
|
||||
import com.krrishg.kwall.firewall.RulesTransport
|
||||
import com.krrishg.kwall.firewall.defaultRuleProfile
|
||||
import com.krrishg.kwall.scheduler.ProfileSchedule
|
||||
import com.krrishg.kwall.scheduler.ScheduleRecurrence
|
||||
import com.krrishg.kwall.scheduler.ScheduleStore
|
||||
import com.krrishg.kwall.scheduler.SchedulerRuntime
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class AppListViewModelTest {
|
||||
|
||||
@get:Rule
|
||||
val mainDispatcherRule = MainDispatcherRule()
|
||||
|
||||
private val notes = installedApp("com.example.notes", "Notes", uid = 10123)
|
||||
private val browser = installedApp("com.example.browser", "Browser", uid = 10124)
|
||||
private val workChat = installedApp(
|
||||
packageName = "com.example.chat",
|
||||
label = "Chat",
|
||||
uid = 10 * PER_USER_RANGE + 10125,
|
||||
userId = 10,
|
||||
isWorkProfile = true,
|
||||
)
|
||||
private val androidSystem = installedApp(
|
||||
packageName = "android",
|
||||
label = "Android System",
|
||||
uid = 1000,
|
||||
isSystem = true,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `system apps are hidden by default`() = runTest {
|
||||
val viewModel = viewModel(FakeAppRepository(listOf(notes, browser, androidSystem)))
|
||||
|
||||
assertFalse(viewModel.uiState.value.showSystemApps)
|
||||
assertEquals(listOf(notes, browser), viewModel.uiState.value.apps.map { it.app })
|
||||
assertFalse(viewModel.uiState.value.isLoading)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enabling system apps shows them`() = runTest {
|
||||
val viewModel = viewModel(FakeAppRepository(listOf(notes, androidSystem)))
|
||||
|
||||
viewModel.setShowSystemApps(true)
|
||||
|
||||
assertTrue(viewModel.uiState.value.showSystemApps)
|
||||
assertEquals(
|
||||
listOf(notes, androidSystem),
|
||||
viewModel.uiState.value.apps.map { it.app },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disabling system apps hides them again`() = runTest {
|
||||
val viewModel = viewModel(FakeAppRepository(listOf(notes, androidSystem)))
|
||||
viewModel.setShowSystemApps(true)
|
||||
|
||||
viewModel.setShowSystemApps(false)
|
||||
|
||||
assertFalse(viewModel.uiState.value.showSystemApps)
|
||||
assertEquals(listOf(notes), viewModel.uiState.value.apps.map { it.app })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blocked filter shows only blocked apps`() = runTest {
|
||||
val controller = FakeFirewallController(
|
||||
initialState = state(blocked = setOf(notes.key)),
|
||||
)
|
||||
val viewModel = viewModel(FakeAppRepository(listOf(notes, browser)), controller)
|
||||
|
||||
viewModel.setFilter(AppListFilter.BLOCKED)
|
||||
|
||||
assertEquals(AppListFilter.BLOCKED, viewModel.uiState.value.filter)
|
||||
assertEquals(listOf(notes), viewModel.uiState.value.apps.map { it.app })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unblocked filter shows only unblocked apps`() = runTest {
|
||||
val controller = FakeFirewallController(
|
||||
initialState = state(blocked = setOf(notes.key)),
|
||||
)
|
||||
val viewModel = viewModel(FakeAppRepository(listOf(notes, browser)), controller)
|
||||
|
||||
viewModel.setFilter(AppListFilter.UNBLOCKED)
|
||||
|
||||
assertEquals(listOf(browser), viewModel.uiState.value.apps.map { it.app })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all filter restores every app`() = runTest {
|
||||
val controller = FakeFirewallController(
|
||||
initialState = state(blocked = setOf(notes.key)),
|
||||
)
|
||||
val viewModel = viewModel(FakeAppRepository(listOf(notes, browser)), controller)
|
||||
viewModel.setFilter(AppListFilter.BLOCKED)
|
||||
|
||||
viewModel.setFilter(AppListFilter.ALL)
|
||||
|
||||
assertEquals(listOf(notes, browser), viewModel.uiState.value.apps.map { it.app })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search filters apps by name ignoring case`() = runTest {
|
||||
val viewModel = viewModel(FakeAppRepository(listOf(notes, browser)))
|
||||
|
||||
viewModel.setSearchQuery("bRoW")
|
||||
|
||||
assertEquals("bRoW", viewModel.uiState.value.searchQuery)
|
||||
assertEquals(listOf(browser), viewModel.uiState.value.apps.map { it.app })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search ignores surrounding whitespace`() = runTest {
|
||||
val viewModel = viewModel(FakeAppRepository(listOf(notes, browser)))
|
||||
|
||||
viewModel.setSearchQuery(" notes ")
|
||||
|
||||
assertEquals(listOf(notes), viewModel.uiState.value.apps.map { it.app })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank search shows all apps`() = runTest {
|
||||
val viewModel = viewModel(FakeAppRepository(listOf(notes, browser)))
|
||||
viewModel.setSearchQuery("notes")
|
||||
|
||||
viewModel.setSearchQuery("")
|
||||
|
||||
assertEquals(listOf(notes, browser), viewModel.uiState.value.apps.map { it.app })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search and filter combine`() = runTest {
|
||||
val controller = FakeFirewallController(
|
||||
initialState = state(blocked = setOf(notes.key)),
|
||||
)
|
||||
val viewModel = viewModel(
|
||||
FakeAppRepository(listOf(notes, browser, androidSystem)),
|
||||
controller,
|
||||
showSystemApps = true,
|
||||
)
|
||||
|
||||
viewModel.setFilter(AppListFilter.UNBLOCKED)
|
||||
viewModel.setSearchQuery("android")
|
||||
|
||||
assertEquals(listOf(androidSystem), viewModel.uiState.value.apps.map { it.app })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refresh reloads installed apps`() = runTest {
|
||||
val repository = FakeAppRepository(listOf(notes))
|
||||
val viewModel = viewModel(repository)
|
||||
repository.apps = listOf(notes, browser)
|
||||
|
||||
viewModel.refresh()
|
||||
|
||||
assertEquals(
|
||||
listOf(notes, browser),
|
||||
viewModel.uiState.value.apps.map { it.app },
|
||||
)
|
||||
assertEquals(2, repository.loadCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `load failure exposes error`() = runTest {
|
||||
val viewModel = viewModel(
|
||||
FakeAppRepository(emptyList(), IllegalStateException("boom")),
|
||||
)
|
||||
|
||||
assertFalse(viewModel.uiState.value.isLoading)
|
||||
assertEquals("boom", viewModel.uiState.value.errorMessage)
|
||||
assertTrue(viewModel.uiState.value.apps.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `successful refresh clears previous error`() = runTest {
|
||||
val repository = FakeAppRepository(emptyList(), IllegalStateException("boom"))
|
||||
val viewModel = viewModel(repository)
|
||||
repository.error = null
|
||||
repository.apps = listOf(notes)
|
||||
|
||||
viewModel.refresh()
|
||||
|
||||
assertNull(viewModel.uiState.value.errorMessage)
|
||||
assertEquals(listOf(notes), viewModel.uiState.value.apps.map { it.app })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blocked state is reflected in rows`() = runTest {
|
||||
val controller = FakeFirewallController(
|
||||
initialState = state(enabled = true, blocked = setOf(notes.key)),
|
||||
)
|
||||
val viewModel = viewModel(FakeAppRepository(listOf(notes, browser)), controller)
|
||||
|
||||
val rows = viewModel.uiState.value.apps.associateBy { it.app.key }
|
||||
|
||||
assertTrue(rows.getValue(notes.key).isBlocked)
|
||||
assertFalse(rows.getValue(browser.key).isBlocked)
|
||||
assertEquals(1, viewModel.uiState.value.firewall.blockedCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toggling an app delegates to the controller without changing the master switch`() = runTest {
|
||||
val controller = FakeFirewallController()
|
||||
val viewModel = viewModel(FakeAppRepository(listOf(notes)), controller)
|
||||
|
||||
viewModel.setBlocked(notes, true)
|
||||
|
||||
assertEquals(listOf(notes.key to true), controller.blockedCalls)
|
||||
assertFalse(viewModel.uiState.value.firewall.enabled)
|
||||
assertEquals(1, viewModel.uiState.value.firewall.blockedCount)
|
||||
assertFalse(viewModel.uiState.value.firewall.isApplying)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `master switch delegates to the controller`() = runTest {
|
||||
val controller = FakeFirewallController()
|
||||
val viewModel = viewModel(FakeAppRepository(listOf(notes)), controller)
|
||||
|
||||
viewModel.setFirewallEnabled(true)
|
||||
|
||||
assertTrue(controller.state.value.enabled)
|
||||
assertFalse(viewModel.uiState.value.firewall.isApplying)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `system uid apps are marked unblockable`() = runTest {
|
||||
val viewModel = viewModel(
|
||||
FakeAppRepository(listOf(notes, androidSystem)),
|
||||
showSystemApps = true,
|
||||
)
|
||||
|
||||
val row = viewModel.uiState.value.apps.first { it.app.key == androidSystem.key }
|
||||
|
||||
assertEquals(AppBlockability.SYSTEM_UID, row.blockability)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `work profile rows stay distinct from personal rows`() = runTest {
|
||||
val personalChat = installedApp("com.example.chat", "Chat", uid = 10125)
|
||||
val viewModel = viewModel(
|
||||
FakeAppRepository(listOf(personalChat, workChat)),
|
||||
)
|
||||
|
||||
val keys = viewModel.uiState.value.apps.map { it.app.key }
|
||||
|
||||
assertEquals(2, keys.size)
|
||||
assertTrue(personalChat.key in keys)
|
||||
assertTrue(workChat.key in keys)
|
||||
assertTrue(viewModel.uiState.value.apps.first { it.app == workChat }.app.isWorkProfile)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `work profile warns when shell blocks all profiles`() = runTest {
|
||||
val personalChat = installedApp("com.example.chat", "Chat", uid = 10125)
|
||||
val controller = FakeFirewallController(
|
||||
initialAvailability = FirewallAvailability.Ready(
|
||||
rulesTransport = RulesTransport.SHELL,
|
||||
chainTransport = ChainTransport.SHELL,
|
||||
),
|
||||
)
|
||||
val viewModel = viewModel(FakeAppRepository(listOf(personalChat, workChat)), controller)
|
||||
|
||||
val row = viewModel.uiState.value.apps.first { it.app == workChat }
|
||||
|
||||
assertEquals(AppBlockability.BLOCKABLE, row.blockability)
|
||||
assertTrue(row.warnAllProfiles)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `work only app is disabled when shell is the only transport`() = runTest {
|
||||
val controller = FakeFirewallController(
|
||||
initialAvailability = FirewallAvailability.Ready(
|
||||
rulesTransport = RulesTransport.SHELL,
|
||||
chainTransport = ChainTransport.SHELL,
|
||||
),
|
||||
)
|
||||
val viewModel = viewModel(FakeAppRepository(listOf(workChat)), controller)
|
||||
|
||||
val row = viewModel.uiState.value.apps.first { it.app == workChat }
|
||||
|
||||
assertEquals(AppBlockability.WORK_PROFILE_ONLY, row.blockability)
|
||||
assertFalse(row.warnAllProfiles)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `permission request delegates to the controller`() = runTest {
|
||||
val controller = FakeFirewallController()
|
||||
val viewModel = viewModel(FakeAppRepository(listOf(notes)), controller)
|
||||
|
||||
viewModel.requestFirewallPermission()
|
||||
|
||||
assertTrue(controller.permissionRequested)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selecting an app exposes its row`() = runTest {
|
||||
val viewModel = viewModel(FakeAppRepository(listOf(notes, browser)))
|
||||
val row = viewModel.uiState.value.apps.first { it.app == notes }
|
||||
|
||||
viewModel.selectApp(row)
|
||||
|
||||
assertEquals(row, viewModel.uiState.value.selectedApp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearing the selection returns to the list`() = runTest {
|
||||
val viewModel = viewModel(FakeAppRepository(listOf(notes)))
|
||||
val row = viewModel.uiState.value.apps.first()
|
||||
|
||||
viewModel.selectApp(row)
|
||||
viewModel.clearSelection()
|
||||
|
||||
assertNull(viewModel.uiState.value.selectedApp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `profile state exposes profiles and active profile`() = runTest {
|
||||
val work = RuleProfile(
|
||||
id = "work",
|
||||
name = "Work",
|
||||
blockedApps = setOf(notes.key),
|
||||
createdAtMillis = 2L,
|
||||
)
|
||||
val controller = FakeFirewallController(
|
||||
initialState = FirewallState(
|
||||
enabled = true,
|
||||
profiles = listOf(defaultRuleProfile(createdAtMillis = 1L), work),
|
||||
activeProfileId = work.id,
|
||||
),
|
||||
)
|
||||
val viewModel = viewModel(FakeAppRepository(listOf(notes, browser)), controller)
|
||||
|
||||
assertEquals("Work", viewModel.uiState.value.firewall.activeProfile?.name)
|
||||
assertEquals(2, viewModel.uiState.value.firewall.profiles.size)
|
||||
assertEquals(1, viewModel.uiState.value.firewall.blockedCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `profile actions delegate to the controller`() = runTest {
|
||||
val work = RuleProfile(
|
||||
id = "work",
|
||||
name = "Work",
|
||||
blockedApps = setOf(notes.key),
|
||||
createdAtMillis = 2L,
|
||||
)
|
||||
val controller = FakeFirewallController(
|
||||
initialState = FirewallState(
|
||||
profiles = listOf(defaultRuleProfile(createdAtMillis = 1L), work),
|
||||
activeProfileId = work.id,
|
||||
),
|
||||
)
|
||||
val viewModel = viewModel(FakeAppRepository(listOf(notes)), controller)
|
||||
|
||||
viewModel.switchProfile(work.id)
|
||||
viewModel.createProfile("Travel")
|
||||
viewModel.renameProfile(work.id, "Office")
|
||||
viewModel.duplicateProfile(work.id, "Office copy")
|
||||
viewModel.deleteProfile("unknown")
|
||||
|
||||
assertEquals(listOf(work.id), controller.switchedProfiles)
|
||||
assertEquals(listOf("Travel"), controller.createdProfiles)
|
||||
assertEquals(listOf(work.id to "Office"), controller.renamedProfiles)
|
||||
assertEquals(listOf(work.id to "Office copy"), controller.duplicatedProfiles)
|
||||
assertEquals(listOf("unknown"), controller.deletedProfiles)
|
||||
assertFalse(viewModel.uiState.value.firewall.isApplying)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleting a profile used by a schedule is rejected`() = runTest {
|
||||
val work = RuleProfile(
|
||||
id = "work",
|
||||
name = "Work",
|
||||
createdAtMillis = 2L,
|
||||
)
|
||||
val controller = FakeFirewallController(
|
||||
initialState = FirewallState(
|
||||
profiles = listOf(defaultRuleProfile(createdAtMillis = 1L), work),
|
||||
activeProfileId = work.id,
|
||||
),
|
||||
)
|
||||
val schedule = ProfileSchedule(
|
||||
id = "schedule",
|
||||
name = "Work hours",
|
||||
profileId = work.id,
|
||||
fallbackProfileId = DEFAULT_PROFILE_ID,
|
||||
recurrence = ScheduleRecurrence.Once(LocalDate.now().plusDays(1)),
|
||||
startTime = LocalTime.of(9, 0),
|
||||
)
|
||||
val viewModel = viewModel(
|
||||
repository = FakeAppRepository(listOf(notes)),
|
||||
controller = controller,
|
||||
scheduleStore = FakeScheduleStore(listOf(schedule)),
|
||||
)
|
||||
|
||||
viewModel.deleteProfile(work.id)
|
||||
|
||||
assertTrue(controller.deletedProfiles.isEmpty())
|
||||
assertEquals(
|
||||
"This profile is used by a schedule",
|
||||
viewModel.uiState.value.firewall.errorMessage,
|
||||
)
|
||||
assertFalse(viewModel.uiState.value.firewall.isApplying)
|
||||
}
|
||||
|
||||
private fun state(
|
||||
enabled: Boolean = false,
|
||||
blocked: Set<AppKey> = emptySet(),
|
||||
): FirewallState = FirewallState(
|
||||
enabled = enabled,
|
||||
profiles = listOf(defaultRuleProfile(blockedApps = blocked, createdAtMillis = 1L)),
|
||||
activeProfileId = defaultRuleProfile().id,
|
||||
)
|
||||
|
||||
private fun viewModel(
|
||||
repository: AppRepository,
|
||||
controller: FakeFirewallController = FakeFirewallController(),
|
||||
showSystemApps: Boolean = false,
|
||||
scheduleStore: ScheduleStore = FakeScheduleStore(),
|
||||
): AppListViewModel {
|
||||
val viewModel = AppListViewModel(
|
||||
repository = repository,
|
||||
firewallController = controller,
|
||||
appChangeNotifier = AppChangeNotifier(),
|
||||
scheduleStore = scheduleStore,
|
||||
)
|
||||
if (showSystemApps) viewModel.setShowSystemApps(true)
|
||||
return viewModel
|
||||
}
|
||||
|
||||
private fun installedApp(
|
||||
packageName: String,
|
||||
label: String,
|
||||
uid: Int,
|
||||
userId: Int = 0,
|
||||
isWorkProfile: Boolean = false,
|
||||
hasInternetPermission: Boolean = true,
|
||||
isSystem: Boolean = false,
|
||||
) = InstalledApp(
|
||||
packageName = packageName,
|
||||
label = label,
|
||||
isSystem = isSystem,
|
||||
icon = null,
|
||||
uid = uid,
|
||||
userId = userId,
|
||||
isWorkProfile = isWorkProfile,
|
||||
hasInternetPermission = hasInternetPermission,
|
||||
)
|
||||
|
||||
private class FakeAppRepository(
|
||||
var apps: List<InstalledApp>,
|
||||
var error: Exception? = null,
|
||||
) : AppRepository {
|
||||
|
||||
var loadCount = 0
|
||||
|
||||
override suspend fun loadInstalledApps(): List<InstalledApp> {
|
||||
loadCount++
|
||||
error?.let { throw it }
|
||||
return apps
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeFirewallController(
|
||||
initialState: FirewallState = FirewallState(),
|
||||
initialAvailability: FirewallAvailability = FirewallAvailability.Ready(
|
||||
rulesTransport = RulesTransport.BINDER,
|
||||
chainTransport = ChainTransport.SHELL,
|
||||
),
|
||||
) : FirewallController {
|
||||
|
||||
private val stateFlow = MutableStateFlow(initialState)
|
||||
override val state: StateFlow<FirewallState> = stateFlow
|
||||
|
||||
private val availabilityFlow = MutableStateFlow(initialAvailability)
|
||||
override val availability: StateFlow<FirewallAvailability> = availabilityFlow
|
||||
|
||||
override val error = MutableStateFlow<String?>(null)
|
||||
override val drift = MutableStateFlow(FirewallDriftState())
|
||||
|
||||
val blockedCalls = mutableListOf<Pair<AppKey, Boolean>>()
|
||||
val switchedProfiles = mutableListOf<String>()
|
||||
val createdProfiles = mutableListOf<String>()
|
||||
val renamedProfiles = mutableListOf<Pair<String, String>>()
|
||||
val duplicatedProfiles = mutableListOf<Pair<String, String>>()
|
||||
val deletedProfiles = mutableListOf<String>()
|
||||
var permissionRequested = false
|
||||
private var profileCounter = 0
|
||||
|
||||
override suspend fun setBlocked(app: InstalledApp, blocked: Boolean) {
|
||||
blockedCalls += app.key to blocked
|
||||
stateFlow.update { current ->
|
||||
current.updateActiveApps { apps ->
|
||||
if (blocked) apps + app.key else apps - app.key
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun addBlockedSelection(app: InstalledApp): Boolean {
|
||||
if (app.key in stateFlow.value.blockedApps) return false
|
||||
stateFlow.update { current ->
|
||||
current.updateActiveApps { apps -> apps + app.key }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun setEnabled(enabled: Boolean) {
|
||||
stateFlow.update { it.copy(enabled = enabled) }
|
||||
}
|
||||
|
||||
override suspend fun applyScheduledProfile(
|
||||
profileId: String,
|
||||
enableFirewall: Boolean?,
|
||||
): Boolean = true
|
||||
|
||||
override suspend fun restore() = Unit
|
||||
|
||||
override suspend fun checkForDrift() = Unit
|
||||
|
||||
override suspend fun refreshAvailability() = Unit
|
||||
|
||||
override fun requestPermission() {
|
||||
permissionRequested = true
|
||||
}
|
||||
|
||||
override suspend fun removeBlocked(key: AppKey) {
|
||||
stateFlow.update { current ->
|
||||
current.updateActiveApps { apps -> apps - key }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun removeBlockedForUser(userId: Int) {
|
||||
stateFlow.update { current ->
|
||||
current.updateActiveApps { apps -> apps.filterNot { it.userId == userId }.toSet() }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun switchProfile(id: String) {
|
||||
switchedProfiles += id
|
||||
stateFlow.update { current ->
|
||||
if (current.profiles.none { it.id == id }) current
|
||||
else current.copy(activeProfileId = id)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun createProfile(name: String): Boolean {
|
||||
createdProfiles += name
|
||||
val profile = RuleProfile(
|
||||
id = "profile-${++profileCounter}",
|
||||
name = name,
|
||||
blockedApps = emptySet(),
|
||||
createdAtMillis = profileCounter.toLong(),
|
||||
)
|
||||
stateFlow.update { it.copy(profiles = it.profiles + profile) }
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun duplicateProfile(sourceId: String, name: String): Boolean {
|
||||
duplicatedProfiles += sourceId to name
|
||||
val source = stateFlow.value.profiles.firstOrNull { it.id == sourceId } ?: return false
|
||||
val profile = RuleProfile(
|
||||
id = "profile-${++profileCounter}",
|
||||
name = name,
|
||||
blockedApps = source.blockedApps,
|
||||
createdAtMillis = profileCounter.toLong(),
|
||||
)
|
||||
stateFlow.update { it.copy(profiles = it.profiles + profile) }
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun renameProfile(id: String, name: String): Boolean {
|
||||
renamedProfiles += id to name
|
||||
stateFlow.update { current ->
|
||||
current.copy(
|
||||
profiles = current.profiles.map { profile ->
|
||||
if (profile.id == id) profile.copy(name = name) else profile
|
||||
},
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun deleteProfile(id: String): Boolean {
|
||||
deletedProfiles += id
|
||||
val current = stateFlow.value
|
||||
if (current.profiles.size <= 1) return false
|
||||
val remaining = current.profiles.filterNot { it.id == id }
|
||||
val activeId = if (current.activeProfileId == id) remaining.first().id else current.activeProfileId
|
||||
stateFlow.value = current.copy(profiles = remaining, activeProfileId = activeId)
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun removeBlockedEverywhere(key: AppKey) {
|
||||
stateFlow.update { current ->
|
||||
current.copy(
|
||||
profiles = current.profiles.map { profile ->
|
||||
profile.copy(blockedApps = profile.blockedApps - key)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun removeBlockedForUserEverywhere(userId: Int) {
|
||||
stateFlow.update { current ->
|
||||
current.copy(
|
||||
profiles = current.profiles.map { profile ->
|
||||
profile.copy(
|
||||
blockedApps = profile.blockedApps.filterNot { it.userId == userId }.toSet(),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun FirewallState.updateActiveApps(transform: (Set<AppKey>) -> Set<AppKey>): FirewallState {
|
||||
val activeId = activeProfile.id
|
||||
return copy(
|
||||
profiles = profiles.map { profile ->
|
||||
if (profile.id == activeId) {
|
||||
profile.copy(blockedApps = transform(profile.blockedApps))
|
||||
} else {
|
||||
profile
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeScheduleStore(
|
||||
initialState: List<ProfileSchedule> = emptyList(),
|
||||
) : ScheduleStore {
|
||||
|
||||
private val scheduleFlow = MutableStateFlow(initialState)
|
||||
override val schedules: StateFlow<List<ProfileSchedule>> = scheduleFlow
|
||||
private var runtime = SchedulerRuntime()
|
||||
|
||||
override suspend fun create(schedule: ProfileSchedule): Boolean {
|
||||
scheduleFlow.update { it + schedule }
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun update(schedule: ProfileSchedule): Boolean {
|
||||
scheduleFlow.update { schedules ->
|
||||
if (schedules.none { it.id == schedule.id }) {
|
||||
schedules
|
||||
} else {
|
||||
schedules.map { if (it.id == schedule.id) schedule else it }
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun delete(id: String): Boolean {
|
||||
scheduleFlow.update { schedules -> schedules.filterNot { it.id == id } }
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun setEnabled(id: String, enabled: Boolean): Boolean {
|
||||
scheduleFlow.update { schedules ->
|
||||
schedules.map { if (it.id == id) it.copy(enabled = enabled) else it }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun readRuntime(): SchedulerRuntime = runtime
|
||||
|
||||
override suspend fun writeRuntime(runtime: SchedulerRuntime) {
|
||||
this.runtime = runtime
|
||||
}
|
||||
|
||||
override suspend fun clearRuntime() {
|
||||
runtime = SchedulerRuntime()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PER_USER_RANGE = 100_000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package com.krrishg.kwall.ui.detail
|
||||
|
||||
import com.krrishg.kwall.MainDispatcherRule
|
||||
import com.krrishg.kwall.data.InstalledApp
|
||||
import com.krrishg.kwall.firewall.AppKey
|
||||
import com.krrishg.kwall.firewall.ChainTransport
|
||||
import com.krrishg.kwall.firewall.FirewallAvailability
|
||||
import com.krrishg.kwall.firewall.FirewallController
|
||||
import com.krrishg.kwall.firewall.FirewallDriftState
|
||||
import com.krrishg.kwall.firewall.FirewallState
|
||||
import com.krrishg.kwall.firewall.RulesTransport
|
||||
import com.krrishg.kwall.network.ConnectionInspector
|
||||
import com.krrishg.kwall.network.ConnectionSnapshot
|
||||
import com.krrishg.kwall.network.DataUsage
|
||||
import com.krrishg.kwall.network.NetworkUsageSource
|
||||
import com.krrishg.kwall.network.SocketEntry
|
||||
import com.krrishg.kwall.network.SocketProtocol
|
||||
import com.krrishg.kwall.network.TcpState
|
||||
import com.krrishg.kwall.network.UsageRange
|
||||
import com.krrishg.kwall.network.UsageResult
|
||||
import java.net.InetAddress
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class AppDetailViewModelTest {
|
||||
|
||||
@get:Rule
|
||||
val mainDispatcherRule = MainDispatcherRule()
|
||||
|
||||
private val notes = InstalledApp(
|
||||
packageName = "com.example.notes",
|
||||
label = "Notes",
|
||||
isSystem = false,
|
||||
icon = null,
|
||||
uid = 10123,
|
||||
userId = 0,
|
||||
isWorkProfile = false,
|
||||
hasInternetPermission = true,
|
||||
)
|
||||
private val browser = notes.copy(
|
||||
packageName = "com.example.browser",
|
||||
label = "Browser",
|
||||
uid = 10124,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `load exposes the app and fetches its usage`() = runTest {
|
||||
val usage = FakeUsageSource(
|
||||
result = UsageResult.Available(DataUsage(receivedBytes = 120, sentBytes = 30)),
|
||||
)
|
||||
val viewModel = viewModel(usage = usage)
|
||||
|
||||
viewModel.load(notes)
|
||||
|
||||
assertEquals(notes, viewModel.uiState.value.app)
|
||||
assertEquals(
|
||||
UsageUiState.Loaded(DataUsage(receivedBytes = 120, sentBytes = 30)),
|
||||
viewModel.uiState.value.usage,
|
||||
)
|
||||
assertEquals(notes.uid, usage.lastUid)
|
||||
assertEquals(listOf(UsageRange.TODAY), usage.requestedRanges)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `connections are filtered by uid and split into active and listening`() = runTest {
|
||||
val inspector = FakeInspector(
|
||||
result = ConnectionSnapshot.Available(
|
||||
listOf(
|
||||
socket(uid = notes.uid, remotePort = 443, state = TcpState.ESTABLISHED),
|
||||
socket(uid = browser.uid, remotePort = 80, state = TcpState.ESTABLISHED),
|
||||
socket(
|
||||
uid = notes.uid,
|
||||
localPort = 8080,
|
||||
remotePort = 0,
|
||||
state = TcpState.LISTEN,
|
||||
remoteAddress = null,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
val viewModel = viewModel(inspector = inspector)
|
||||
viewModel.load(notes)
|
||||
|
||||
viewModel.refreshConnections()
|
||||
|
||||
val connections = viewModel.uiState.value.connections as ConnectionsUiState.Loaded
|
||||
assertEquals(1, connections.active.size)
|
||||
assertEquals(443, connections.active.single().remotePort)
|
||||
assertEquals(1, connections.listening.size)
|
||||
assertEquals(8080, connections.listening.single().localPort)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unavailable socket inspection is reported`() = runTest {
|
||||
val inspector = FakeInspector(
|
||||
result = ConnectionSnapshot.Unavailable("Permission denied"),
|
||||
)
|
||||
val viewModel = viewModel(inspector = inspector)
|
||||
viewModel.load(notes)
|
||||
|
||||
viewModel.refreshConnections()
|
||||
|
||||
assertEquals(
|
||||
ConnectionsUiState.Unavailable("Permission denied"),
|
||||
viewModel.uiState.value.connections,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `permission required is reported for usage`() = runTest {
|
||||
val usage = FakeUsageSource(result = UsageResult.PermissionRequired)
|
||||
val viewModel = viewModel(usage = usage)
|
||||
|
||||
viewModel.load(notes)
|
||||
|
||||
assertEquals(UsageUiState.PermissionRequired, viewModel.uiState.value.usage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selecting a range refreshes usage for that range`() = runTest {
|
||||
val usage = FakeUsageSource()
|
||||
val viewModel = viewModel(usage = usage)
|
||||
viewModel.load(notes)
|
||||
usage.requestedRanges.clear()
|
||||
|
||||
viewModel.selectUsageRange(UsageRange.WEEK)
|
||||
|
||||
assertEquals(UsageRange.WEEK, viewModel.uiState.value.usageRange)
|
||||
assertEquals(listOf(UsageRange.WEEK), usage.requestedRanges)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blocking delegates to the firewall controller`() = runTest {
|
||||
val controller = FakeFirewallController()
|
||||
val viewModel = viewModel(controller = controller)
|
||||
viewModel.load(notes)
|
||||
|
||||
viewModel.setBlocked(true)
|
||||
|
||||
assertEquals(listOf(notes.key to true), controller.blockedCalls)
|
||||
assertTrue(viewModel.uiState.value.isBlocked)
|
||||
assertFalse(viewModel.uiState.value.isBlocking)
|
||||
}
|
||||
|
||||
private fun viewModel(
|
||||
inspector: FakeInspector = FakeInspector(),
|
||||
usage: FakeUsageSource = FakeUsageSource(),
|
||||
controller: FakeFirewallController = FakeFirewallController(),
|
||||
) = AppDetailViewModel(
|
||||
connectionInspector = inspector,
|
||||
usageSource = usage,
|
||||
firewallController = controller,
|
||||
)
|
||||
|
||||
private fun socket(
|
||||
uid: Int,
|
||||
localPort: Int = 41234,
|
||||
remotePort: Int = 443,
|
||||
state: TcpState = TcpState.ESTABLISHED,
|
||||
remoteAddress: InetAddress? = InetAddress.getByAddress(byteArrayOf(1, 1, 1, 1)),
|
||||
) = SocketEntry(
|
||||
uid = uid,
|
||||
protocol = SocketProtocol.TCP,
|
||||
localAddress = InetAddress.getByAddress(byteArrayOf(127, 0, 0, 1)),
|
||||
localPort = localPort,
|
||||
remoteAddress = remoteAddress,
|
||||
remotePort = remotePort,
|
||||
state = state,
|
||||
)
|
||||
|
||||
private class FakeInspector(
|
||||
var result: ConnectionSnapshot = ConnectionSnapshot.Available(emptyList()),
|
||||
) : ConnectionInspector {
|
||||
override suspend fun snapshot(): ConnectionSnapshot = result
|
||||
}
|
||||
|
||||
private class FakeUsageSource(
|
||||
var result: UsageResult = UsageResult.Available(DataUsage.Zero),
|
||||
) : NetworkUsageSource {
|
||||
val requestedRanges = mutableListOf<UsageRange>()
|
||||
var lastUid: Int? = null
|
||||
|
||||
override fun hasUsageAccess(): Boolean = true
|
||||
|
||||
override suspend fun usageFor(uid: Int, range: UsageRange): UsageResult {
|
||||
lastUid = uid
|
||||
requestedRanges += range
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeFirewallController : FirewallController {
|
||||
private val stateFlow = MutableStateFlow(FirewallState())
|
||||
override val state: StateFlow<FirewallState> = stateFlow
|
||||
|
||||
override val availability = MutableStateFlow<FirewallAvailability>(
|
||||
FirewallAvailability.Ready(
|
||||
rulesTransport = RulesTransport.BINDER,
|
||||
chainTransport = ChainTransport.SHELL,
|
||||
),
|
||||
)
|
||||
|
||||
override val error = MutableStateFlow<String?>(null)
|
||||
override val drift = MutableStateFlow(FirewallDriftState())
|
||||
|
||||
val blockedCalls = mutableListOf<Pair<AppKey, Boolean>>()
|
||||
|
||||
override suspend fun setBlocked(app: InstalledApp, blocked: Boolean) {
|
||||
blockedCalls += app.key to blocked
|
||||
stateFlow.update { current ->
|
||||
current.updateActiveApps { apps ->
|
||||
if (blocked) apps + app.key else apps - app.key
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun addBlockedSelection(app: InstalledApp): Boolean {
|
||||
if (app.key in stateFlow.value.blockedApps) return false
|
||||
stateFlow.update { current ->
|
||||
current.updateActiveApps { apps -> apps + app.key }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun setEnabled(enabled: Boolean) {
|
||||
stateFlow.update { it.copy(enabled = enabled) }
|
||||
}
|
||||
|
||||
override suspend fun applyScheduledProfile(
|
||||
profileId: String,
|
||||
enableFirewall: Boolean?,
|
||||
): Boolean = true
|
||||
|
||||
override suspend fun restore() = Unit
|
||||
|
||||
override suspend fun checkForDrift() = Unit
|
||||
|
||||
override suspend fun refreshAvailability() = Unit
|
||||
|
||||
override fun requestPermission() = Unit
|
||||
|
||||
override suspend fun removeBlocked(key: AppKey) {
|
||||
stateFlow.update { current ->
|
||||
current.updateActiveApps { apps -> apps - key }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun removeBlockedForUser(userId: Int) {
|
||||
stateFlow.update { current ->
|
||||
current.updateActiveApps { apps ->
|
||||
apps.filterNot { it.userId == userId }.toSet()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun switchProfile(id: String) = Unit
|
||||
|
||||
override suspend fun createProfile(name: String) = false
|
||||
|
||||
override suspend fun duplicateProfile(sourceId: String, name: String) = false
|
||||
|
||||
override suspend fun renameProfile(id: String, name: String) = false
|
||||
|
||||
override suspend fun deleteProfile(id: String) = false
|
||||
|
||||
override suspend fun removeBlockedEverywhere(key: AppKey) = Unit
|
||||
|
||||
override suspend fun removeBlockedForUserEverywhere(userId: Int) = Unit
|
||||
|
||||
private fun FirewallState.updateActiveApps(
|
||||
transform: (Set<AppKey>) -> Set<AppKey>,
|
||||
): FirewallState {
|
||||
val activeId = activeProfile.id
|
||||
return copy(
|
||||
profiles = profiles.map { profile ->
|
||||
if (profile.id == activeId) {
|
||||
profile.copy(blockedApps = transform(profile.blockedApps))
|
||||
} else {
|
||||
profile
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package com.krrishg.kwall.ui.schedules
|
||||
|
||||
import com.krrishg.kwall.MainDispatcherRule
|
||||
import com.krrishg.kwall.data.InstalledApp
|
||||
import com.krrishg.kwall.firewall.AppKey
|
||||
import com.krrishg.kwall.firewall.ChainTransport
|
||||
import com.krrishg.kwall.firewall.DEFAULT_PROFILE_ID
|
||||
import com.krrishg.kwall.firewall.FirewallAvailability
|
||||
import com.krrishg.kwall.firewall.FirewallController
|
||||
import com.krrishg.kwall.firewall.FirewallDriftState
|
||||
import com.krrishg.kwall.firewall.FirewallState
|
||||
import com.krrishg.kwall.firewall.RuleProfile
|
||||
import com.krrishg.kwall.firewall.RulesTransport
|
||||
import com.krrishg.kwall.firewall.defaultRuleProfile
|
||||
import com.krrishg.kwall.scheduler.ProfileSchedule
|
||||
import com.krrishg.kwall.scheduler.ScheduleAlarms
|
||||
import com.krrishg.kwall.scheduler.ScheduleIssue
|
||||
import com.krrishg.kwall.scheduler.ScheduleRecurrence
|
||||
import com.krrishg.kwall.scheduler.ScheduleStore
|
||||
import com.krrishg.kwall.scheduler.SchedulerRuntime
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneOffset
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SchedulesViewModelTest {
|
||||
|
||||
@get:Rule
|
||||
val mainDispatcherRule = MainDispatcherRule()
|
||||
|
||||
private val work = RuleProfile(
|
||||
id = "work",
|
||||
name = "Work",
|
||||
createdAtMillis = 2L,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `saving an overlapping schedule is rejected`() = runTest {
|
||||
val existing = schedule(start = 9, end = 12)
|
||||
val store = FakeScheduleStore(listOf(existing))
|
||||
val viewModel = viewModel(store)
|
||||
|
||||
viewModel.save(
|
||||
schedule(
|
||||
id = "new",
|
||||
start = 11,
|
||||
end = 13,
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(ScheduleIssue.OVERLAP, viewModel.uiState.value.errorIssue)
|
||||
assertEquals(listOf(existing), store.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saving a valid schedule persists it`() = runTest {
|
||||
val store = FakeScheduleStore(emptyList())
|
||||
val viewModel = viewModel(store)
|
||||
val schedule = schedule(id = "new", start = 9, end = 12)
|
||||
|
||||
viewModel.save(schedule)
|
||||
|
||||
assertEquals(schedule, store.value.single())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enabling an overlapping schedule is rejected`() = runTest {
|
||||
val existing = schedule(start = 9, end = 12)
|
||||
val disabled = schedule(id = "new", start = 11, end = 13).copy(enabled = false)
|
||||
val store = FakeScheduleStore(listOf(existing, disabled))
|
||||
val viewModel = viewModel(store)
|
||||
|
||||
viewModel.setEnabled(disabled.id, true)
|
||||
|
||||
assertEquals(ScheduleIssue.OVERLAP, viewModel.uiState.value.errorIssue)
|
||||
assertTrue(store.value.none { it.id == disabled.id && it.enabled })
|
||||
}
|
||||
|
||||
private fun viewModel(store: ScheduleStore): SchedulesViewModel = SchedulesViewModel(
|
||||
scheduleStore = store,
|
||||
firewallController = FakeController(),
|
||||
alarms = FakeAlarms(),
|
||||
clock = Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC),
|
||||
zoneProvider = { ZoneOffset.UTC },
|
||||
)
|
||||
|
||||
private fun schedule(
|
||||
id: String = "schedule",
|
||||
start: Int,
|
||||
end: Int?,
|
||||
): ProfileSchedule = ProfileSchedule(
|
||||
id = id,
|
||||
name = "Work hours",
|
||||
profileId = work.id,
|
||||
fallbackProfileId = DEFAULT_PROFILE_ID,
|
||||
recurrence = ScheduleRecurrence.Once(LocalDate.of(2026, 1, 5)),
|
||||
startTime = LocalTime.of(start, 0),
|
||||
endTime = end?.let { LocalTime.of(it, 0) },
|
||||
createdAtMillis = 3L,
|
||||
)
|
||||
|
||||
private class FakeAlarms : ScheduleAlarms {
|
||||
override fun canScheduleExactAlarms(): Boolean = true
|
||||
override fun scheduleNext(at: Instant) = Unit
|
||||
override fun cancel() = Unit
|
||||
override fun requestExactAlarmPermission() = Unit
|
||||
}
|
||||
|
||||
private class FakeScheduleStore(
|
||||
initial: List<ProfileSchedule>,
|
||||
) : ScheduleStore {
|
||||
private val flow = MutableStateFlow(initial)
|
||||
override val schedules: StateFlow<List<ProfileSchedule>> = flow
|
||||
val value: List<ProfileSchedule> get() = flow.value
|
||||
private var runtime = SchedulerRuntime()
|
||||
|
||||
override suspend fun create(schedule: ProfileSchedule): Boolean {
|
||||
flow.update { it + schedule }
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun update(schedule: ProfileSchedule): Boolean {
|
||||
flow.update { schedules ->
|
||||
schedules.map { if (it.id == schedule.id) schedule else it }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun delete(id: String): Boolean {
|
||||
flow.update { schedules -> schedules.filterNot { it.id == id } }
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun setEnabled(id: String, enabled: Boolean): Boolean {
|
||||
flow.update { schedules ->
|
||||
schedules.map { if (it.id == id) it.copy(enabled = enabled) else it }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun readRuntime(): SchedulerRuntime = runtime
|
||||
|
||||
override suspend fun writeRuntime(runtime: SchedulerRuntime) {
|
||||
this.runtime = runtime
|
||||
}
|
||||
|
||||
override suspend fun clearRuntime() {
|
||||
runtime = SchedulerRuntime()
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeController : FirewallController {
|
||||
private val stateFlow = MutableStateFlow(
|
||||
FirewallState(
|
||||
profiles = listOf(
|
||||
defaultRuleProfile(createdAtMillis = 1L),
|
||||
RuleProfile(id = "work", name = "Work", createdAtMillis = 2L),
|
||||
),
|
||||
activeProfileId = DEFAULT_PROFILE_ID,
|
||||
),
|
||||
)
|
||||
override val state: StateFlow<FirewallState> = stateFlow
|
||||
override val availability: StateFlow<FirewallAvailability> = MutableStateFlow(
|
||||
FirewallAvailability.Ready(
|
||||
rulesTransport = RulesTransport.BINDER,
|
||||
chainTransport = ChainTransport.SHELL,
|
||||
),
|
||||
)
|
||||
override val error = MutableStateFlow<String?>(null)
|
||||
override val drift = MutableStateFlow(FirewallDriftState())
|
||||
|
||||
override suspend fun setBlocked(app: InstalledApp, blocked: Boolean) = Unit
|
||||
override suspend fun addBlockedSelection(app: InstalledApp): Boolean = false
|
||||
override suspend fun setEnabled(enabled: Boolean) = Unit
|
||||
override suspend fun applyScheduledProfile(
|
||||
profileId: String,
|
||||
enableFirewall: Boolean?,
|
||||
): Boolean = true
|
||||
override suspend fun restore() = Unit
|
||||
override suspend fun checkForDrift() = Unit
|
||||
override suspend fun refreshAvailability() = Unit
|
||||
override fun requestPermission() = Unit
|
||||
override suspend fun removeBlocked(key: AppKey) = Unit
|
||||
override suspend fun removeBlockedForUser(userId: Int) = Unit
|
||||
override suspend fun switchProfile(id: String) = Unit
|
||||
override suspend fun createProfile(name: String): Boolean = false
|
||||
override suspend fun duplicateProfile(sourceId: String, name: String): Boolean = false
|
||||
override suspend fun renameProfile(id: String, name: String): Boolean = false
|
||||
override suspend fun deleteProfile(id: String): Boolean = false
|
||||
override suspend fun removeBlockedEverywhere(key: AppKey) = Unit
|
||||
override suspend fun removeBlockedForUserEverywhere(userId: Int) = Unit
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.kotlin.android) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
alias(libs.plugins.kotlin.serialization) apply false
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8
|
||||
org.gradle.caching=true
|
||||
org.gradle.parallel=true
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
kotlin.code.style=official
|
||||
@@ -0,0 +1,42 @@
|
||||
[versions]
|
||||
agp = "8.13.2"
|
||||
kotlin = "2.2.10"
|
||||
coreKtx = "1.17.0"
|
||||
lifecycle = "2.9.4"
|
||||
activityCompose = "1.11.0"
|
||||
composeBom = "2025.09.01"
|
||||
coroutines = "1.10.2"
|
||||
datastore = "1.3.0"
|
||||
junit = "4.13.2"
|
||||
shizuku = "13.1.5"
|
||||
biometric = "1.1.0"
|
||||
fragment = "1.8.9"
|
||||
serialization = "1.9.0"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
||||
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
|
||||
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||
androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" }
|
||||
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" }
|
||||
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
|
||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||
shizuku-api = { group = "dev.rikka.shizuku", name = "api", version.ref = "shizuku" }
|
||||
shizuku-provider = { group = "dev.rikka.shizuku", name = "provider", version.ref = "shizuku" }
|
||||
androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" }
|
||||
androidx-fragment = { group = "androidx.fragment", name = "fragment", version.ref = "fragment" }
|
||||
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user