commit 8014cd4b1761818bc61e66bb828908885ad8eee0 Author: Krrish Ghimire Date: Tue Oct 27 19:20:27 2026 +0545 first version of firewall for android diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bdb111a --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.gradle/ +.kotlin/ +.idea/ +build/ +local.properties +*.iml +captures/ +.externalNativeBuild/ +.cxx/ +.project +.classpath +.settings/ +bin/ +.DS_Store diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..1caa155 --- /dev/null +++ b/app/build.gradle.kts @@ -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) +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..7a8743e --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/aidl/com/krrishg/kwall/firewall/IFirewallService.aidl b/app/src/main/aidl/com/krrishg/kwall/firewall/IFirewallService.aidl new file mode 100644 index 0000000..0b9bf14 --- /dev/null +++ b/app/src/main/aidl/com/krrishg/kwall/firewall/IFirewallService.aidl @@ -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; +} diff --git a/app/src/main/java/com/krrishg/kwall/AppChangeNotifier.kt b/app/src/main/java/com/krrishg/kwall/AppChangeNotifier.kt new file mode 100644 index 0000000..7e3824c --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/AppChangeNotifier.kt @@ -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( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + val events: SharedFlow = _events.asSharedFlow() + + fun notifyChange() { + _events.tryEmit(Unit) + } +} diff --git a/app/src/main/java/com/krrishg/kwall/AppContainer.kt b/app/src/main/java/com/krrishg/kwall/AppContainer.kt new file mode 100644 index 0000000..8053e8c --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/AppContainer.kt @@ -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, + user: UserHandle, + replacing: Boolean, + ) { + appChangeNotifier.notifyChange() + } + + override fun onPackagesUnavailable( + packageNames: Array, + 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() +} diff --git a/app/src/main/java/com/krrishg/kwall/KwallApplication.kt b/app/src/main/java/com/krrishg/kwall/KwallApplication.kt new file mode 100644 index 0000000..481fa6d --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/KwallApplication.kt @@ -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) + } +} diff --git a/app/src/main/java/com/krrishg/kwall/MainActivity.kt b/app/src/main/java/com/krrishg/kwall/MainActivity.kt new file mode 100644 index 0000000..1d92285 --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/MainActivity.kt @@ -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)) + } + } + } + } +} diff --git a/app/src/main/java/com/krrishg/kwall/data/AppRepository.kt b/app/src/main/java/com/krrishg/kwall/data/AppRepository.kt new file mode 100644 index 0000000..a437549 --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/data/AppRepository.kt @@ -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 +} + +interface AppSource { + suspend fun loadInstalledApps(): List +} + +class ProfileAwareAppRepository( + private val sources: List, + private val ownPackageName: String, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : AppRepository { + + override suspend fun loadInstalledApps(): List = withContext(ioDispatcher) { + val collator = Collator.getInstance() + val loaded = mutableListOf() + 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 = + 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 { + 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 { + 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 diff --git a/app/src/main/java/com/krrishg/kwall/data/InstalledApp.kt b/app/src/main/java/com/krrishg/kwall/data/InstalledApp.kt new file mode 100644 index 0000000..2a479c3 --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/data/InstalledApp.kt @@ -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) +} diff --git a/app/src/main/java/com/krrishg/kwall/firewall/AppKey.kt b/app/src/main/java/com/krrishg/kwall/firewall/AppKey.kt new file mode 100644 index 0000000..a0cdf17 --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/firewall/AppKey.kt @@ -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)) + } + } +} diff --git a/app/src/main/java/com/krrishg/kwall/firewall/AppUidResolver.kt b/app/src/main/java/com/krrishg/kwall/firewall/AppUidResolver.kt new file mode 100644 index 0000000..c7f9498 --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/firewall/AppUidResolver.kt @@ -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 + } +} diff --git a/app/src/main/java/com/krrishg/kwall/firewall/AutoBlockNewApps.kt b/app/src/main/java/com/krrishg/kwall/firewall/AutoBlockNewApps.kt new file mode 100644 index 0000000..e975fba --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/firewall/AutoBlockNewApps.kt @@ -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 + } +} diff --git a/app/src/main/java/com/krrishg/kwall/firewall/BinderFirewallClient.kt b/app/src/main/java/com/krrishg/kwall/firewall/BinderFirewallClient.kt new file mode 100644 index 0000000..975aec5 --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/firewall/BinderFirewallClient.kt @@ -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? = 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 { + 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 { 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 + } +} diff --git a/app/src/main/java/com/krrishg/kwall/firewall/Blockability.kt b/app/src/main/java/com/krrishg/kwall/firewall/Blockability.kt new file mode 100644 index 0000000..1d4667e --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/firewall/Blockability.kt @@ -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 = setOf(SHIZUKU_PACKAGE) + +fun blockabilityOf( + app: InstalledApp, + uidCounts: Map, + firewallPackages: Set, +): 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, + packageCounts: Map, + rulesTransport: RulesTransport?, + firewallPackages: Set, +): 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 diff --git a/app/src/main/java/com/krrishg/kwall/firewall/DataStoreFirewallStateStore.kt b/app/src/main/java/com/krrishg/kwall/firewall/DataStoreFirewallStateStore.kt new file mode 100644 index 0000000..737642a --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/firewall/DataStoreFirewallStateStore.kt @@ -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 = 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 = + 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 { + val profiles = ids.mapNotNull { id -> readProfile(id) } + return profiles.minWithOrNull( + compareBy { 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 { 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") + } +} diff --git a/app/src/main/java/com/krrishg/kwall/firewall/FirewallBackend.kt b/app/src/main/java/com/krrishg/kwall/firewall/FirewallBackend.kt new file mode 100644 index 0000000..3bd5a7f --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/firewall/FirewallBackend.kt @@ -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 + + suspend fun refreshAvailability() + + suspend fun setChainEnabled(enabled: Boolean) + + suspend fun block(app: BlockedApp) + + suspend fun unblock(app: BlockedApp) + + suspend fun reapply(apps: Set) + + suspend fun clearRules() + + suspend fun unblockAll(apps: Set) + + suspend fun observe(apps: Set): FirewallObservation + + fun requestPermission() +} diff --git a/app/src/main/java/com/krrishg/kwall/firewall/FirewallCommands.kt b/app/src/main/java/com/krrishg/kwall/firewall/FirewallCommands.kt new file mode 100644 index 0000000..1f80ec2 --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/firewall/FirewallCommands.kt @@ -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" + } +} diff --git a/app/src/main/java/com/krrishg/kwall/firewall/FirewallController.kt b/app/src/main/java/com/krrishg/kwall/firewall/FirewallController.kt new file mode 100644 index 0000000..ca8dc6d --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/firewall/FirewallController.kt @@ -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 + val availability: StateFlow + val error: StateFlow + val drift: StateFlow + + 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) +} diff --git a/app/src/main/java/com/krrishg/kwall/firewall/FirewallDrift.kt b/app/src/main/java/com/krrishg/kwall/firewall/FirewallDrift.kt new file mode 100644 index 0000000..ca9e254 --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/firewall/FirewallDrift.kt @@ -0,0 +1,62 @@ +package com.krrishg.kwall.firewall + +data class FirewallObservation( + val chainEnabled: Boolean? = null, + val appStates: Map = emptyMap(), +) { + val hasAnyData: Boolean get() = chainEnabled != null || appStates.isNotEmpty() + + fun covers(expected: Set): Boolean = + chainEnabled != null && expected.all { it.key in appStates } +} + +data class FirewallDrift( + val chainMismatch: Boolean = false, + val missingRules: Set = emptySet(), + val staleRules: Set = 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, + candidates: Set, + 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, + ) + } +} diff --git a/app/src/main/java/com/krrishg/kwall/firewall/FirewallException.kt b/app/src/main/java/com/krrishg/kwall/firewall/FirewallException.kt new file mode 100644 index 0000000..02a29a9 --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/firewall/FirewallException.kt @@ -0,0 +1,6 @@ +package com.krrishg.kwall.firewall + +class FirewallException( + message: String, + cause: Throwable? = null, +) : Exception(message, cause) diff --git a/app/src/main/java/com/krrishg/kwall/firewall/FirewallManager.kt b/app/src/main/java/com/krrishg/kwall/firewall/FirewallManager.kt new file mode 100644 index 0000000..4cc4bdb --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/firewall/FirewallManager.kt @@ -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(null) + private val _drift = MutableStateFlow(FirewallDriftState()) + + override val error: StateFlow = _error.asStateFlow() + override val drift: StateFlow = _drift.asStateFlow() + override val state: StateFlow = store.state + .stateIn(scope, SharingStarted.Eagerly, FirewallState()) + override val availability: StateFlow = 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, + candidates: Set, + ) { + 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 { 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, + newBlockedApps: Set, + 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, + ): 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) { + 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): Set = 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 + } +} diff --git a/app/src/main/java/com/krrishg/kwall/firewall/FirewallObservationParser.kt b/app/src/main/java/com/krrishg/kwall/firewall/FirewallObservationParser.kt new file mode 100644 index 0000000..05671d7 --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/firewall/FirewallObservationParser.kt @@ -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:" +} diff --git a/app/src/main/java/com/krrishg/kwall/firewall/FirewallState.kt b/app/src/main/java/com/krrishg/kwall/firewall/FirewallState.kt new file mode 100644 index 0000000..8e742cc --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/firewall/FirewallState.kt @@ -0,0 +1,15 @@ +package com.krrishg.kwall.firewall + +data class FirewallState( + val enabled: Boolean = false, + val profiles: List = listOf(defaultRuleProfile()), + val activeProfileId: String = DEFAULT_PROFILE_ID, +) { + val activeProfile: RuleProfile + get() = profiles.firstOrNull { it.id == activeProfileId } + ?: profiles.firstOrNull() + ?: defaultRuleProfile() + + val blockedApps: Set + get() = activeProfile.blockedApps +} diff --git a/app/src/main/java/com/krrishg/kwall/firewall/FirewallStateStore.kt b/app/src/main/java/com/krrishg/kwall/firewall/FirewallStateStore.kt new file mode 100644 index 0000000..f242973 --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/firewall/FirewallStateStore.kt @@ -0,0 +1,25 @@ +package com.krrishg.kwall.firewall + +import kotlinx.coroutines.flow.Flow + +interface FirewallStateStore { + val state: Flow + + 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) +} diff --git a/app/src/main/java/com/krrishg/kwall/firewall/FirewallUserService.kt b/app/src/main/java/com/krrishg/kwall/firewall/FirewallUserService.kt new file mode 100644 index 0000000..ebcbac0 --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/firewall/FirewallUserService.kt @@ -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 { + 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 + } +} diff --git a/app/src/main/java/com/krrishg/kwall/firewall/RuleProfile.kt b/app/src/main/java/com/krrishg/kwall/firewall/RuleProfile.kt new file mode 100644 index 0000000..6bc7d0c --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/firewall/RuleProfile.kt @@ -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 = emptySet(), + val createdAtMillis: Long = 0L, +) + +enum class ProfileNameIssue { + BLANK, + TOO_LONG, + DUPLICATE, +} + +fun defaultRuleProfile( + blockedApps: Set = emptySet(), + createdAtMillis: Long = 0L, +): RuleProfile = RuleProfile( + id = DEFAULT_PROFILE_ID, + name = DEFAULT_PROFILE_NAME, + blockedApps = blockedApps, + createdAtMillis = createdAtMillis, +) + +fun validateProfileName( + name: String, + profiles: List, + 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 +} diff --git a/app/src/main/java/com/krrishg/kwall/firewall/ShellExecutor.kt b/app/src/main/java/com/krrishg/kwall/firewall/ShellExecutor.kt new file mode 100644 index 0000000..611adc9 --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/firewall/ShellExecutor.kt @@ -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 +} diff --git a/app/src/main/java/com/krrishg/kwall/firewall/ShizukuFirewallBackend.kt b/app/src/main/java/com/krrishg/kwall/firewall/ShizukuFirewallBackend.kt new file mode 100644 index 0000000..f6c508d --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/firewall/ShizukuFirewallBackend.kt @@ -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.Checking) + override val availability: StateFlow = _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): 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): 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): 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 + } +} diff --git a/app/src/main/java/com/krrishg/kwall/network/ConnectionInspector.kt b/app/src/main/java/com/krrishg/kwall/network/ConnectionInspector.kt new file mode 100644 index 0000000..5ab1812 --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/network/ConnectionInspector.kt @@ -0,0 +1,11 @@ +package com.krrishg.kwall.network + +sealed interface ConnectionSnapshot { + data class Available(val sockets: List) : ConnectionSnapshot + + data class Unavailable(val reason: String) : ConnectionSnapshot +} + +interface ConnectionInspector { + suspend fun snapshot(): ConnectionSnapshot +} diff --git a/app/src/main/java/com/krrishg/kwall/network/NetworkStatsUsageSource.kt b/app/src/main/java/com/krrishg/kwall/network/NetworkStatsUsageSource.kt new file mode 100644 index 0000000..cece534 --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/network/NetworkStatsUsageSource.kt @@ -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) + } + } +} diff --git a/app/src/main/java/com/krrishg/kwall/network/NetworkUsageSource.kt b/app/src/main/java/com/krrishg/kwall/network/NetworkUsageSource.kt new file mode 100644 index 0000000..1de904a --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/network/NetworkUsageSource.kt @@ -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 +} diff --git a/app/src/main/java/com/krrishg/kwall/network/ProcNetConnectionInspector.kt b/app/src/main/java/com/krrishg/kwall/network/ProcNetConnectionInspector.kt new file mode 100644 index 0000000..3ff5f1a --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/network/ProcNetConnectionInspector.kt @@ -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() + 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 { + val sections = linkedMapOf() + 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" + } +} diff --git a/app/src/main/java/com/krrishg/kwall/network/ProcNetParser.kt b/app/src/main/java/com/krrishg/kwall/network/ProcNetParser.kt new file mode 100644 index 0000000..ce654aa --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/network/ProcNetParser.kt @@ -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 = + 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, + ) +} diff --git a/app/src/main/java/com/krrishg/kwall/network/SocketEntry.kt b/app/src/main/java/com/krrishg/kwall/network/SocketEntry.kt new file mode 100644 index 0000000..31c064e --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/network/SocketEntry.kt @@ -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 +} diff --git a/app/src/main/java/com/krrishg/kwall/notifications/FirewallNotificationContent.kt b/app/src/main/java/com/krrishg/kwall/notifications/FirewallNotificationContent.kt new file mode 100644 index 0000000..b189422 --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/notifications/FirewallNotificationContent.kt @@ -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, + ) +} diff --git a/app/src/main/java/com/krrishg/kwall/notifications/FirewallNotifications.kt b/app/src/main/java/com/krrishg/kwall/notifications/FirewallNotifications.kt new file mode 100644 index 0000000..93bc696 --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/notifications/FirewallNotifications.kt @@ -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 +} diff --git a/app/src/main/java/com/krrishg/kwall/notifications/FirewallStatusNotifier.kt b/app/src/main/java/com/krrishg/kwall/notifications/FirewallStatusNotifier.kt new file mode 100644 index 0000000..98a2f8b --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/notifications/FirewallStatusNotifier.kt @@ -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, + ), + ) + } + } +} diff --git a/app/src/main/java/com/krrishg/kwall/receivers/BootReceiver.kt b/app/src/main/java/com/krrishg/kwall/receivers/BootReceiver.kt new file mode 100644 index 0000000..8f00724 --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/receivers/BootReceiver.kt @@ -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" + } +} diff --git a/app/src/main/java/com/krrishg/kwall/receivers/FirewallNotificationReceiver.kt b/app/src/main/java/com/krrishg/kwall/receivers/FirewallNotificationReceiver.kt new file mode 100644 index 0000000..267f842 --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/receivers/FirewallNotificationReceiver.kt @@ -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" + } +} diff --git a/app/src/main/java/com/krrishg/kwall/receivers/PackageAddedReceiver.kt b/app/src/main/java/com/krrishg/kwall/receivers/PackageAddedReceiver.kt new file mode 100644 index 0000000..3cc0cfb --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/receivers/PackageAddedReceiver.kt @@ -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 + } +} diff --git a/app/src/main/java/com/krrishg/kwall/receivers/ScheduleAlarmReceiver.kt b/app/src/main/java/com/krrishg/kwall/receivers/ScheduleAlarmReceiver.kt new file mode 100644 index 0000000..9b3abfe --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/receivers/ScheduleAlarmReceiver.kt @@ -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" + } +} diff --git a/app/src/main/java/com/krrishg/kwall/receivers/ScheduleMaintenanceReceiver.kt b/app/src/main/java/com/krrishg/kwall/receivers/ScheduleMaintenanceReceiver.kt new file mode 100644 index 0000000..698f548 --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/receivers/ScheduleMaintenanceReceiver.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/krrishg/kwall/scheduler/DataStoreScheduleStore.kt b/app/src/main/java/com/krrishg/kwall/scheduler/DataStoreScheduleStore.kt new file mode 100644 index 0000000..cd1cb89 --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/scheduler/DataStoreScheduleStore.kt @@ -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> = 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, + ) { + if (this == null) preferences.remove(key) else preferences[key] = this + } + + private fun Long?.putOrRemove( + preferences: MutablePreferences, + key: Preferences.Key, + ) { + if (this == null) preferences.remove(key) else preferences[key] = this + } + + private fun Boolean?.putOrRemove( + preferences: MutablePreferences, + key: Preferences.Key, + ) { + 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") + } +} diff --git a/app/src/main/java/com/krrishg/kwall/scheduler/ProfileSchedule.kt b/app/src/main/java/com/krrishg/kwall/scheduler/ProfileSchedule.kt new file mode 100644 index 0000000..eeb13c8 --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/scheduler/ProfileSchedule.kt @@ -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, + 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, +): List { + val issues = mutableListOf() + 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 +} diff --git a/app/src/main/java/com/krrishg/kwall/scheduler/ProfileScheduler.kt b/app/src/main/java/com/krrishg/kwall/scheduler/ProfileScheduler.kt new file mode 100644 index 0000000..2f58eb2 --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/scheduler/ProfileScheduler.kt @@ -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, + ) { + 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" + } +} diff --git a/app/src/main/java/com/krrishg/kwall/scheduler/ScheduleAlarms.kt b/app/src/main/java/com/krrishg/kwall/scheduler/ScheduleAlarms.kt new file mode 100644 index 0000000..f56a14d --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/scheduler/ScheduleAlarms.kt @@ -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 + } +} diff --git a/app/src/main/java/com/krrishg/kwall/scheduler/ScheduleEngine.kt b/app/src/main/java/com/krrishg/kwall/scheduler/ScheduleEngine.kt new file mode 100644 index 0000000..f6664b3 --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/scheduler/ScheduleEngine.kt @@ -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, + 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, + 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, + availableProfileIds: Set, + now: Instant, + zone: ZoneId, + ): List { + 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 { + if (to.isBefore(from)) return emptyList() + val firstDate = from.atZone(zone).toLocalDate().minusDays(2) + var date = nextOccurrenceDate(schedule, firstDate) ?: return emptyList() + val windows = mutableListOf() + 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 = 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, + otherWindows: List, + ): 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()) +} diff --git a/app/src/main/java/com/krrishg/kwall/scheduler/ScheduleRecord.kt b/app/src/main/java/com/krrishg/kwall/scheduler/ScheduleRecord.kt new file mode 100644 index 0000000..85da44f --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/scheduler/ScheduleRecord.kt @@ -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 = 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 = 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): String = + json.encodeToString( + ScheduleRecordList(schedules = schedules.map { it.toRecord() }), + ) + + fun decode(raw: String?): List { + if (raw.isNullOrBlank()) return emptyList() + return runCatching { + json.decodeFromString(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" +} diff --git a/app/src/main/java/com/krrishg/kwall/scheduler/ScheduleStore.kt b/app/src/main/java/com/krrishg/kwall/scheduler/ScheduleStore.kt new file mode 100644 index 0000000..751f7ab --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/scheduler/ScheduleStore.kt @@ -0,0 +1,21 @@ +package com.krrishg.kwall.scheduler + +import kotlinx.coroutines.flow.Flow + +interface ScheduleStore { + val schedules: Flow> + + 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() +} diff --git a/app/src/main/java/com/krrishg/kwall/scheduler/SchedulerRuntime.kt b/app/src/main/java/com/krrishg/kwall/scheduler/SchedulerRuntime.kt new file mode 100644 index 0000000..d269090 --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/scheduler/SchedulerRuntime.kt @@ -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, +) diff --git a/app/src/main/java/com/krrishg/kwall/settings/AppSettingsStore.kt b/app/src/main/java/com/krrishg/kwall/settings/AppSettingsStore.kt new file mode 100644 index 0000000..d67d761 --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/settings/AppSettingsStore.kt @@ -0,0 +1,12 @@ +package com.krrishg.kwall.settings + +import kotlinx.coroutines.flow.Flow + +interface AppSettingsStore { + val appLockEnabled: Flow + val autoBlockNewApps: Flow + + suspend fun setAppLockEnabled(enabled: Boolean) + + suspend fun setAutoBlockNewApps(enabled: Boolean) +} diff --git a/app/src/main/java/com/krrishg/kwall/settings/DataStoreAppSettingsStore.kt b/app/src/main/java/com/krrishg/kwall/settings/DataStoreAppSettingsStore.kt new file mode 100644 index 0000000..db3fe96 --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/settings/DataStoreAppSettingsStore.kt @@ -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 = preferences + .map { values -> values[APP_LOCK_ENABLED_KEY] ?: true } + + override val autoBlockNewApps: Flow = 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") + } +} diff --git a/app/src/main/java/com/krrishg/kwall/shizuku/ShizukuShellExecutor.kt b/app/src/main/java/com/krrishg/kwall/shizuku/ShizukuShellExecutor.kt new file mode 100644 index 0000000..9890e1b --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/shizuku/ShizukuShellExecutor.kt @@ -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, + environment: Array?, + 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::class.java, + Array::class.java, + String::class.java, + ).apply { isAccessible = true } + }.getOrNull() + } + } +} diff --git a/app/src/main/java/com/krrishg/kwall/ui/apps/AppListScreen.kt b/app/src/main/java/com/krrishg/kwall/ui/apps/AppListScreen.kt new file mode 100644 index 0000000..048b4aa --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/ui/apps/AppListScreen.kt @@ -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(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(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, + 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 = {}, + ) + } +} diff --git a/app/src/main/java/com/krrishg/kwall/ui/apps/AppListViewModel.kt b/app/src/main/java/com/krrishg/kwall/ui/apps/AppListViewModel.kt new file mode 100644 index 0000000..f8da641 --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/ui/apps/AppListViewModel.kt @@ -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 = 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, + val availability: FirewallAvailability, + val error: String?, + val drift: FirewallDriftState, + val profiles: List, + val activeProfileId: String, +) + +data class AppListUiState( + val apps: List = 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 = emptyList() + private var uidCounts: Map = emptyMap() + private var packageCounts: Map = emptyMap() + private var loadJob: Job? = null + + private val _uiState = MutableStateFlow(AppListUiState()) + val uiState: StateFlow = _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 { + 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, + ) + } + } + } +} diff --git a/app/src/main/java/com/krrishg/kwall/ui/apps/FirewallInfoDialog.kt b/app/src/main/java/com/krrishg/kwall/ui/apps/FirewallInfoDialog.kt new file mode 100644 index 0000000..86662d4 --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/ui/apps/FirewallInfoDialog.kt @@ -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 + } diff --git a/app/src/main/java/com/krrishg/kwall/ui/components/AppIcon.kt b/app/src/main/java/com/krrishg/kwall/ui/components/AppIcon.kt new file mode 100644 index 0000000..12ece70 --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/ui/components/AppIcon.kt @@ -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, + 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(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) } + } + } +} diff --git a/app/src/main/java/com/krrishg/kwall/ui/components/Badges.kt b/app/src/main/java/com/krrishg/kwall/ui/components/Badges.kt new file mode 100644 index 0000000..924a364 --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/ui/components/Badges.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/krrishg/kwall/ui/components/BlockabilityReason.kt b/app/src/main/java/com/krrishg/kwall/ui/components/BlockabilityReason.kt new file mode 100644 index 0000000..9ea7544 --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/ui/components/BlockabilityReason.kt @@ -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) +} diff --git a/app/src/main/java/com/krrishg/kwall/ui/detail/AppDetailScreen.kt b/app/src/main/java/com/krrishg/kwall/ui/detail/AppDetailScreen.kt new file mode 100644 index 0000000..562d46d --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/ui/detail/AppDetailScreen.kt @@ -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 = {}, + ) + } +} diff --git a/app/src/main/java/com/krrishg/kwall/ui/detail/AppDetailViewModel.kt b/app/src/main/java/com/krrishg/kwall/ui/detail/AppDetailViewModel.kt new file mode 100644 index 0000000..4c62958 --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/ui/detail/AppDetailViewModel.kt @@ -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, + val listening: List, + ) : 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 = _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 { 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, + ) + } + } + } +} diff --git a/app/src/main/java/com/krrishg/kwall/ui/profiles/ProfilesDialog.kt b/app/src/main/java/com/krrishg/kwall/ui/profiles/ProfilesDialog.kt new file mode 100644 index 0000000..50589c0 --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/ui/profiles/ProfilesDialog.kt @@ -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, + 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(null) } + var deleteTarget by remember { mutableStateOf(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, + 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)) + } + }, + ) +} diff --git a/app/src/main/java/com/krrishg/kwall/ui/schedules/SchedulesScreen.kt b/app/src/main/java/com/krrishg/kwall/ui/schedules/SchedulesScreen.kt new file mode 100644 index 0000000..2ace6e4 --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/ui/schedules/SchedulesScreen.kt @@ -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(null) } + var deleteTarget by remember { mutableStateOf(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, + activeProfileId: String, + existingSchedules: List, + 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(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, + 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) diff --git a/app/src/main/java/com/krrishg/kwall/ui/schedules/SchedulesViewModel.kt b/app/src/main/java/com/krrishg/kwall/ui/schedules/SchedulesViewModel.kt new file mode 100644 index 0000000..ef428b0 --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/ui/schedules/SchedulesViewModel.kt @@ -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, +) + +data class SchedulesUiState( + val schedules: List = emptyList(), + val profiles: List = 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 = _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 { 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, + ) + } + } + } +} diff --git a/app/src/main/java/com/krrishg/kwall/ui/settings/AppSettingsDialog.kt b/app/src/main/java/com/krrishg/kwall/ui/settings/AppSettingsDialog.kt new file mode 100644 index 0000000..c9224f5 --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/ui/settings/AppSettingsDialog.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/krrishg/kwall/ui/theme/Theme.kt b/app/src/main/java/com/krrishg/kwall/ui/theme/Theme.kt new file mode 100644 index 0000000..612c7e1 --- /dev/null +++ b/app/src/main/java/com/krrishg/kwall/ui/theme/Theme.kt @@ -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) +} diff --git a/app/src/main/res/drawable-nodpi/ic_launcher_foreground.png b/app/src/main/res/drawable-nodpi/ic_launcher_foreground.png new file mode 100644 index 0000000..ba4298c Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_launcher_monochrome.png b/app/src/main/res/drawable-nodpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..bb991a4 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_launcher_monochrome.png differ diff --git a/app/src/main/res/drawable/ic_notification_open.xml b/app/src/main/res/drawable/ic_notification_open.xml new file mode 100644 index 0000000..b70a55c --- /dev/null +++ b/app/src/main/res/drawable/ic_notification_open.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_notification_pause.xml b/app/src/main/res/drawable/ic_notification_pause.xml new file mode 100644 index 0000000..08e55f7 --- /dev/null +++ b/app/src/main/res/drawable/ic_notification_pause.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_notification_play.xml b/app/src/main/res/drawable/ic_notification_play.xml new file mode 100644 index 0000000..ecb7c2f --- /dev/null +++ b/app/src/main/res/drawable/ic_notification_play.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..c78bee3 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..c78bee3 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..58a6426 --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FF101013 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..8737b56 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,213 @@ + + + Kwall + Unlock Kwall + Use your device PIN, pattern, password, or biometrics + Kwall is locked + Authenticate with your device screen lock to continue. + Unlock + Set up a screen lock + Kwall is protected by your device screen lock. Set a PIN, pattern, password, or biometrics to open the app. + Open security settings + Turn off app lock + Apps + Settings + Require device unlock + Ask for your device PIN, pattern, password, or biometrics when opening Kwall. + Auto-block new apps + Select newly installed user apps for blocking in the active profile. The firewall switch controls when those rules take effect. + Settings + Profiles + Default + New profile + Profile actions + Rename + Duplicate + Delete + Switch profile. Active profile: %1$s + New profile + Rename profile + Duplicate profile + Profile name + Enter a profile name + Profile name must be 40 characters or fewer + A profile with this name already exists + %1$s copy + Delete profile? + Delete \"%1$s\"? Its blocked app selections will be removed. If this is the active profile, another profile becomes active. + Save + Cancel + Close + System + Work + Refresh + Search apps + Clear search + All + Blocked + Unblocked + Show system apps + Show details + Hide details + Loading apps + Could not load apps + Retry + No user apps found + Turn on system apps to see everything installed on this device. + No installed apps found + No apps matched this filter. + + %1$d app + %1$d apps + + + %1$d blocked app + %1$d blocked apps + + + Firewall + Firewall status and messages + Block internet for selected apps + Checking Shizuku… + Firewall off + No apps blocked + Requires Android 13 or newer + Shizuku is not installed + Shizuku is not running + Shizuku permission is required + This ROM does not support per-app blocking + Install Shizuku + Open Shizuku + Grant permission + Blocking stops after a reboot. Once Shizuku is running, Kwall reapplies your selections automatically. + Turn the firewall off before uninstalling Kwall. If Shizuku is not running during uninstall, existing rules stay active until the next reboot. + Selections are paused while the firewall is off. + Existing rules stay active until reboot or until the firewall is turned off. + Consistency check running… + Consistency check passed + Consistency check fixed changed rules + Consistency check found changes that could not be fixed + Consistency check is unavailable on this device + Kwall checks its rules automatically and repairs changes. + Firewall status + Auto-blocked apps + App blocked automatically + %1$s was selected for blocking because it was just installed. + Firewall on + Firewall needs attention + No apps selected + Could not update firewall: %1$s + Pause + Resume + Open + Firewall paused + System UID apps cannot be blocked + Shares a UID with other apps + No internet permission + Required by the firewall + This app is only in the work profile; per-profile blocking needs the binder path + Could not resolve this app + This device only supports package-wide blocking; other profiles will also be blocked + + %1$d app blocked + %1$d apps blocked + + + %1$d app selected + %1$d apps selected + + + Back + Refresh network information + Internet access + Block all internet traffic for this app + Start Shizuku and grant permission to change this setting. + Data usage + Today + 7 days + 30 days + Downloaded + Uploaded + Loading usage + Allow usage access to see data used by this app. + Grant usage access + Connections + Loading connections + No active connections. + Live connections are unavailable on this device. + Listening on port %1$d + Listening ports + Local address %1$s + All interfaces + Unknown endpoint + Active + Existing connections may stay open after blocking this app. + + %1$d active + %1$d active + + + Back + Schedules + Schedules + Add schedule + Edit + Delete + Delete schedule? + Delete \"%1$s\"? + New schedule + Edit schedule + Schedule name + Repeat + Once + Daily + Weekly + Monthly + Start date: %1$s + Without an end time, this profile stays active until another schedule starts. + Every N days + Every N weeks + Every N months + Days of week + Day of month + Starts at %1$s + End time + Ends at %1$s + Schedule enabled + Profile + Revert to + Select a profile + Profile missing + Next: %1$s + Loading schedules + No schedules yet. Add one to activate a profile automatically. + Create a profile before adding a schedule. + Exact alarms are disabled. Scheduled profiles may start a few minutes late. + Allow + Enter a schedule name + Schedule name is too long + Select a profile that still exists + Select a fallback profile that still exists + The interval must be at least 1 + Select at least one weekday + Choose a day between 1 and 31 + Choose a date and time in the future + This schedule overlaps another schedule + Once on %1$s at %2$s + Every day at %1$s + + Every %1$d day at %2$s + Every %1$d days at %2$s + + Every week on %1$s at %2$s + + Every %1$d week on %2$s at %3$s + Every %1$d weeks on %2$s at %3$s + + Day %1$d of every month at %2$s + + Day %1$d of every %2$d month at %3$s + Day %1$d of every %2$d months at %3$s + + %1$s until %2$s + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..4ce935b --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + +