first version of firewall for android

This commit is contained in:
Krrish Ghimire
2026-10-27 19:20:27 +05:45
commit 8014cd4b17
105 changed files with 13836 additions and 0 deletions
+78
View File
@@ -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)
}
+91
View File
@@ -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>
+213
View File
@@ -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>
+4
View File
@@ -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
}
}