From 8014cd4b1761818bc61e66bb828908885ad8eee0 Mon Sep 17 00:00:00 2001 From: Krrish Ghimire Date: Tue, 27 Oct 2026 19:20:27 +0545 Subject: [PATCH] first version of firewall for android --- .gitignore | 14 + app/build.gradle.kts | 78 ++ app/src/main/AndroidManifest.xml | 91 ++ .../kwall/firewall/IFirewallService.aidl | 12 + .../com/krrishg/kwall/AppChangeNotifier.kt | 20 + .../java/com/krrishg/kwall/AppContainer.kt | 237 ++++ .../com/krrishg/kwall/KwallApplication.kt | 22 + .../java/com/krrishg/kwall/MainActivity.kt | 404 ++++++ .../com/krrishg/kwall/data/AppRepository.kt | 125 ++ .../com/krrishg/kwall/data/InstalledApp.kt | 17 + .../java/com/krrishg/kwall/firewall/AppKey.kt | 17 + .../krrishg/kwall/firewall/AppUidResolver.kt | 35 + .../kwall/firewall/AutoBlockNewApps.kt | 44 + .../kwall/firewall/BinderFirewallClient.kt | 140 +++ .../krrishg/kwall/firewall/Blockability.kt | 61 + .../firewall/DataStoreFirewallStateStore.kt | 266 ++++ .../krrishg/kwall/firewall/FirewallBackend.kt | 47 + .../kwall/firewall/FirewallCommands.kt | 33 + .../kwall/firewall/FirewallController.kt | 45 + .../krrishg/kwall/firewall/FirewallDrift.kt | 62 + .../kwall/firewall/FirewallException.kt | 6 + .../krrishg/kwall/firewall/FirewallManager.kt | 507 ++++++++ .../firewall/FirewallObservationParser.kt | 41 + .../krrishg/kwall/firewall/FirewallState.kt | 15 + .../kwall/firewall/FirewallStateStore.kt | 25 + .../kwall/firewall/FirewallUserService.kt | 237 ++++ .../com/krrishg/kwall/firewall/RuleProfile.kt | 42 + .../krrishg/kwall/firewall/ShellExecutor.kt | 41 + .../kwall/firewall/ShizukuFirewallBackend.kt | 312 +++++ .../kwall/network/ConnectionInspector.kt | 11 + .../kwall/network/NetworkStatsUsageSource.kt | 77 ++ .../kwall/network/NetworkUsageSource.kt | 52 + .../network/ProcNetConnectionInspector.kt | 74 ++ .../krrishg/kwall/network/ProcNetParser.kt | 67 + .../com/krrishg/kwall/network/SocketEntry.kt | 49 + .../FirewallNotificationContent.kt | 92 ++ .../notifications/FirewallNotifications.kt | 220 ++++ .../notifications/FirewallStatusNotifier.kt | 65 + .../krrishg/kwall/receivers/BootReceiver.kt | 43 + .../receivers/FirewallNotificationReceiver.kt | 41 + .../kwall/receivers/PackageAddedReceiver.kt | 41 + .../kwall/receivers/ScheduleAlarmReceiver.kt | 34 + .../receivers/ScheduleMaintenanceReceiver.kt | 44 + .../kwall/scheduler/DataStoreScheduleStore.kt | 158 +++ .../kwall/scheduler/ProfileSchedule.kt | 83 ++ .../kwall/scheduler/ProfileScheduler.kt | 133 ++ .../krrishg/kwall/scheduler/ScheduleAlarms.kt | 90 ++ .../krrishg/kwall/scheduler/ScheduleEngine.kt | 377 ++++++ .../krrishg/kwall/scheduler/ScheduleRecord.kt | 128 ++ .../krrishg/kwall/scheduler/ScheduleStore.kt | 21 + .../kwall/scheduler/SchedulerRuntime.kt | 9 + .../kwall/settings/AppSettingsStore.kt | 12 + .../settings/DataStoreAppSettingsStore.kt | 50 + .../kwall/shizuku/ShizukuShellExecutor.kt | 68 + .../krrishg/kwall/ui/apps/AppListScreen.kt | 775 ++++++++++++ .../krrishg/kwall/ui/apps/AppListViewModel.kt | 415 +++++++ .../kwall/ui/apps/FirewallInfoDialog.kt | 152 +++ .../krrishg/kwall/ui/components/AppIcon.kt | 109 ++ .../com/krrishg/kwall/ui/components/Badges.kt | 55 + .../kwall/ui/components/BlockabilityReason.kt | 17 + .../kwall/ui/detail/AppDetailScreen.kt | 652 ++++++++++ .../kwall/ui/detail/AppDetailViewModel.kt | 214 ++++ .../kwall/ui/profiles/ProfilesDialog.kt | 295 +++++ .../kwall/ui/schedules/SchedulesScreen.kt | 869 +++++++++++++ .../kwall/ui/schedules/SchedulesViewModel.kt | 229 ++++ .../kwall/ui/settings/AppSettingsDialog.kt | 91 ++ .../java/com/krrishg/kwall/ui/theme/Theme.kt | 40 + .../drawable-nodpi/ic_launcher_foreground.png | Bin 0 -> 55093 bytes .../drawable-nodpi/ic_launcher_monochrome.png | Bin 0 -> 12784 bytes .../res/drawable/ic_notification_open.xml | 9 + .../res/drawable/ic_notification_pause.xml | 9 + .../res/drawable/ic_notification_play.xml | 9 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 6 + .../res/values/ic_launcher_background.xml | 4 + app/src/main/res/values/strings.xml | 213 ++++ app/src/main/res/values/themes.xml | 4 + .../main/res/xml/data_extraction_rules.xml | 17 + .../com/krrishg/kwall/MainDispatcherRule.kt | 24 + .../com/krrishg/kwall/firewall/AppKeyTest.kt | 32 + .../kwall/firewall/AutoBlockNewAppsTest.kt | 336 +++++ .../kwall/firewall/BlockabilityTest.kt | 159 +++ .../kwall/firewall/FirewallCommandsTest.kt | 74 ++ .../firewall/FirewallDriftDetectorTest.kt | 93 ++ .../kwall/firewall/FirewallManagerTest.kt | 1105 +++++++++++++++++ .../firewall/FirewallObservationParserTest.kt | 52 + .../krrishg/kwall/firewall/RuleProfileTest.kt | 51 + .../network/ProcNetConnectionInspectorTest.kt | 120 ++ .../kwall/network/ProcNetParserTest.kt | 101 ++ .../krrishg/kwall/network/UsageWindowTest.kt | 39 + .../FirewallNotificationContentTest.kt | 145 +++ .../kwall/scheduler/ProfileSchedulerTest.kt | 321 +++++ .../kwall/scheduler/ScheduleEngineTest.kt | 273 ++++ .../kwall/scheduler/ScheduleJsonCodecTest.kt | 66 + .../kwall/ui/apps/AppListViewModelTest.kt | 705 +++++++++++ .../kwall/ui/detail/AppDetailViewModelTest.kt | 294 +++++ .../ui/schedules/SchedulesViewModelTest.kt | 204 +++ build.gradle.kts | 6 + gradle.properties | 6 + gradle/libs.versions.toml | 42 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 47505 bytes gradle/wrapper/gradle-wrapper.properties | 9 + gradlew | 248 ++++ gradlew.bat | 82 ++ settings.gradle.kts | 24 + 105 files changed, 13836 insertions(+) create mode 100644 .gitignore create mode 100644 app/build.gradle.kts create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/aidl/com/krrishg/kwall/firewall/IFirewallService.aidl create mode 100644 app/src/main/java/com/krrishg/kwall/AppChangeNotifier.kt create mode 100644 app/src/main/java/com/krrishg/kwall/AppContainer.kt create mode 100644 app/src/main/java/com/krrishg/kwall/KwallApplication.kt create mode 100644 app/src/main/java/com/krrishg/kwall/MainActivity.kt create mode 100644 app/src/main/java/com/krrishg/kwall/data/AppRepository.kt create mode 100644 app/src/main/java/com/krrishg/kwall/data/InstalledApp.kt create mode 100644 app/src/main/java/com/krrishg/kwall/firewall/AppKey.kt create mode 100644 app/src/main/java/com/krrishg/kwall/firewall/AppUidResolver.kt create mode 100644 app/src/main/java/com/krrishg/kwall/firewall/AutoBlockNewApps.kt create mode 100644 app/src/main/java/com/krrishg/kwall/firewall/BinderFirewallClient.kt create mode 100644 app/src/main/java/com/krrishg/kwall/firewall/Blockability.kt create mode 100644 app/src/main/java/com/krrishg/kwall/firewall/DataStoreFirewallStateStore.kt create mode 100644 app/src/main/java/com/krrishg/kwall/firewall/FirewallBackend.kt create mode 100644 app/src/main/java/com/krrishg/kwall/firewall/FirewallCommands.kt create mode 100644 app/src/main/java/com/krrishg/kwall/firewall/FirewallController.kt create mode 100644 app/src/main/java/com/krrishg/kwall/firewall/FirewallDrift.kt create mode 100644 app/src/main/java/com/krrishg/kwall/firewall/FirewallException.kt create mode 100644 app/src/main/java/com/krrishg/kwall/firewall/FirewallManager.kt create mode 100644 app/src/main/java/com/krrishg/kwall/firewall/FirewallObservationParser.kt create mode 100644 app/src/main/java/com/krrishg/kwall/firewall/FirewallState.kt create mode 100644 app/src/main/java/com/krrishg/kwall/firewall/FirewallStateStore.kt create mode 100644 app/src/main/java/com/krrishg/kwall/firewall/FirewallUserService.kt create mode 100644 app/src/main/java/com/krrishg/kwall/firewall/RuleProfile.kt create mode 100644 app/src/main/java/com/krrishg/kwall/firewall/ShellExecutor.kt create mode 100644 app/src/main/java/com/krrishg/kwall/firewall/ShizukuFirewallBackend.kt create mode 100644 app/src/main/java/com/krrishg/kwall/network/ConnectionInspector.kt create mode 100644 app/src/main/java/com/krrishg/kwall/network/NetworkStatsUsageSource.kt create mode 100644 app/src/main/java/com/krrishg/kwall/network/NetworkUsageSource.kt create mode 100644 app/src/main/java/com/krrishg/kwall/network/ProcNetConnectionInspector.kt create mode 100644 app/src/main/java/com/krrishg/kwall/network/ProcNetParser.kt create mode 100644 app/src/main/java/com/krrishg/kwall/network/SocketEntry.kt create mode 100644 app/src/main/java/com/krrishg/kwall/notifications/FirewallNotificationContent.kt create mode 100644 app/src/main/java/com/krrishg/kwall/notifications/FirewallNotifications.kt create mode 100644 app/src/main/java/com/krrishg/kwall/notifications/FirewallStatusNotifier.kt create mode 100644 app/src/main/java/com/krrishg/kwall/receivers/BootReceiver.kt create mode 100644 app/src/main/java/com/krrishg/kwall/receivers/FirewallNotificationReceiver.kt create mode 100644 app/src/main/java/com/krrishg/kwall/receivers/PackageAddedReceiver.kt create mode 100644 app/src/main/java/com/krrishg/kwall/receivers/ScheduleAlarmReceiver.kt create mode 100644 app/src/main/java/com/krrishg/kwall/receivers/ScheduleMaintenanceReceiver.kt create mode 100644 app/src/main/java/com/krrishg/kwall/scheduler/DataStoreScheduleStore.kt create mode 100644 app/src/main/java/com/krrishg/kwall/scheduler/ProfileSchedule.kt create mode 100644 app/src/main/java/com/krrishg/kwall/scheduler/ProfileScheduler.kt create mode 100644 app/src/main/java/com/krrishg/kwall/scheduler/ScheduleAlarms.kt create mode 100644 app/src/main/java/com/krrishg/kwall/scheduler/ScheduleEngine.kt create mode 100644 app/src/main/java/com/krrishg/kwall/scheduler/ScheduleRecord.kt create mode 100644 app/src/main/java/com/krrishg/kwall/scheduler/ScheduleStore.kt create mode 100644 app/src/main/java/com/krrishg/kwall/scheduler/SchedulerRuntime.kt create mode 100644 app/src/main/java/com/krrishg/kwall/settings/AppSettingsStore.kt create mode 100644 app/src/main/java/com/krrishg/kwall/settings/DataStoreAppSettingsStore.kt create mode 100644 app/src/main/java/com/krrishg/kwall/shizuku/ShizukuShellExecutor.kt create mode 100644 app/src/main/java/com/krrishg/kwall/ui/apps/AppListScreen.kt create mode 100644 app/src/main/java/com/krrishg/kwall/ui/apps/AppListViewModel.kt create mode 100644 app/src/main/java/com/krrishg/kwall/ui/apps/FirewallInfoDialog.kt create mode 100644 app/src/main/java/com/krrishg/kwall/ui/components/AppIcon.kt create mode 100644 app/src/main/java/com/krrishg/kwall/ui/components/Badges.kt create mode 100644 app/src/main/java/com/krrishg/kwall/ui/components/BlockabilityReason.kt create mode 100644 app/src/main/java/com/krrishg/kwall/ui/detail/AppDetailScreen.kt create mode 100644 app/src/main/java/com/krrishg/kwall/ui/detail/AppDetailViewModel.kt create mode 100644 app/src/main/java/com/krrishg/kwall/ui/profiles/ProfilesDialog.kt create mode 100644 app/src/main/java/com/krrishg/kwall/ui/schedules/SchedulesScreen.kt create mode 100644 app/src/main/java/com/krrishg/kwall/ui/schedules/SchedulesViewModel.kt create mode 100644 app/src/main/java/com/krrishg/kwall/ui/settings/AppSettingsDialog.kt create mode 100644 app/src/main/java/com/krrishg/kwall/ui/theme/Theme.kt create mode 100644 app/src/main/res/drawable-nodpi/ic_launcher_foreground.png create mode 100644 app/src/main/res/drawable-nodpi/ic_launcher_monochrome.png create mode 100644 app/src/main/res/drawable/ic_notification_open.xml create mode 100644 app/src/main/res/drawable/ic_notification_pause.xml create mode 100644 app/src/main/res/drawable/ic_notification_play.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/values/ic_launcher_background.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/data_extraction_rules.xml create mode 100644 app/src/test/java/com/krrishg/kwall/MainDispatcherRule.kt create mode 100644 app/src/test/java/com/krrishg/kwall/firewall/AppKeyTest.kt create mode 100644 app/src/test/java/com/krrishg/kwall/firewall/AutoBlockNewAppsTest.kt create mode 100644 app/src/test/java/com/krrishg/kwall/firewall/BlockabilityTest.kt create mode 100644 app/src/test/java/com/krrishg/kwall/firewall/FirewallCommandsTest.kt create mode 100644 app/src/test/java/com/krrishg/kwall/firewall/FirewallDriftDetectorTest.kt create mode 100644 app/src/test/java/com/krrishg/kwall/firewall/FirewallManagerTest.kt create mode 100644 app/src/test/java/com/krrishg/kwall/firewall/FirewallObservationParserTest.kt create mode 100644 app/src/test/java/com/krrishg/kwall/firewall/RuleProfileTest.kt create mode 100644 app/src/test/java/com/krrishg/kwall/network/ProcNetConnectionInspectorTest.kt create mode 100644 app/src/test/java/com/krrishg/kwall/network/ProcNetParserTest.kt create mode 100644 app/src/test/java/com/krrishg/kwall/network/UsageWindowTest.kt create mode 100644 app/src/test/java/com/krrishg/kwall/notifications/FirewallNotificationContentTest.kt create mode 100644 app/src/test/java/com/krrishg/kwall/scheduler/ProfileSchedulerTest.kt create mode 100644 app/src/test/java/com/krrishg/kwall/scheduler/ScheduleEngineTest.kt create mode 100644 app/src/test/java/com/krrishg/kwall/scheduler/ScheduleJsonCodecTest.kt create mode 100644 app/src/test/java/com/krrishg/kwall/ui/apps/AppListViewModelTest.kt create mode 100644 app/src/test/java/com/krrishg/kwall/ui/detail/AppDetailViewModelTest.kt create mode 100644 app/src/test/java/com/krrishg/kwall/ui/schedules/SchedulesViewModelTest.kt create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts 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 0000000000000000000000000000000000000000..ba4298cf16e671bfd5e62a257f8c8f7452b01b0f GIT binary patch literal 55093 zcmeFYQ+Flr*EPIj+cr8*hr46jR>$b1W7{@6PCB;Tv2EM7y`TL4@A>-)?t>avjZt+_ zwQ8=p=DOCba7B3uBzSyy004j_B`K;50Dym;f&*Zozm|Kq`Bwm7@m)$(NY!oi{KLK7 z)Le}^M&NAYEt6-y(tVg0Rgx4AOc=ZbQOyr=lwsXLknq?0YUS)|(}uuB)9LK0OTJIf z&e}y(Sd%u6m>8O<99`~r6n{!YvOCSvlgX#d`lo+BzULpESK3$n&B{_%T&jzG#gYph zN7;r~-cRY-54?Uo1}wOU(1HKA{9g$E4+a012><_F5CT~zV!%D%++*B1X+HeWN8knh z+4b>nP(L3B5j6G_^>{2=`P*)U6Q{%baY4iO4 zdu@MRzuLS7Y!h4SwtwBp50D^$oES2KmM?y-?{VMj@Eik8I2y9xRzMhVvqu_%cu#%* z#(?ylT0i=W&c9f!EwviD-Q^|I9m^c#J} zLA^`WJqB=oJL51Ue)+)XdYWdvdp3ow_g8w?`zq3+dcNTWjwdVe9U?4Z-IVScJJnhk z_rn0Vd2uxqma_D>eb~)Df7r?Rr*W~1RtZtk-V%VgfnOQ|GzLakTKF()AVL5HSTaSK zpuuD3ebg*O444FR_G+eIS|mPgSWk>1Yic)b>Nc+6PKgn;TK3zx9VI%%QoFgnAKekl zF;4}0tH-f>(QPU@_>rrR|pI{@@%EFARVS#?9bv zQoqe?3`M`*dsizFiZA~v3=r@n=Vgovi+BV;?}|WX@HqX3Lca8#wfh9ea7O`8Q(|oj zFd%1vFn|tI&d3()Oz8x$Pi^x5+#)sN96-c&j|zQuw~=;*4qSce>GTe=<>zp`TcL-x zMD^mu?(SxzL#g=w7T{hv^kA8@KAMvKwp$#F(sj$0qw{{j1B2iibhhl1j|43PKrHrO z(ZamDD*^3c@#uqyaBM#p%3b7b0x^0*fKWu}1Frnuri)PQ!PVhXEJ`}pMzV2e!7TBY zL@cr2AO{M|mfWg1cF;B(J+sxSgiDV`WX{mq|y9k!k= zl%$ZLC4tY(V$gBn9Ox|F`T%3c`_y(%l@IOIhqJ(s*thdeD|agR6;D1#?NQapp@%@A znD9K3(x@+>Lrz*%fMmCA_4xfDM?e>5Ty9YiGKnuYvs-RRUE%#i1KVf6SOC<0MZyi> zGMDW2tkmCzV0P-cFK9g7m?;u*SL{yh75CM0knGZtVw*fb|n{>&ClG^AIHkLB$nZb+h)75A^l29e0>g03yIK^Aym0$X2)G#8_c#!8cQ%7|}``Z8JJzWuDmI zDQ@7%6f5+A40cGG7;4lZgEz(ToG9qX`jd$V+<{x@1tB~zb4qO=NSIR}mwr>ATm8~a ziH?RXg&sb4n0Y4~GdBBl$q($WUax(O(tm!3$ay|lwr0geOab5yE}6q@TUo^ zt!{L+c$luw?bx9tDMFG;A;@XmNqM^A_8YNXyi`2*IRAB6hY{I zu^b}dsuc_*&L;gC19>L>l8@0mN7<^zhup_*2t|IMC+M*0XpOep!0nzz{K6OjGrdmf zdpttJc$q^=*}bI5+hiJ!~)5Eq>KvdPxihM56gi}y0keY_Ane! zEdfwro3jZ>4s@(aBjRF4rVg-8M7Ly~Lq+9{Y~zAP{KI6vd&tMVlyZ-}Z*q+Tr$DHY z`*H38kr1w!H0d9n)8_4QHa7KCP40(sxuLT^a-YHfGJ?L)(QJyS%0<&wF+U~1mQ z*QYYGjl3#j&eYn{zd~7ehr(j|$E8?aDMm+N`)#8XkR%F)89AUifETG=gtpbc>?w1s zQ$tiC#U+4u*S+ne3rqr^^Y+6_rF?EL!|TBe^f$dNes0>X1e97+F^TG7K=gN3`lam+ z6dTO#Le0a?_6qAmlaZ z+Jz3J0)*7U_MBFPJG7xFUg%JBHv5Y=Ee%9-1;GapBM)_pgpE5Zp zhhgJXX53+&u~%A$dyo-5apYWzfyyreyJP=d&=MORoe|GZ1bqk1?9jT5&Fqnc-l@R7 zGy8#y2pfG#C^2%_BtR^0j(5^L-0#N_q9TtD{E~*7@!xvX7{+i0iW+oHLOl7(_@Y21 zZa-WO&!UmKx-FZ)otgXwk?Uo!CFh0Ga~{#(tc=vGST^(y=>8N~$8u>Y2>`Th+VAjl z>wKz5{yg2gjcZM8@o})jD@O^*OrXkdXV&yYwQWVXAKXx)61j?hMecBCJ;5@c0G=#& zeYa+~@pf_AH2u~(*=wXS865@zSkvO{rj&BqF45Sa~ z9y)%z?#0U`@lipjZNTp+sTq`hy{$+~A_h-m&I6A?a2hJ&fR>3FA11Sge@S0*#Wwf=YOL*HY7`DVJGq3 zrTaGpl4#{MK59lx^AitxzulMIsG4GR@Vh_bb)mYAHFMG7p=CQRkRM>JP^izbq*_;J za!Tm?A!e6TTIQu%Nc-um7WyB#9{~ja5w1LhC4hm>CTx+v7{BUQaC5U^k@&cn(ujz& z#L6poAvvFV+qx8^2~NP>5A7;q^ztRuDIpK27|LLO^tTRo#eZ>23A>t?{VLKtw1|xm zH`i#ePlKF4$T$9`k+aW=8&5)E_9PfL2M0YXJicG33|Ow8^kD>a&Hg5pH<6tE_0wwz z64E;CdeuE}7#NWSy-8bXMDmZNbs9BzYOs_7ZO)%teA~?jAB@az2W$%#0gacM0gMIr z>&PVbY6*<=ci(IXmC?J6!(~yK2PcKMb|)eT_>}o+p<=$--KIohHvIjs;NyL!_M^Qi zBjgG$*8^g^b$>bXzt}5DhKb;99-F_}CO@ZZ=1Ho+NLMjDr2bN^+UwfcJ|O93m<>3R zsYX(f%#tbLi#1W065{&=hV@ubV(=8mOIPD@q0gtG3Gv*r(4u2d@Z{8sQnRNT6;1ny zOXEYvm%x!E<|nx|=KH(+j&q_|wd#gA|0sP3Lxk@8)yEBpE&R<~5dicvqRKI=B9lZ@ zTD`IzlV!xGap@7`$@r$`EQK4xBMufpMw|ecK{iKY372$+NquPlmxgzS?Jd$I)h}!X?V-U zG{D#d@V7ry6rB7YSbu;9=^cN9gK&_SslTWWwGs!dJ=%U$juxdr8m?ghC2GSxrXM%t zS8M+tOv1s%$X5@*WP;PHaFNUfx&tsnqEu7T7sJiF)BI2~6~mGl?{w2r5{ta(!pXqt z22O!cx6_ovEwJ133xbpM^X>l6Z5K1wu+k94Li`j&6&aQpaVO}5dy{FI%!M1xA-Q1; z+%ia{GjQ>iz9TrLz-6f|eNVXkD>X^P(mS{r#vR;&0Z9pJ;><-{J^5S6ViVhO|9p9P zz>GJ%9&AW8AN1KoGs&jmr98bESvEvXlYG z!A53ya*nX%U*s-Q+J^9U6EVI7tGhA!Yw&;WOtH~#U)rc z;TveN=3z)u2{Bb%aEKMo*B><{;3Vg24#|iWVTUU;QqX@l!WHZ97owXRRImH}MDbXy zg@%at6MaGzhtwWSs{&=z0BS}_@x5^_3iy2)Moj?NY!ehATzN4IPi`-bOH0Ln!WZJ0 z#7|>NlO(Dvy4%=WLd82-?k~x`xU=4ZTM4B0OsiEX#+vadieqTJ%Av$l9l>`Lmm_64 zb3F)>jFX;*4^^AG6mhNKz#Gc^_(6cU_?3+46W(0OH?8khV-IuaoJp(W_D4Cht9Y@- zLm1A8zkBb-3L6!{LS-qqU@^7)E%@wcQ0p^+5NY^7J!HM$!<3*-I^2!WH(;H9z?G2r zKz5%Fal?V^R+Dfl!!c`scWlEk<@+d%!*YpFmh^xjQ|WCmmo>s5f%o~ zYZ%GpZe_8u?bYC2GLtHV*z_x|0W4)ImxK7tG6} z>|tVK3yEq|kRECqRB8xHfpYqv1uegSTY*kM%XC(zq2O~}5)m1qu_N+QTcRofsJM^a z?10)GAO6C;pZ^y6N-cR67HpBL zDs_YT{ep|LvHqHp8{g|^OG0O3%=a^Xt;6X;O02JhP5*X7Z^h_C>udH-n)?PaZsz@V z$F6_JAdUhY5lSS^Kmsr3{=me$A>Rx|125>PN1Tvm>$N#sh@8hzcx z@k(G$H0)BVbxIy|KO-@c@`?dT!17|6%9+0lyXns<}|0K+jnH|<;uL}3L z>Lpf$(xM6;tAzkV+n;#_3rc1i-M3sAHJ}6^)#ij^ojDC#3!0b47)owxV(c+SC+4FB zeo)dA&w7;$@<*vOGD5gKr=w8v%h!8eR_L2e(LKYsKaow|4ouyM?;&B!_}dqBOED(Y zsV7)!E1u%EavBC5Yno8T&zl;s)H8?UVsseUAKT4+(EIZH9<8ozKE8M&Lv%ZPD)EC@ zEP2xxL4Q}k-;S)N33DD+&Gofxw_fqL?w+bQp9@i{rlz)_%zA~$&!GQTehuyL$RhJK z<*1+OGB?`5?0>iCn-x{|YX=n6l(0zEEK9+GH-hQ50Y-c3RFg*L0Ye=)co9Z{KU5og zG9~b?6dxT|7vfSCcsAn{!RBX0R=R@EYHwjl-HBJ}HppV2XKO^Gl)u09Z+8e9HOBHd z;z`wEQeYi2B$AO;P@X4B`Awz_#diQ4583S~h=+H4u)iXT`sdaWk zRZLA-w104lVq!DNYo)@M`=JQTg|}?J)v(|sG>Oe><0f>K(96T0-wSqQL_J$9vK8s< z+(V=d|bA`^^zP|iy#_%jemPoB4JKy8x+;b27_FCs=1Y~o5v6%qX8zv`2l1R0Us zYZECl_E41dS)WR1d2m;_u~e)dPGm&V6N6;d ze0JAB9(OWT9^{9}8g}!ykciM{0Nl>O9n+g5t?>b1d8&$xI@VUucxjBzAa?_iZ_YbMr0$hVWX}^R?(})pyrXGUf*a=U&-gnI?A5EAS`*hS{ zUYvjsFcb71LO-Bm-f6YRyUxpbR<*cRp0-wXsUTQvoz_*wis$2HK)t>+k2!zc z-tE|?!~2ErEq!W6x!ty4cx$T!m&5;*r`Llj=or#7h0V^oKf~#jt=exHcwI@%7k$DK z1RgJ0UHOf~{V!|I9lB-+UhN6ftv@Pv;V{Y(t0pyp(qBcHj|eS4bV>9onAme%M@6aO zL2|NbboxUt%dSN9cd0ep)_M|Ehu-f(!YP7^r)T}ubp;=An-cUo49YB7l(YeP$I~?~ zO49Anr^JM2s&>v=aUwkf8Yv+%Ii2PgT%Q{fe|&GM7V@8Xz(0?jX~PsDV#%LLxVP<4 z0TT=6U>T<*uPG!4cdqZaP2<0h77XRo4isC+DJdp6_QT+r+`Kym$HzH6Mi)V@LsMQ; zRyQx6ppBc>*4V+IUde#}w%hbG7y&tDSDI+~XPr1^tCS^#DxK2@rW)-fEu3Y7(3TW zL1(Ul=&E~3NxHDo4Ri@AWmln6Opm`^fR~Y7E0|g80r+)i^onN)Ob>*l$$Fg8NC#h$ ziU@QS9_pP;mGn&!wxg#i+S2ns0}zpCE!Kt~8ooDUGF{iju9@u^l8{{=cd=!sG=x9) zYkYm9fd+3+UC%t?e{}7pFV;Q2p@eKFKdb^Z`U^&AIC_`;zm;@b3~%CWW;MU9_?X@H ztud|N%G_u8-s;PbPcb)83PVRxT0F0YPBatC(5!_kcc`RNS%0uZF8bWG{*WuP6(KBY z0AO1Oe#@9B#=%kUDwFXm!BUuMFMfzKx0a#(2cA@lEd8P{@tUkxLt@7F8b&Ti7M0+H z#w*7_TJ^!P0VDqt5^}-vPYxH{xu7SV3VxuZ{s3+Wg>7K279JjrF!7!2p~9i%cd!82 zIAL>%NlWe*4!;Hl`&Im3KZgSOVAX(HZ5(vR`6v}14^uvm{dd_PJ^y^46|T75X{IKz z5fx>wJqs!y%_dP;9tMbMvDw z4>$_#w{9oWXP3~94(Z)r9S?P0Vd!){iBQBjrBYl;>PEN&^JPvajCF|Hf zZ5oiAlsjAN4WY&c@iw=FYLEMlw7Z(cufKI(Dv1TfhjG+Qw5<~Tv;IaQkSoPG2Iwk6 zm_VCedf7iR1yD*PreA9plR=F;z@W~EDG;q>YLFe`^u(mr`6-YC6UsgP1xwgrRO=Q; z9!O)Oew-;0D^UuL;t0|+eJ>0~q=LlT#0c!jZ#(BXB1+ET>->xt0Nvi;cyGMfd>rb3 z+|Hofod2GIf1SU#SbBwxYVBLdrnv%It#x@{FyC@0yuxPvJ3HVlv;3)lvpoo%C{{-` zgbxVJlFZLzrvxJMy&NpSubE{}|2u{AZQB#@TDwy4*~7vyy0n8LA3XnU=!)I9ME4u= zI#n7#DAf3&rO`9vMg^r&`5>>Ip+DI|Wr%7Fj*4K$Et)$VWtv(zwC4vDj?ARr6RcWdQvm$YyJKc zdKv`Cp|Db=R9R&`MFaBFlXqcO+I8SG0|)t0Jl)b5*<=qV8Og#@*9g7r_UD2tzkhm+ zh{O#SrV2Fd5;VAgI|hckkkb-W?NNEFX~Un^sDi7!Q?>+m-TO000#T0t$I zBBDZH+i-{F*=UlWAj0@|!$68rABSo}EOX0kfp`3yK!nl#$^mHJ3)O$&Yd2r|>R}$oSj)Ntg~W2fk)b>r zoM1{#0W%B32^!dX1SrlwVJH2;eiAGW98U1mdY<`ied>2z@jT185_7lueokfB^#28a zd-7av`u}2q@(9iyC3HZLvo0BBh4f3*6rY}~6dqR>fY;j~LHlH{j3_()I<0;bC4j1Z zmZS>EqxH8d*o(@Q?3H4!hdxx?fl16Wp{;hgybE!Yb=QQ)A#)(hGxwLtIchLBo98%H zN_PF}9$}3zDN^<>ly)D~Jy}OE2YsGm@GU$xOp2)RGB)l_?ugj(B7u|#a_1_^4N=Ia zTS+geDk%RpCfM?ZjV8tSjYgpEU8cDrr#%tye!~#)MV|@VTwgVG8VfPKTqtN`>uq*P z4JYf4#NsH$3J3$QZUg_$z3stvFZTCKEKEt7ypVH`i#THU39TyNyQ8boWL`zAL=?WYcSm0iWkCH0G8%^CFGF;> zKTbDpDAaa(_haaV^d!3Yj6OTdr&sTPrYb?BTSPzy0>JfGH+FaT=__yzvDWo;u(dzA z_Mq}`IWZ;Ph0^t-#6Mk76*ZDIc|GEs(r-0DX-pAeLHMKuHUI}9|GINKSPcbcy4Ad+ zN`UQdbhGfN!`f}b+}GXu^4eQY!l0@9)BOzgKW**Rm|MxIXXzYCU5dInBI;(OmVNG& zQPqGGFg4^nQJiuH%wCe;3$ zk!Sc!@_`fLor<>k^e2WNL=oU<->}mm;+K;hoLQ$>Rw)LKt8_+BU=YkDq>r)+c^x-J zrPSk;x?#q%5$sl&5xA3{n3p1(D-nI4HuSaM3!8mg;og@n#9FUdP9->wrocflT@Vgorm%a7|M)a>;w;lgBB(P`rwoM&nn-{o(gU8HL_V!H4*#Wm#Ti61 znhe5qxLj1EE4^uL~c>(gFeIM#^98I>5Vzdn%j<%?~G#P zP{hff@D!-7j(L{l|3i2}IOyyTRCkNDyW6b{!ot7L{m7xMM;%wjbz*SPL+B1ku$h3N z%i;i@0N7gBTQKtjPS%IaGfXB==Bxck7Mer-f)>Q&d8-ZAl-p6P^u9-_B8_nt?6c|H+*pJ7sSs(WOYySB8* z4Qe2}uYHe9{s4e5Rg$Q~v{2%BKs}>a8DRrSNZk_Bv|vAExB-pDWaBrLp6j9d>&X0f z`}AlzC9_oLf$_ZFBCpcg6_YUhH--VQRr!!*{iVhJ>GE3!fu6nw$7XOSp}9*S)CLRm z%n!%_7c|l6@62E(KVT9%Cf#pQ6$0FXtYY(VVI&2DRNpN7^vQF)`|7tjxc{fZM6bD!AJJFIn@ z536-LpgJ1&3<4z&Jf`lYlb?Ij@xICcuVO$Q>V#LYGRf@hn-L}zWc4Z<8r+Qtv~)%6 zGRD#}rz6mM?RAn!;HijYckSwP07KLlu^S!izcC4D&Y}qrQw8RXL_M{=TAt>3dQnu= zIJ*tgCd}urI9Pg+?X@B0tO#_0P2ca1*0GJufLr1HNjq9&Bf`aX+0uziX_>#vmk?oq zPZ?)xyJK`%u7xZdD9~vR9_FUGQ2d?+bibukTh?PQxWOOizc^HpRwXAP57MPJO(cws z4o!HHGd_u{iCcvY%fhp_BchUn)S?~QCc%Qyqu1RB>-Kc$w3|$ZA{4yzKM-ncQSQ2; z7%Fr1eG0vL#sHKHvh4|Q3rtN3yu)tX;Rd~Eg?`)_W>qgg6lrwCOs(G9y1yM(_w7Tv zSr2RCxuL?7V=4;>MG6;2hb~W$R@&onQx+h`1c^GB4ekdvKzfmfM#QJKZw^dh@Jd%y zd0xR6W$EMy>Ck{I*8H;sbT?gZ&12C&IQ&+{A^w+g5_TW_ZiLXpxUXmO704F<%Hhpe zfO&$S6uC;mR<2LI9@()zkH6dqIcH}tfzEVvX6P43<4|#8<>_n}9tn`bCnkSZ_sM7} zt_b(DA-+W+(EXJ+BaHraieO~K%^xF}Z2o#aYsKkucbt+0W0(!z&=BNsYGLTq>L zS?PCL8#*0d&zz!cddxvI2Th$3SbKrO%->;kt3knjyT_jHHCh!w8Pc}=fUZ!$J zNKXVFu45uY8ki2$JL+aOAFZ@ooxL@69Uv{;Y)EYS+;RzAm|mLV+!G5 zs~R~WX$n>wtL9fx^4m84h|`RiaktCyU(dh>?YP+suEj{_n1s9f0*!Mljv)| z(#2>ynMU=YIY?2lVwVx(3lNLiO#+jwyTSMR!>+eSk0;;MLw(<7@*n=@yzg&t0W@u^ zC|UxS`X-15DfAqzj@~p_O3=nvZf46Tk7RPL#aA2m-qx!sacU@@dul?ebq36&bELAR zq&r*}5W_=b_h`j74m1nkD8*UHy3{t9X#Y}=sz}(g7Fxv_(a;xD$30~uz@%q7;Ue}( zD0nSzJx#A+`@BK;h8WBng#S-HbwMIejTKk_vy0ezI3LR^KvTNmX=eC52Hn;E+w!q+X<8QOJf(uRAZW7rSi`XfbvOPS zHJ?9ohC1?_1q+`0ily)v!$6ewzn{N4&VZ(nOwO2uXzfV`CM0Xmog*vhtl?ImIQnf} zdYf(kw%*a=8q3+o3n8jeKwY|uemL?!|S_4(>3X$;5T4CeK=4+-YA&*G_-l{GHb6&MeyZTS91 zn5|E|X6@w!mJC70&Z0%Ce*PTCi(1DC_j0=2D!(%5_94_DG*}21&B~Ulu*)uAznP#r zU(i7C41`tSX`o{>!-m^ZZ!erX_Xk6SJK*i;MzVaiXf-Db>Z|(&+lJ2tZFjjJ$b3v* zfZ$aynD?wRU<2Vx&LaHCYp!ph5}Yn9?kGA&;?iiSM1qr0?W&vw%jhXLsP7ixCFG&X z(UQ^uu-*|3AuTh`R~aYij^~_m?0eA|GF&&cdtJg^ci95GK7^5Si#{dYa$ITsHUBk4 z0w~V_!igT=j};vK`#puTuA5uA`+JQ1S4$jPIZ=bhYuS|u!X#alkBEN`OMx>Xk zEyt#S3t?0bh&U#B04pr&?YADYs zep>lTRzGi|7TUBnoS3|0FtCih_jgvf4)SbuiTQwp1d9t!J1Ip(*ssof+|BxgD=Q?M zpm4114=e3MnNX2qW{~V^(TA5x1r~h1&B+W&Bh3|A# zsIoSS+Nr=S_Ky;(I-J{;Qmb5arCKTsU-eT1L7QaAG()#I=?(ecu|*)&p0zOVVUx~ra|6;RKQt%^!5;1& zQSdfP+Pn_rs&Mi?vRY4D+8_=gSSPkBC1>JZ=r3af}RAM{ej! zA+SRv`(K*|X8Urf>dY!V7bJ%}@k^>1Ose`^B8hpzIF#gd2njKuBIrZIl*fO%F)HF; z*xhc%^a+vCeFqhqI|>-beJ=${6XKySHy{y&$`4~e&Qlsh~ToKBC;&D-tHJ|7$8*wYYC z3+#dO_>LexPux_Pm~*Og5!dML9Iy!$V)4C?>38of;_G&|ak;zgR^9xU8R!rCD$?3PT&e1A)Q{~go{OwqzkJAB&7VJVl0dHvS0$uO{_ zq@Z~6^av(^^?iK!4;7z;>fzQTyX~q5YmjX2H)o{PsqM!6(w1sd?;XJ7m?iOTMDVoRsYd`pdJj!2nmA+k^ts|-Y*Si>v>ee z@adkQ=9d{slWm%T*|pv9`3lBY6*Q7sh^>FYokYWcP?9;y0{eE7SrH>;1CxCL<^{i@#)&6K6WE{A zaYi)6e;upe@bx4%@6dre`pEV5P*p7f*D1}aWx6lY)F%N zGV&lRacc7h+u^D*VNp`0NOn+x$Q>DR%S-72j zd-s;`wS95@Jp8a|=IDkYJhY`ApY&WK{SPZFxWwLt)q$OBH+sySi&^z&91DjhOD%FL z3jO<#?-l^Wv-DIji5P88UyHzc1q6)R2gZt6W7A5a%)g5#v?J=~P;LntF5joEs$rC^ z17d=WevgHclJEfblJMg3*u$TgLlH;$dG@%ei{)|_RVlu4N?KT<|GpdF3>;ROp=Vuz zKnd;M9sT3}>ju@P>j_2SeMVqCiGK+!^n+_H?7uARG2ouaWnO&))Eqr9f3%&hTUYlM zh5s?O+#P8!g+9mQ#doi=QplczCu!Aeq@V^&oaRMsu=XF(g`g&Tpodx*|0stPY@<{5 zkr`VQOAAJ1-*=%El7^W8p9R`h>y*Zlrpw`IV>-t`u0 zmT~Loi_Xyoo2u*9*J<8b|J1&2oBm1tJG-rJxgAc|-GIf!Odn&Jb-Uq$f{G|~+{h!7 zQsg>rdcjTccy1N(rSJQ!E`{`1#1gAR=VLJ&EygbUumjc2Q{EpB z-4CHC0uCJfS_f0$yq?26($P1SI7a_GKM``&&X-^4y4+7Q-kZ`QBTA+0^a9J%)4VUb zV}zL8C1Q29C>BVLSdA2vy8IIbV^f%sH^AhD5*!vgmPsY+BCO-HI05&Iu(2x;1+x9p z4G`b;O17iX_%Wi_gu{nK?_u^Q1ri`R=UW7AS!-Nq7hNWW>q+@=dZmRN^^#15I?!>W zyAkN0Zf0*`?C$?Q&4==T#>&XZ3@LwS(J>%SLHv(DE3%%Z|2B%;G={1V`768?d0RBa zseQU`mtBx}JZNbkPiA?jcUX@#I&1O97ST*Pv&}`nDyVthZgQD)iYKN@sx~ud-iWr{ znqg!4tbL*++rygXZA@jl69yf$aKe8dQ~%*~9g1AF`Q}o|lHax&xp>{ytR?!z4*YkNwVaJ&Z=KgQ{a}+D4FL-_ITqH-nA0 zk@OwvECN|>E?W4j(D9B!G_&s9(rld!I~&b<*G)&IrE^`O2iaJ4&uz=$d7USp-oOZa zRn_c_CmlyHhk1H8NQG!Jav-!H|7n?ii_(c3Bp?tUYQ?&xwD%gQO5i~h9U=RN!O=NylJug{M)--5tt0 z+me?=J#>3!y4lfGLX_#-O+!x-ZDqBTa;DK~F&&Y+sZ1%k{HbT9mXhXKBD>Ewa7s!S z(@0)u-pPeW&2}W|rlDMFsjyA7XlS8#-tjEX_9Pz@tvr#$&!j)l1&Ia6VWP`XcW7C>H5=oKKQ&*Ib$U}Xml&S&MNZUAnu8xuqx*UhF zHzjg9E<*N5UT!-SJ5c`9=WI;RJbul_Ln)w#_q44pv!{J}7rCb(!x$3!KPW4kg;g6i z5*FU$2=swihE$od3tA}Z2S^JdQsBjEb@uRLdkr6ViwxhfxlXdnf1e1$4V*27eHi9E zSq-(}mqE16*r!b4k;_+Glr30>!K8G6?q?oceeSllKB@TuzN7NpUseuB$K@B1>Q}tY z`a69?4+k9E^xxq6YJpyQ_7kF;JkTn-wN~XGt;y9yaL#_?rk)sMSr}T(^`gjup6F`d z$D}z|1tYDP$9_mcio0a`#LPYQ%I3g_;v=eJh2*2@c7>K2fk)G_?KUReeUz${#?}L{ z8GW3+O~V7u@l7_s;#m+T0pYAtYuE;cNegBw75y=dS{mu2>@;yAIz{Ke)7B!knogGh zb@k71;`XO#3Ew@DCo3+-|Aj99!WYnRaiP=fbDhTK`w+E}sIRXv8Q5;_Q28vW0tU6{ zgek5>%~xTwN*5P-DvB+#ckA~))aI`>rBwPb{Z|)hx->!ZxfZ4tO9|F>Q`CiRlT_Xn z(=43JXY1uIoCLX1e;EK_DkbSMMTJM2A?D645mY*|YkT?gb>ffL^(0qxymO4T@2Y^l zt@quz0*;0r`_Z4JZf6->2p&{mLoh$v*}Dw%hNdBf@gRZ<@r8Yf<_D`SMSzIPMhI_$ zooikZCUc8Y1=Z6Y`*wpqxiTNvn16U0 z`8Fye3F)H8J4QcwoDmY}bWi#d0v#qf^kF#kMfQZx(tUf;R&&U>;~ZnQ1@}wRNQ!d` z?^~`LuR}}RsVuvW2!XjDT)ElB?rBkE+~d&GL`g zwU?Y916(B9Eg--)@)2gU>()8*zr#8`dVSx4tDF6P$Y?t1ygYA`x}X@aurR@xSwLvQ z&#qes@?WEo7R42(=F*j}D;042iG$G!G|~m>^t}cWk`G0`w%6V!)g#t1np~A2lD9UT zotM~u@YQbbDmN!@SKW*fmjgvV1)xlB)_K)KwVmj~$bl@BunjwuA$6y`SI_(S(3Ini z{d5JgHvZ==1r1Sk^1%&~Hrah>VT4$dXn|)!9CS^#z}R9>uF8%F-}NvEPZ`3_xg@A5 zJMJ%!WBlURKTXv06way+uOw*eAorp}l{Kc)3rGiSeDj&VVzAa&k6&z|Pj*t3wd3U8-N66XaBbl_mI=i^~-+fSWtR+iyh+ z2AbGyb>PpmlPwMkG;&PDvyk?8fqGoqxQ3td6sQT-K8rL7-+>#d2AuWQTiGPe(Sn!P z%@-b)t>)xF19_DTGYgbj;UPNiu`^C0jS}eqpEi(Z9G*AmU_fuHb5*%~wLu(-CIq~ENm3kNcO~)9rUPwuFumdh@Xek3Z0AlcJS6EaYdnE)` zHGNWm;WJ@v0MgKEYyUI$*)MrYlN}u#Tc%L!eiG1__f9K@tif{|*%gJ_6RW*+AhsRB zyX4c{n%|m_3*}z+^-k(PFy^=oWrsy-i%BX&;`kd;GamCMeyv-wz!E|r>#FEtBqJ2f zAWV#+du-^Uh7xyZVGn*chtJi*Ry0ZbBNX=!O$aQgFuWwHe#H1(!6>ZNeK9J8xVo;2 zgrks#A$!W+3ulq(J<>-;m|QvOFuZI2^4EteYa7+16nx?7sYG+2v|+BhT?P`&G}$MY7{XIwAZCt4re*HJHn*>^DBOr6I|eGGPy) zmB<@a*E{As4y_(w=~)?1RSqr$$s317PzJ!p`YI&dL5PytB}uiKFk0gkml#f!Z*h}1 zMoR5<5}P&c>bgA&a`f)rdc0Aj(F$dt%$F~4vV^rq^-h>00VBC{PpS)!UKm_JQmXNY z>P4KpmF0YX@9i{X#K$uMQGr>IqArxVF&w6$s?-#6yQ`Fu#NDG7NY@nHEM=?49goi> z%pj)sWe(>4+D0tQs+~T?vB=za?U7Z*amxAx%Egt83TTgSeY|M&GW9>pAT1#@ASpZKTV zYB+MF0$}&kc7RY#f?~g?fTol`79Pc)_>{y^j5#=SDKo}4*ow+Ny~YJSW<<*N3TI}y zp@d?l@>e`B%$<;rv1qgU7VBW<&kWqS9So=|kQT2ax1P1VK#AG@6jo!jx-(wL%!}YX zWu_<`*$p;p1C5o3&~*82Lu-?G!X8l6Ni z_^j*ynMC0%JuBt08G3q?TIODrO+1a%<9>rA_Cuwjz_~D(83nhKrR5&MB~`-9E8{Ic9h?W)GNHb7c3|TUW z-w!e-FBg>JMX+)-tcpEXNG)|-BvJB?|1C;k1nI>#53QK}+K&>V6Sq*gZk>KM$r8R@ z9!h`VZbmc_9H38%9%7QPS34}}C%G#e7cRJ|4(lJ=!QZv3(hn&=&@!)$B1MeB!n!b| zmC+Qi#weNAq~;tA9`z+D3G7}T>uY)Ob7^%KZ|pqKMUR)kR8)~=kF;MYPN*@5tl7tSRS`~rsG;%&b2U!hLSq_=r@6H)opanp9hqU>d^(8rkll76hPG_rf@~75d8de0 z5(%zL+~v^fKBthd<;|4EbaV}hWIt+Y7(w*;L?`~#VPb`h5~wSPiNLdC`Z_&J3a;^! z$NDlGR^vY6;G!~#3s?0$eO6F(a+?g7(_t!PA)Vs)e_EBpBLJaHHykCBZzyf!cBF2o zF&Y!F20r<7{1q;WNmmLz5JE@(iA{WwyD9yjohelpF_NmFEfBQiM(=Zq6t_x_xBqU-OEKPEtyY8*Cqtp7VGaT#=Dx>KZWbOb}2aOC6dIk2C(e zCuW4PBy~qoFan#+j~Gi+SHG_jM>isH${S0*s*-DslFTpo{6{rVTPdbS&Q)aG z@zlyn%M&S21oHHOSsOl5c^Hr7r%X)04Mik`pn)M4Yll?kyU=^7j3{<`4Cwr!`4ZKJW>*ftv5$;P(L#&(0owi-6c#(dKM^9AxH-FVV*B`AU7c(!DK-5siFy z>BLVNmo8>?Xym}tJ>&w%{+a*|KyNtbIVPQaTG|BvcB#8=AU9GT*QrD%FKS+7d_TTG zK4485Cln1}MRe-asDAQj3a9X^M%q}~;1_HYvm8(tSN4x}j2_F`8H#O6@4prsy#+R& z(7j{*`z!5pQ51eq6z?9i#B_-;>d{1y43lO~kWMXz(0u(|!TMeHvPNMJ!&}h73Rx8( z!)-?UQYU;bj87s9*_3h!=4OJ<^L9)E3^=N8)o0PqnE=IO!wHE3vB#aJSoM$t%`Pkq zH6j-LyfAK{UD_P%X`jyywyvA z3li#+jneWOJErFF>fUcm-*kJnx?h9`dv_{m)(HKQ7 zdgrJ?JnfeKZ2UoLad;L(`OWlzxE%JzNj9Is3Vsc|032zEnfCaRHLeGr$x+p6cpC8%BnB}-X& zJVG(vZKhST@uxB{`9@X_{#+?J!Ax`%N4Pzy#l~dO|LA4?x<{K``MCEJ0Xa_f6JG+UJ}ogIQD8LkGQ`^6VP z)MCCpLz%X{=ldE&G>RBl!cS-P4Tuwngt1ii5u^xe~o3wPdp{oV*b!`;}-#?cQeUZmeTfQbu>lX z>1mU|$M_Ndk4EG~3o?Tn*JQz@)m5GUTG`4luE%;8j^9WwwFdB*Fn{a5bE=XV@ri?}2-McJpyvUL6!08(5nh^t<-6hWs?MQ1 zPI0#xMwQN{@Fdi>Dh@bOTm<{$xF`*a2&Y1HLW(U0e}>Ez-=GtfiO4t#pdLx2m@;t?s#!8^1r_yb$Jw#%J^>|Z$bzDyB8S&YiGG!RtP^!O*OcFOyDl7^hzYt44g~WxVZ(@<@ZK%6l)Uf^sAh%^YY6n>s zspz*qiON~86+6@&OcxMDu+hO_2F|mGZMHA=)#_r0Xk=mqvIcbWi{ZHqX3iKrALHNS z?`DNPoWUF7wW)-17Ug5ktQ|2c3**34zar!uWR;9zEBp8*RJc^lr>}yaK?mIsS-SQ8 zXLb_z9FrBATF`F1VN=W!9*E{zMLnRq9@0*BMKlHWRugR&{~~s!;bQsQ!~+r8PTj1@ zFP$(%TO}AQ_WhJwu;4#ol&EVC_bZ8RHSOHRK}{OCxXuE3)Vwsfi0*MgZWl(}t)CsD;cWR7n4 z&vm8389xv$zX4knjuh{JC&qXa?0KlH6VqQN${-utDci^zU~#0?5A3&$Sfw$D{l@us z#9JUJ$qK^aNPV{h-UL8>w<@MNrhGXzW9_ZV6@G> z_Vc-zjmZxti@sEweLw5m8-H*=v#~aZN zy!ad&P{u4lGWtY{2JEcd=umKd^|g-osdk==W{Shs0@jp4>u0cY6r}ISXg84|3nxgu zND96gA%+R(wwAiasn2}{Hj*k*WM1g(Y4x|3(s1;#C#cL#k^eCPCW`JE1VC5@LsQ!w zrC}hdEgF|49qpir-o0r25`wr8L<@kfYgfUZ{Br*@t%YT&b;Tiir?`;wno>me+Dt z9{L6}?e6SU;|3>f$AeEK6tf60pR!-s9$Y*t!#cClZ|l7&fGNQyxSraJjJPr_n1d48h-X#Ne|>3uZ8 zYsAZ&;GdhFtSn2V3bS#)0(bc&ZbTHQHlx$TS@{^9l%&0MVO*LmzOr@N;p7pcGiM+KWe(|Nz5Tq;9E;j%daY&Q~77$BV z!pX2yqF1ng6J;DRgZ`&Lw16!qh+)sr-=qZU>QZ-#RUZvgM#*){q-hFK>Kz^^G<9YC zf|pCm;?~Rq>-YI{4Ph}6xT=$@poMM~DRn4o+*WfGs7FO;PyOLf=g#1ky=!K)E_> z;u;`cIBvItL(M&Y$YQ2V$<&}RX0E2bvXnRE|6_@(kT`ZNF<>nMV>8m#DHbW}f`#A- zfBp@w>uP|;SV=QB12qZzJ&}UFg{)B1)FcQCgCAQLc|*S|(dwrX>7>VltA_|R1*_N0 z1GIDmHrO-Q%0GPyqC7-7LB8=J1%u5{s*Q(OgnEER3bYN*)3hJU+0N>aoiY|!MkG>j zCxLEd&Q^0(Qasp?efY6L9XX-%y$y~@rA1j{u~3K$y>g5FY_G;&h06V z={}N~h~epsOvjC0@`GS$V5%N>ZSvx)-1Fj7to5rP8Ne7%v;QXlWG@uz&mLDEl<=G# zPBSSyA8f+m?B4aJF%u0Pa|(DTG8A}E)b|L}LNZ%AmWzaAF0N`szXiqttzQDL(3Jh$ zuh(_RWTACv{&iy+q2-A5*4egHOk0_ap?hk((kbYxQ>HAS;bipb!XkH?tpG~}*a~Ab!$C3{a>Poq z+{PC@y*4+RrPGdA4 zV`wmF2n8hK^91eo6YVMDde(wKtfB&Qn^$a>&!eIx*v$nv8k~l z_6LkCmQaf>05%IX(O_n+kqec?mpo3TwEwqu24)(wRU|677H?U7&R$e;H9Gc8tK6^k|vR?E}5FK>KZspPCJg=tt~n z>ctF9Ujjmf@<>Tl__#IKH7*(wSOpLCquTfv%MthD3e>9^x8|9=FV+@ z2?D7|v@FNpmB<^d2*%LaXr)btoWbimx7P!^GL($O&F2Inv;UWous%zWx^WwI%*iVn;UOTZj*zljGjD#we=<+I1DeNvaz5`GF%NIpK!@CTgs zW(NC4$*TkDB-E2Gc>_hA#x|O6Zit*vw|)}>qcbw`DJoWnRFY$ac59D55VcIEO0S?K z=YCPfkt(yj9uw+D5CdBJmPopTkOjpF3=ln3;g|;2={lkfRSyM8h(zA=Z zlss5Ttj@oWQmlqb$C6cEgj9`<6Uk0p5^E+~bD&49i2I$N(H0qN?i~IV+d(5h-&oPL zsDuCLs>?jJ#t27&+n*5gc4YbjyLOgD*h-H{rIKX$X<*z~Lre8xkh)z;om66SvzhDA zWX7I>8x1!)9C_ZD7x;@F>i)vL=z`JgC}|{$6$>`G3K8T)T*krorr(50aS%=ibC3ZA z4^EJdycNR6D@jWLRKXjkWrgN7b=IVs<5+DXWjJj@x=@VKahHgpZ8Kp>P8gc{=}!I- zH|8SYz?NCmV|j<+x~`oT<$M}=COD{`@?RvG*h4xnL5G<%$t7;m=GNc$fi-;f?LvW- z_q4`?Bhg=B*k@SU$VcD;<830Hk&;bkXfugJAC94NQj;+#j>&bE{L%(^lsdFZA!85R z3?TpnV7SveYeJa7fi(S-XtBC=G?#FAxv^kwkLHDz9%1YFvg0M2QAW5h(Qd{m6|$g4 zcd!TwLlrv&Qr1e{<6heJOm?W`ApVo6hKuL{sYdOevC|=OEJ|GkBanfiKH2Bg%_I}b z3uB6@V^*mJksFBoqTB9NGubtit_Q~2FD&`{C&JpS5rNq3q(5UT}$!#P_4g5=mCJsWz*4odEFE`{E`lVRxv_h$Kna5Pn{Aq%xROQYO3`d zh%Enl{os1d4XMm=dOD%|b7&@5N9&ZVp$>y6lnSCct>e8Bsft3lb1y6K<+~oEsZ4Bg zrU_Bat+s?2kCH9Is>O!a5|u@%C=L|BSNdbCW6>au)acIZ;?j4z{0MAGpzC4_ellxL zy%KRJj^GxbJ--S^HQ~L=DMSvDFw zgJchOHjGFVrEhl$Qde`+AN4~8?V#eMp6R-9%yHwyG5B1tLx_(_XC9otQJJ_s_V2#G zFEcs8nX@wzzS>h-^jIxr6|xO4xkN#UG7-y+sJ}*7=7r<2RrCmm^-6XTjodSy#D%ct zqe-aUmRk5Wfz}qJY5}rb{C0gwQ@q6`b$c&tLC>f>WzCSr2^cl%c!biegZlQ8Y?ApY z^DAT-94wMCNc6+1*`Ob}igQ_HY^Jgxti-Y6``+kRZ-Lf8c2V$?Le3a|ov+XcFW<3g zQYdKvR>kDYJKvl=9nIi`?yGV3!)rIghZoI~Yn2#k7N|R`Yt6hAq&c%#?V}!jrB`8b zV;X6lwo2{FEAeTQCf)Q*2whY}2R2>Vf9$jMFUVu6xe!|u31ujY(cx59sd5`opF*98 zv$ej`xqs}AUswI_)`bGQT>?eO>PA7yJNniz!eBxVf!jd{E zoRi~qRIQ&-lRU0*b+TEOQ#sOolQ+Xw8HT?{n$&~YmE4Zr{KlH(_XS-LJ+?xjLvrBS z(5u!Z`qb05`9!`=!CNAdf+6~G`d41)Pr<*7S_Zl{8aI1uIq7f5=o6KcX&pE^bhMso z;$Hz!a~7bUnY`81T7`S^rnIvJ}8*UqMG)kVC%m0HxMzx)Qy zYJ0~t;M(AZ6rc3C&SUhh&zcAEcUew#oklNt0&soRMbj^9mv0c(%iLYm-_W>$&doAA z;cUUlt`_r^f+*X7G;?|x_z@A_5r!#VW&OnR)8hTCcO~oLgpf|dtp}W!-8Db0s{L4j zMuUW%LKB-n4u9UoUYy;@aU#X;H>M=~%lk01t1`WLCGVx^uiuzsj57!mNo(L-h^N8v z5#W7w-fU`v4V3o^`r!@kL(2C2{pNp~D4~QFU{&qs#FOw2Cb7sQAT9_W8PRosXT4w) z1`B6=VXsPrX0xb_^NyAP8kKA`XuM&`u)4W@oET;LG9Cs$VX~ocBL9aj(1SCd^|-@a zj4xm+-|%%;l4E?(BV)`!%8n8108*`e@r(sVDjX4&MLvdozW~-r}^c?NEsn z03!zmSS8e&%>!E42F2-)Oq!f-X_&#dJtxku7Bc3P5lh{$#r@~B8gGbCy||uj-=yVy zI}2C;aL2y)hGW{S{f7B!4d(c=8zBRw-eQ#)J>yXWHSbMm5rYT}^NNI$65kAolxg3( z%NcA~G&>jgV13>C)dN%FQr_*rfvgovP#7lu8`ab_Wb$@I#!`_MYSf*&qyl#;e1nsv z06nBwNFsv^(oM~c&H>d_hKivzD0!N?23W06&udk3UWxkaAfs~}HKZ-|Kawjr{GQMp zfE(yQ{HS3y6ivFkwhs;c$eq(x?m?0aDtzEJCJ@LgXfm9k`DI|a>5O+yg_qT=*0AJH zBE%)nD~1^wBFdUzzBM35Bq~g6(|Oz=tUi777f3e)q$0gT5pT(1Pnmnc5usq7%R%=e zvupwb&pUFcT?$8A$I)KbPnr%PV^JNI3S>>=GovGV#2P|U6}H41S{}a%N6juibb$@8 zy>^$ZbL8&m2(g*zLG{0F{M%nz*jHYw)Yf>fq7_oZs>Nx;r7^@B&B?nX`py#qctR zMHHkjWx#jFSWn6Y9WId}3=Vj5ybxQ37o$fe2XXE+^NdgC6jM6LjZMdq8>9D8WP$yA z7>ersduD~7=GRjNokz#-umY48w+6Ym82|eE4BpEdx*|#;N+BtKndaqbFXJFLD`-T) z@>hwA{_^Yo>jBTyi?+BAj6E*YxjGf&#rJs-7uRTOAX()RrfZ~1l|V}Z1lbJ4L4Uzl zWILam%1#l$SU5qm)J<(#W2^Mn2%_C=R}=ZV`5sO$w5@^a4KA0P`qGhVDXM~(U4m&S zeo%+UyAc5(1qUCc7#vMzR0PNdlIs+%p$+L188DSue{N*17>^IfEOR03-d*XR5!+HM(|^2$zJL9_*o#7Xm$cPn}n{WVfy$bFg&4r&#zel5@^gs2NjbQ9Ln+@Xh&J`_0g zeVTqQJzCCJcY~Bkg{DIRqgG+4Tw|scd|(0ey-t)0f$>WUgFv6T46_?Ky-G{R z-H&c*_FHGTePb`UP(`~)Yx;+h#~Wg{t7tpqRkV=pKcq-~fBjnWdjW~SY@5mdkbK#& z({ij7k?oHaMjcAvsi7+QIYKJTb{JILfGK(-oJ|JPt~K0et$Ry0#;1OrnBJf|a_f`fF!utMO5x9ANR#74GK+a;WlhF>8$-dXH?_b;vvQ_6mXuGxBZxnBDtAgAgbMy zBkVmgQyk~7Q*|5BGc<<16uRMJdqF|bBN9&mWDIEjZhjjLPP3%x^mOTRZBge72_Sc(h1Lxthi!6q6R}btP3;egP*7(6BZDcL#x^kitj3(&-7RO-hO_DqX&8)OXk4Q^mxY@a~llQ1!?=Iy#0m5%`3OHLn!VgK0KHV-sOoz)7uBtQ59+@j8s?A7EAoK2Z4C-JXK(mle?;%Uh}SlORr+~ zk{e2nM*BJ73Sbxj-^bIptW_h><5<{n_FEvBGMk^#9o?br zA~xq^XHdxDZcH_W*-5fc@GeGcVvdj8h$4yn`Hhvi=2;hV1b}n`w6qVp?5;IK@9G`lXH320k6xcb?uwb5AU+geW%4-{(Zo8j)jD zdMb|)IOb)38~`rf;?N2-3H@vHcwb6vF#6Ie>k$#sekBM$dg0z0{6Ku6mZJ_n`@YE& zp1H~rxSO%Ne(dx--d(rri|^a-L7Cc`n zY75F50M%xAyt_AH-XBL82<>(u*t_HKteB_Mfvm;twe#t$6NqtsnW!YE0N39$Yvwqj z%*v)$2-C2j-fTx(kIPLOORPSikO~f67RU6OScb@w`n=*QEpZ5~=Y4rS&oEB2lkSX2 z%StMEcTfklz95dWM(kr!@gkJ~Ghs&w@!v5ZD)bI)odzCiq=R~XEeAiCWcn0KXfLwH z6wR&a;Y0~S1k-*UcLwL5BGck^rn!YcXyISbXl6m*^*e2yFUvQnzkCwD{W1Wy2#a0I z_W~Hd(}M6VyYPe0Xy~1I3+3}XK8eHlv#gKmxH%1oXd)Lp7D6jO@pFDkJpI&Zft)Z_ z5Be2cPhDjyCWJlI0G`L^Vmv_r=Cn~azR}qb=KEuc!w^R6<xK z)}k$0vWrugRnZwF9F=y)WM(yCUrRThxVAks*ipI&T`z2n>5FTJ6bdrmFUhCIr|eF) z&{Vqi#9}X|?evu!5LxVO`;MG-YjGtDL(!jsCmE>!VWb`m=J-so%)HQbU(>%rVx51P zfyV)_FTL#VCEw3J4lEMu(hNMlcaHGHn-`gUyjBGDN@JTp|17#6M^!)$X>xZ~X%cHr zr}`ty7e!77Zi!(PVghh)Z2;P{aV1vnXV#DA5u7o?rwVh{BqF&H8F)7aOs;S%sPRDM z!TMK1idXm#4<8sJAe*NrF7Ixsrw*>aR<0z|+g{(s=jOR=PMXRUw6%VR$!2!=oSr_D zS>46ByYA>-0PshBFht}us|&Py_z8E2m|lpxcJ_WX)HjCYG884-k&<(sW!pvA!u`mc zfhO7wp>ix5o-wJiN@e~1rJXrea!yr4J@lWFZo*k_UidY*p}8k4Gq6V291}0ah3CN2 z0NqO^$}JJK3%;{~@rj72;)H*U>&h#bwDtmh#ph~VVpD=Od$yiKICxj??a+XrX5gg_ zM74~Nk+hPi=XI_%?9{r^Cwjw;VPV|juuVlE+Z%33N0k1jgu&6aKIG$d!(qfj@Ci># zPhYf-cF$%+yNl8f8iRVS2fG+H-gy)VBrlo25tJp-b~Xrog(|{RX4DYlZ)=TcbRCyn z`3b4BJ;uf7tW^u``KCTI5ef-I(8O7{09Mu{vjKH6^{=$VDm!P_8G-VFIZ* zMpn*&dUvYZDes8#uovy<4>e4?f1crGomtY85Q`Dju2uxw;XbVX+BlBKfgHC_!ME-s zQH&oBsMUJxF%ps;=_3DVh-T>MoGWNS z^zi~dO@lT>qjBKsi||9u1%StsFwyoLiiJtFmyO${ibj)E(8}kD~B^ybuv1(NrRvBWYWPQa)(@h zG@EmxUQ!izh9=u6SyrU@Fom;&#lbwoU13$C)w^oHJg^V)xY`@zRpmm;|P!GD^{@)E&n zAPhYb`(A}epUysBM{jlpb?&o?KjCtTR~whhi_;MeiU@{)_l?|KY#7HL}9AG?3Vv`DH=yp21&>}WJ#)>9_N8{h>#Bm>p)CVQaR@86QBpD}x6Lya(d+g>CGXO!Rv3DH~*MJ$0$LHDzdhk?XR? zVh~(j#1(l`#H5dv!=x6T#$yYPKSFM^2@3X-=By1QCh)7UG_Eu~_BTzjf^bE0q5R)8>o-jzCv8jkiC`lv?Qm9M5C!nWh(Fn4=L zjKOX_x7Og+==Zq4?dD8=cOb5v>UZ`^<&q6L8DC6%ilyS!_K zu-sNUte)2+U%Zy)V}s@+F7Y?RFXU{W7~paX>jQD-1vOyv!k@}L?-HXKWh3~OQcB@k z=gB4S)MjO;8?0{%aP?bE3?K%+Q<8aZMXBN-b2`$^_1$&0bW@c5HcPBF2r2jG8RUT3t6=qIOPwkqF0hP6tQLG`M z`63e>%&K%N;*Gz#HHf7B=*ja1!61704l7v!_N{!=N8Dnd3;ePfH(8V_T5~czCff$g z`II`L83*~oE65_gqA)yd@b7br;#-_y#K=~nsp(Eh;W$Hm)CJinKw?*shy6~=eH zw|+&TtG?M-DR8@zpWL)o=1EuO$LuCj#z#$YKJ=dKNH#`Jgql}C3PU|+(sJ44P)MfV z5n}h*gEy7|WtV~|FnMqCS_HL&%84nIyV~5X!y+owX~nrl@H=y0N>d2pp+X8m5lLkM zj(E~U$jr~7gAfeg+T3<&scC5T+!W9Iw+5fU83x^6Z?=~A3hgY6j1DPfHO0#Y`W++w zVY*)5HNn?EZvHVj81ttcGzvGNgl>^+EoRp*Nn9kop#GQjO(}%;e=2>-$?0kAm*eBJ@16Hf+fTsF!@qhP58WEH)KExV_Gej; z8BuJ@Y%(0qe=ezhEm1TZ^ql%y*ZjC^%GP8x;XNaQVlStZXxPs7WU?FoXd8dOw1|1# zh@Dw|o%r56)qAkx`)w2@w-Cb-;0U#o*J86=((8CPnJ08yJ^pK#h`AE7eau3v?nKxj2u!+_(n_0<{st##s@7m5{kyYs?o#V!I zjv&N?yY+Cs;#+NiazI!hwH=&36u1|xJK5myF#mw==d5nsJ4uVMw#ZT9o8EjDOohNK zHKq|$F9mf1rE|%;ex+xH58W@cz_sLrNOO?AfY0rO%!czT?B#dep-EGe^L14nG+Amw zZLPknPa>8}?;1%QElOdp@1n?O&qCuWv6Mj{j0my}K&=x1Pr z`*Om$KQ=F%@qtK2w`2I?>Z1?a6(9i2@%NicYz^Y~&3dmHlkyw?hu0r(Bz3$G1mArX zKJ;-MPE(53;={IHz}Qf%XM!`mw0ww{CdE*Qy^oY%wvWy(NMGd~-9y-KHr1dTDWd5G``VpC0uNRvsxIF1jFf$Ioy?&5U2zlnO!2 z@L%=3&GVN&{kU*Zo3cTc!a@nhLJ7k{(OB~3&>}EsS=0xeB5-|BfkBPXSF>|_0+iVX!eP#&rsHiSNm&VUM+XMHZ~_9C z(Dj9uF|J#W3w5K>#1E8=h|5{@84{w@njhu!>+!Xb&k$pj7^3p=I_hGxPoz(|bW~i$ zGM?3vP>}S`kB^C+%m?^pHa6pkx6$aMFbtksP-(lz4?T!gXTPF+62>B9lsN`k#|7V~ zIA#X(Yj@!<(#vB@Zhpo8)>m6NPI7@LTwzJ95IwTp6l@Y|U--#a@^2j5@Z1YN={Yd; zt95=Rm!lsS5vIjufnkhiR1WzwUgLuo9Ltb?AP9r4y|tFYK6N5PUHi^2O^kVJTvEHQocUIyS7$^o98KV7VLjY!EDJ<;_ZWK0ER` zLLLHrKfDdZRQ0+uG|I}}>T?HaRIzS0>_Y7)KKtI`RqcY@Q+qZ0;vm)QD7*>#emibt z#6FZ_krVIHy?2-H2t|Lt>4u6N%4y`~i`As)&713e52bZY!A;|Vhd#^%&xIx)igJj# zV)dF`D6^#fBEClGh}hPmOaZ~|-{ds-PgO+69T0Z=F!*U~@Aa(wL0yNk!;e$0?LZp) zq5CTb1v<( zND8h8_7^@gJ#+8Z&)Xg-0CLw%W`R|1Xg6n$g1x#3V>p(Uem`Hl*bpJyaBsk)$)m2(v zw$5YCqMW=^%KCx4Xb5+6hfN+A-aL$eQRb9N+=RNi{Uz%&19?41^4ty}_v)9how1vr z0ih9K81&S(Sz9q&0(b3N4=tRO_-V6F=oWi1Y8lx$P}*!sl*rTJ9aR*a>_bDs4R+5C zU;r+CBe?%MUud7tSn-wfKTmu#KIa+Mp42muh7#h5c{nyS)Tr&v9gAB-+QSYtgT$Aj zm`Q;nJ0ZKt&srFhGLZw~671NiH4UC6=>e24tWj_%)o1n6hg9Yh6;b&I z-6))ATkn{FBG~4VUobG}V;ZFiGV`i3W*K}Q$GSasdWJF5Ny9&9Zm#t)RsR_I>n{#N zRJtC9?f9H|6-`!nK}`KT;RedazU)KiW{RU`x-*}=pe$H+cHscCD>>a8fc!JrfMt&5 z+ZzE)A6Yqx&6kLUi-5X*Da|l?FZNF!{&8*e(+#EDwl?QrHnZY0R#D%vYW}j_s3j+O zb76wt;jKnGA4*kK8M0`j%;^!PBa<{`wm4;~KOVlEWZFty;N(Asj2M{0}f-K9|r>2Hp!R7eOeQq?_2tivj~&va=fwOUIancVm8k~UO8 zZ8lL7K+g#ZqHk!n1;$FL0FTopxiYjmVI54-TEJ&>cwA#V2rOQ1XwT}};8s*+c?_+< zJf7=&6OL-P;3=-_p(i;-{!Mx=ryCp`uQKX#_^|?5?m9L^k9-8TEbQo|2m_+{Ma|8d zhcB2!9e{8*um!8@{>Eaj&fFjMc*b^k&M?BD`W1}o7se}wcq8jgOiotoCq%nqXGB@b zx{G&O7JIMdTW!TbhaJeKOL|T@7b1@;l-eb_t{3$^{}VLq|F7dAL=KnMq*HHI((Y7~ zLM;YF$8}K_wj&}_YC^y@DmZ*RS`Lsq40+G{6yf5U>aRa!KoP2g%(!%f33?KKhboSP z)0Dd6YBC3}3Y_C7Km1{?+>-{&kYMyeyVZueV}7NkloE{E2a6&Gk)^c&=d;O-saSWR}&or)SZDhZY47s8QMSek37O(@oEDBymWEjt* z_V~eHOzrRtXJ@p8Ct-dGdsyv8Ym4*!?925=PIsIa&e-T+n9V_V(v@!+BeiWrOwvu1 zPR1`1kua&)SF=72g9-dAJY8uL3K2a)vGV1+Kf9uErekt~~>5v=y~so|~U% z@&27XtfpSc8uTkpQ%UiF@`>RQk#fYsIXRh^S(|*i5Hzo63xyKCxUkAbVoeX71lIaV zJC6j~7@-`Q9;@@UpJ=X9V~|V+aAaHKr$e#pb2aktfe*uCf=SSEHkbnex?>G(yA7;FEz8 zK7aQ8VT84f2w47^9m4qT!#pp><$tK5dD|1yXsO6_qKueK>Nql$@06!?{^+6FsuqiY z1h-82#ld^6iDmJuz?=z~=_scq30gpajSMs#O`6LhO|*29y;2LK?}q3whLO?D15tzX zLtxYnr6~{(ULcHKMUCo5_U62yy}np<Uat$kLm`3rl6lNE$Q z(rB5Om)el^^<}w}cCJ}3?5dS4SvuKi^%;%GRM$ksJKKbkb1FY)xujnCevB1{+x+b4 zA-uMY*#9Q3OOs_OSv<I9|&Lk>MODZ`Y&Ml};W1V`#*g^IRxiuQHe1^J&>pn+G* zrt*V%83!#!1_T4{6)TM{pe?gAVNx#fTH7djExbCG>C*^s1*>vTx z{cNu1vh?()lQ>l)O(_AQB`zQmL>exjBr5h)f+6SMbJ$pQef)ECAtrxdh1^o|KrGZ# zkdh8~!vl7p6~F(Y5}YI~X-1E5Q_^`jG-@x98G`*^hZvZ@5aj;3-6@F5MLhb<*bWt$ z>p~b7pC?4X0I2km$;b$pKyU_Y38@!ACeIPLUz7DXVf z15M3!gL2 zFA=aBCqBeZLuKt;CFWZ8e;} z{WydHA~^Y-U|O~b6#;?8!Ijl_K6{gr9r>tiDyZ+$n(vMB_&i{Yt&l2)@-|@^+l(%h z2?DQ7@jG$R3gIf>uv2<*VMdW9v!6Osjp~p&I|^CFk=0|(ezSok+uKja+v#q&s)AZD zsyqtr*CgV_HHX6WWK|xTC-ihAg7?CyqXDx~@w$Ys$-qZBORV z0p84y>?o3!&7r1dnTe--ya_yM+9f|smD{4AL3JLQ znrL|TMSjOx<}Pt97rq5NCJ^yh$tBh=xYJ|MX2FuVT z&)ukt-2Hg*9^GPqo+T021iY{PC96F1U@Ao#7B#UpK>>ng%Tqcs@NsotUgOWHs=47% z@=%oN6>uE+n_Z9jf$&vNuJei?7E&HE0MumSm$t6nx0jpreK*Ce-Uaz49X?3^^rqaO z-2fyTB!w}hWveu{iWW+r6rlP29uu<`)62r!pdEuQG;c3mlp2vz=T2_(prT0gd}p`b z&6~jQ&4pwXZ>BI@?zX^T`d@u{Mgb$qa0N zQplwuv|O+I;T1uaSfej?L<`e4I!-CNU&tSTf7z(m3Zr2vfVe`{`*&`i%WU0slh`jC z?*DUC-rL5nXT&W+*+}}3%EEm|`yvR;4KK=%+Q65N6fc(@($MYjQf;N0A%NKve?&Mn zPhN~6v@_I3R;24pqlX|zizT_fP;9Er^4w7|CSAL*guq8|!N2%Lj2y>aEOX5Go|9?x zUTP3{Y*$J|z-hle+jnq$*|yH+i)RKFATXYfqEcn#{UF4LqEg9a(_q8WC+X$5BQU+* zK{V63qY@)2hI#Q6glGV?7j}yL8hWcVg&vVBN*uavfJDD z$usx)WG}8mN)fy1lM#Shy+b8Qdmf}h4?mPjS5c1DS#PEQ1sd@+*|&A;q}oi+ko8*a zP?kcNt!?cYURD zz}d|q#H5%2fEZmUNPwX5!-mTYe(KU?i3&+e8!sKEY@%Ur*w! zQflFDa4~beHZR?8Dz`{QDsid8IzXK(_B|aM8#}tVqS9s!8f_oBYIdXp7E*H0ne7&TC83wt8SH32R)_HDHE0*-OQ0$ig-dYB8 zXqn`-J;vr^7XkyIoj=~jkaw|M8t@$)ViK9dP#w6vtkFIxT`;_ZfeFttzlDd2LC2?K_01AaqQT zkgB2QD?b}Gp`)o8Slvgohp~|rnkoefvAUsg@*AeYp7F*?>6D*&Uq?}Hxt8m`@>@^8%T||gA2Ag7+shBI$(*q{F zhX0=m7=i|XZ1O5POT}DZC;!Xc)AlTh)Z9r2C&XaZTv{weuOkh^hQv!Eqnm2!fMH^Q zHrjyEqo~VN(rUC_8af1dFMfw>&_nJ;T6U#HA0u%rqSbLnT6z|L0@2;<4o@h!s2TJ} z-*>t+@&xDNYb{hjut{}v;2<#|>ho7eC+N%bgSx0U0<~It_nlHiylk`aNX}tD??Ho) zF?5>UdZ-b{!~%rOH+Hxd{?bcT7kej~2oLdg>?WyR#%nSEhqAWAz|MLosIW6JNA*D; z$z51onliBEU<$4McPRPwN?zou({>tEBQJu$1>NH#oV-1MMLXjI4oc(RD@F3G8@x7 z5AWHgA&XXW$Q>WeY6%tETiuX_PNDG{E(iXd#v;4&I4Yg%PDC+x_n33@oh9Ao@T^Q>R=P~D_IB5ki2m+?{Cd5GGqhN3C~mIwd|Q^# zn82(3Ey5>be6V`WGZg}z~hu39>}m%e+oGZ4(+`Ty9`4v2jF!iunj=dy}FAlZQM z#th$;<@gY)xqBA6gvjF*zV%`Tk@8p=YOqV5YIdVS^{L|lH%k*R`Xhm_PL$yccYb=fiNFyNX z$Uq1|gp{1M?7Or`!|KE^%T-0$HDE2P$zk(DOZ+hy6XgV+uyq$IU+3GH<8vY@ex*8F zv2~&}DfIh7?aBfejy`$fw%e9*Xtsb604)J9oZ%;1&TWI{yT}7nYVJ1K>4+I?3Kq57CQ~~5G=>}x)3=qVY=75b~`Xb^YiuC)~C zqCZd@25O|y$>;#RsyRN`u=jZU_FX_x>S|aT1%zZKvrmxQ2(i-up=X~ZdXfLZjDLUq zd$G8_g#JVcU$iHwbtNPs3e)I*mS4hfb%wOf%iTMCJE92%L!9lgB66@ta~DLG&F%+XJts8}SAywiiJ)VT1Od|1-;B}>*LU&e~wK#?Dy=p^?(E#PKNZflH zdtTwus}%VlB!Dwn`Mj!SM$M;N425^%#S$RvUP!ATr{!-`%=BJl3sU5;6O>u-0T2a& z&YSLS`Jt=My%qotjS~cFc>v|T$NqsAR|$nH31CNQy-X$-={#m?bTT&-ZzLKs$qJN! zb7!{deRx?Viy$dWS|=iG^VXdF`1(Bgh81WBm{N>UxOOd7F#202LOf0Acq`h_&t%=6U zv2J8O*O8soB~Saa$zC?hhr@eJwOdP&68-=z%071*zZuOOUW>4#T91V23X3?mvDQiy zXv*0491Q?6$vHukyl`{>c>oA|_H4idn02=T1Glz**p&sC$>#@{iMpP+oru>I)j^St zvvegqulif0&#~xkC?!C~MA^g6{xrO=n?y1 zIFb57fj?RH@ZjP}{K_Y9z_cUyprtR=7K3u0uRSJF;MU_uaL@8dOcX^!Q0&CM=JR;5 z-Wru7S(r32w96zX=fv=XF|ihASDs~3s1uOsastj3&Gq0}Y<_U(*C{4#S>R{cRg&(4 zQQEta_o|oHt^;5OK$ZX)9xErCAf-z=*_k6rh04c3RI_P?xV@(_)Q+(hl4-rxIVBz3 zsQh!g>c~gJZLh1bALqQn9=BZs2~c`IBd`_b3a8qsxo<@V&RA-=Au?5uE5cq*P9c zU{f=0@RSl$)v1y#cH%J^FgoJ?u5;}>I9GrQ;ZhhpG@!j90~wdRk0S`JJjlAc7oLX! zD#-EjYDM=Sc|QO%0I~$Y@R)(B+LJ1qEY3XTH~XylN*PnUD^I3d>Ll$`#ZXu<{8i8; zT>!J>39#P%XCX|)l8GLqi)DorxeckXrg4q<(d5h4TZ+^D&AnNyU*^GMyK-jnE@c1#nq1od8Pk!%C^9va1QVN^5;+kaIVlCl2fQ3$u3D8N^o`&v;%%h8M0d2W1)mNPRE!l_AZ&{@amh-r|KIPSNZa$=|Mp8mPq`T-Ol=-5E0J4n$ zUCTf0FHd#@S6dY%_RQt+7I9VvToOz4(n?qf!m^$(P!wH)0|Vzyx#vFV+$ZjI^B6w{ zXji>xarG~pCy*oQ$1ALuk*gYYjESWFLi!l3_W}q*Ut?u7ghL8Og1;yt6L+&JhEmLv zF1F#4ti8u%e-eLr+b#Iw$p^8i=r!9E+z$M=CaQ%s#6}v zEn=zMr|+R1hrHdw-<;A!%=;~RJr>nE?9aN1DGGT4tQ{`27vm;FaEVZ zxwMX=D8mbP0!R2nB#dr^Trg#`7LJ?{f}=*+ZlxZjk&8IrBxd|UO;1PzbcJ~ob)Y$C zg>$HUg+pKX6bd4t;}`TDSosV@UkV}=Wr0<$@avzw0nYW}tUpCnkB$xm=iM;sa*20PP71VVt5@bkm;dIS$F0q{*FY3}0K?EwC`Q4#?smu%S$G1rPHw`X+it{%@BRX2dQ%wrN{d?QTqqF$MFI?w^h5%n zAh@!WwpLk;aLR}ynC8JJ)|OBQ4TDz1D75%<5@`4xOdAuFHgaMoL!CO0UfQn$uHx{1;0n~;`!ShN~H`m$)Lx}}J1gOr_OJS1GHgy?s zPqgfTPE3!4sKl{r@1Rbr7^;m15nEGyZPoxI;gCt`4G=xNAwZ)Q;FTaz3Rhl}z9=V3 zaCZ)z5$W6|z&OkuE=e!z(j zrUkfx`Id`S{hrYib|mECuhNe++6OsMOA(-~K!Di}#@rS(hK8ttr~ zLRL?L0mvOTV4Ilm!!rTvxSpoR3UXUgeL_b{%qSmA^ zbARxfnXe~+$EpI(HDiFakKOS;I=Z;%Tv2k(%s#6a<8(p?WNt-hNOV2YmVP3y5d(KE zoz!Z2kn5XXwDh4`@X64I^vupo)@A3kQ!li4# z%Upvy>X%?afR%G1VPe#FjhG55cwxC-zw_iDiCKri5&*+vqWC8;5w$ww5caHXbuL)+ zaZQl2^0^JRNnbn|rU;rJsX(gkzOo{cjOQ3NWP6hm13IsK@~(y)Y3RyPiws2Oc9bhl zpEp+DRR!1m61L58z!leiYj`ZAImM^dit}ndur&pM&$Id=-dEbgPK7-j6WJ0s+nu2Gt06 ztuCV1>xs9lMUb>a@i@(juqi@9H-W*-9=#sbCzn3H@}3WV9tRFM%*|N>UWB2Z}TXUf9?|4i6s*kMw7OxM%?G{0r%p}#vq;@Dsj!bAN_97^y zHV*@s==X5j!ZDm24pF)y8X84%UhFXVl>fDFLpI1s8X*y(W)D~P@sIc2j+4U`^r?tR zYDy*(p)rnjAYAz3CEs+xrh9$-$OjJLZ}0dLwoY%s$op(qlXYyOXpzb+k^O=w>7HPS z6%2&rZUr-==LknumvP_9B6?++JeK5z$TV&u65pHRnY7#-7JdQnHT?0#-);VJz1{l_ zmH<2!aQ)%tF1uFOA3C{S6W3g@mj*L!#Uxweg3->IH#{lFF`*|wq0K4HaTvM%eEyt) zLqM6U9AW@Xd*cv$r1vxPX3#VCtm{q*X`Zg z{T-j5ufa?Uzx$pK^TP5MXrk}HO|vZ-26zFS>;7NQ{>tK;C6Mn2q-oeFTV6P^*+C`C3QPD0wW1k}trJ+#)pqgFYvjS!yV z)G~G#yc{75Nn$TVSX7WqPKS{k)#}HtZ(!XBae`vNwXtvgJn)t@Xn#=5uUV}zaK!cZ{G$t`h)ul z0K@Y5!aH420M7Y*#Vv{_b#!r9$>6tUhs}|9O#pO6SQxD0vk%=1S5Cm^&U8XvS59T* zT3pVWv(R;26oKquGd@i#XgZ?*p|0Weqs@E8Hl?}sCx`shOS9~Nqdi4v%NBRRsY zr9Gz{K&-1Z{|@mvNRS5ST^1@fUjBUeIcaFqrV(C+K$4w&t$Zd_dg44?Y}e(hT46O! ztr8;bf5+DfU*q)0d*;ex{qlVazsU@m8{?8TcldD44Eo`Z-t%Yw_|Whc z*DK4qsz=`YXx&1hZSS(mlvs2dqI59p_A={$MQWWe1(bO6Z>@0&D7-Im@wS<3{(qP4 zxsvAK4`5@9;Ns@n-hsuH1?u$**|lbI#yY`MtiA~i8ZnZlw!IE~`2MdTc?k#!Xl6D3 zP=PxiiHLC8cMf+i9mm~^C(v^xn0;y($wyK;6Y01$Nkq(NC`mATZhQh(2LtT?^WVhv zcYhgMCpR_YfQ(<#xCBT(hgdh``Y6l@4F2p3E?E}>RPevp|K>q zYGW-HL``qyFPr6#wSa_r3Qoj}G2^)J+r>pni%#E$j9eqj04 zpFipE>Ca5{%6^ZEvS{7|n)mNqQIjicR}{5#MePb#6S?Ml?fZ!Tb*^@f>cX{u5052R zQ{ifI&2tdB+ELMd{-Ql@TvP4ql2M~5*U#EE%`Z4-`jr51aNHPR4jzXN6-WO5^AFO| z)xV~RJ`qt(Q7jDCP&!K1!D`zi+2BGXv($PKFn8Bf7>wH2=u?4};X1zU&wm@AIdV5P z_a{-eoB(DBZ-d1C=Z=R2k99C)Wka2uD*LX)eM=|tnFsH|6qTsGSC-fvGOXlZ%H)EO zR0@!@~S^pTvO!&ZK`00i_*+$5s+>U@yw=Jbdpj|HM`2efdSZCx7~a zsrrdCp0_tQ6xSFAg`1lkSGEK?sYvFM+<9csr^*}p1}L-wN&X9rMI&)v6HFWtX3`kP<9ZFulK_Z<8D0l?AR#%tQKG6~)8e4bjkhpYJD~V+pq09@nLN6HIK3nw? zBs+lM>bf{~(&OVR{VxN+{CIDGR0-a7{QsqMw!MxE=aQ_g81IPSpGajw)sOVK4xNrE z*Tbz3J&1q2?{>WWk|$%iUPD1HzW+vYn&apsNhd>ZZOEl}=Wx&RNsPD#k%K3nSnrgs zheR&rc)fUTM?fqs^3s*T%&2^gNmpWVZ3W-)=kLG=zvIo=J-Hdfden4sGJ$;(3z|wq zNKt8ZcOXoD%QHuZY=hx>2G-Al)?eR2}s`*@6rP9 zw>4k5-dX$KAn%8U$J#vLKniY8m^pj*Q@2#lswVmbG~Ti$fC2@88iV$~5mlQxmPO-3 zi{__*8g={avi)xj@rC#}U~^GpdwMGDngB|4CCRJK7iyp^nx_j7A5+W7pR0@Uj|u1j zyk3F+^2pj}ZaQ(|ZU8t06#Gy4QwawEH`m@!H*Gp=&l}FcGj~rHRJL!iD8$>W(|by9 zzkP$Xe(z&5^P&Lix_ey2v2&<-2B42Uw02_t{u3V_-%OVupjijo@sn@(JGyY!3;gP! z26F*1rXc7Yb6Mj`Dro##n;4c<;P~1SzW1rm#&5j%+p(~|iqbVsfb3YfL8iY)%i1>5 zI=Dz6teLT8Y6d@b_&xZ!Pka!&C#F&Px*anG;i{z8r+g_|aiTCkkIIvhSQl0FJbG|A zFAAcp@{hEJ;;c$?Icqqy6=3Y*ck042e4QC^bhsZTA=hLb z?JsCdW4xHl3*QDNQTZk%!)6K)TKs9X)5BwWateQU*B5c`@*;LlPNMQ+l~5?v#qE;t zdLfXv&wmeTXISy=`e_!s;^0!X--|&SP_v$bVm) zhWx(vao3;XIre4$zWy{{d*4^lakz3%bAP^R9(<~n|HCB#b92qHoS%ie=?Lh`J>Z*; z{Gaczh1b3LFqlr~W%f_s z-id)f)$8G|)djro&Rg)Wu6i~W*Oo!ff!If{hrEJKqk%AX@bE!1fjv&HufZ`%c?Y%e z*~UId{X~wdmq^|bl#&rSa0+Lw;u_ngH{lmQeH~tQ$(6Wd>n^NU!Sk8Y9R#z8-6%R!%!SI&2$+ z3ZJF4yX)CEE#F>244 zaXozO-rMlruiS{~ejhbAK1ISMInCA?LROlS;-q+m)a_1+g(2}sf0S?>z3N=(cb8jWYqDYJG}PW>sy~T8|I&^4)RDWf zDL5K3#5$xDS<x( z0oYO6anC#-qQOlV39uA>PLi}?CCz(=W8iHc`yiHwP4({ej$Ye55E8^<@r@yO?H$Mw zsB}U~T2#o`$ZSf)W6xP`K%ICUKC@?_V8X)cD*o3OK83RAMaROBPmr)hX4Q!h-4Zbq zU}ls=SXx`f+dp*!CJGn57sBkH!Dt%xBFPcYLG0kjLAz%l@CVotsJ`#$&pmkKjVqYn z?_k6~gCzh4gJHuiz@gdV*zdjXIzGDa*TvLC(dsb>nH!FxN|5FO+?73+RQ1|>Z0=3q zWB1;UKluE|F*7;cbOC}juk;0wGyO<}Z-S|307Vbmr)C-vpCW7wG`a(7_8x<95K(Fq zl_(}96fa45S%lR)(dPGHOixVU@9+2mPOL4XUz9O#h&~w%wNaoHe?xCT=nYIyOyQk3 ze;l8A@D6P5^_%w(DTalV*tBi^sGQh#Jfc!(Yh2VOO|_evaQ?`#&z<<~_x=tJ9B@|s z8#aP*01W@PK?nAt*oVDeRph|01zg|$ci$s;{Pl+q`?-zwz~`HwQ{Hjpr?7MDOR8Q8 zsw*Ui+oa+bXWx#J#zWu@1hb_^hN0aEU~HP4!rMOacX;KcPsO(WL}Tnjq~`A=nERod z5QV5)4%?22Y53ZM%hp}8U96`}w*o*+Rw$VksyThsAch_xFnnCs^URp+_wkjbW4P(q z5j^|+ORzFnj}>_1f1$Yu%-niSG|7QS>k$FQWUq%i7LMZ=KK}REGSP#teUv_C2FMA* zB6K3enYDFnV6-M!>*;qKpYPdM+5yinCBToK&m8umVE8{Fu4hbVv)$!@JVlm}t5*T=2P z$MN4k{&#r$3txwY)kP2rBt^mS^WoP?cd_}9DMVFjU8}!#@2Afg}2~+`%DgS93KW zT-qK8fgi{;2HFgyCtR{bOM z#Z`T1@iqCn?6pE%^jQ+G?K!RFxz-i#tOD2W?EU2BJ%26kIQ|80)SPt&Lc};UTRil> z!w>AZaN9w;>f*OCp(bzvv63ep1EcVbB;s-E1|)WWV>;+O<46B?9#?(ao3VRh5<_33 zP<{jYn8{55_8yPlwj2Nd_U=8-vZ}fh_;2lVZr!?7)m{DOt%#1A#)tt-OpIv|CL?(& zT8JiNGMZo<5+@UUWXz03`OzdA-vmbS5h3wGH$*^?2qG2`f?@+|Xb}}`XqrdU-So2_ z_nf`vk8{r6YoA+%e#XSm@ca5{>F%n!k8^L|z4qQ~uQlB1)WI0S7KEypOCC!jw+ckZ zN8bbp+A8A*O1)JMIXot2tFMWi(Zw`#?w zwQ$T*F~Tg^9@w%iZj)PWj+ixtTVM;*n8G*BFdJs4>7W)n^;IQxn`QH)_>9J~u}y^- z5h;6R!OOI`}?JO&BY-XkzAbQBGvDqnmZte`W&h`wEDe+<|4?U z$fa5V&qXyCXzm2LK&#t==1w&i^*P|Hzt`sL$VIB}bEy8#p}Gx$&!NrtS3mDmbEiHR zUp*E9a;FGigI2fgfE?cEBK6|};(a!cSF8Ji*0+J`_M{5PtMAkJcj1?64rnfr`g@!E ztRA!2$6P-rsOIpRJFhuPKU~h0<@1*h{r0<_bMQL10i{kX(1Za{oVG~-lydijf0XT; zhi17#7uq)dJwy+(tNKINS;AD8Jx?^5xn*y zPkh`>JL*z_(cw|NW%^27bN6?!a;S%*^d{DbCnr=|%Ti%WBW_!WX^{;G)QL*zoxA&y zvv%G1?VYW~4k88s^dE0>9Wnv*caP|ve)N*;pklr#GeM^HbWNHM@z9aZRB^LSX;i|V zakD$n?)WFrdjDABlC0S`7Hu!Xw@b;&z%um0#aUv)n%Gr>=Gd0Pd}#YLZ_X5}5iI<% z-PD$kbHaLGytPe`OjyQRdJwv=Vz|@XLtYh4@<9` zsp+%i#13A*6+Ix#{;TV6)>{N{PEe|1<>(T8@Yd__-mhPcCB3C6ybl3HOsbP)K3sZ_ zPM+h~)rVuQU%*M)EU?`?&<^5iY8fP*d_sOf6VO!W|HJ&dUVV_#?%eL#bt)yVq|V3CAYW>VL81qB$EWrY?YL z;f9AYfA2$=?!4g2&tY<1&cr`r06>pBXV?Edxa&^nh^n`H{G!RMt3JWq&U^}uW5Uc$ zBz%Bs_yCvs%(c|H1l2OXE>@XeB-u)j_hm-wCO^Q%Jzh=f*dm&_u+H+t@d)WBA!+*p1annXv;`OR8Pgc$s#dDYUe*R;}9`XmW0p)e;7F-1Y zrlv~hq$@A{^8b+sw{Ld6ZVvB#H9*FWeKr8G0af$5s=3Ts&t$6|0gshKBlwH!FT=-f zo5s@K7>cr}AFqVhJJi3gPSFFtk6$_b>BwA$Qtb6_zSXCJPjg1zz6VTGJTce_up%+T zqB*S~0yyuz>kbKa?0O{o_SXLnRl(FWm;Q+X06l)K$g`q4*;SpDmSnH9{W;hqY9Su4 zY9(96DrxCKFtPpG?Lcg%h9ai1YZ!D8v&WM@_1db}F5wJ#lMn|?gK%LGZUQK;ijiyxZ@lVrIRCaA zFg~`lo<*lCAId!?{;;`W{Ks>xJ>y-qbyq3*-%zMT7$T&WE`^|X6mo{KYmeoy-G&EIg9&-{3B-;~` zlrB!btR5=PXM)V%`@mU`eCdV<>WY610EhtqJ$|WbIx4g#WYo?g&(}vl%dB7_17vgHb0#C7>Jr}M+SpJqYAyFVE!#}Qs zR6#+z?tBcG;=*j9@!slpJ!{F|$Q1ku>lXPe*s!6*Gc<#!kAtqX2SJ#c?ybQ-Ns>1mcAA#7cDF-Y1Cy9wAgp>|fs=+NaSXPOKTHYV zupf|nUlzZ#y8G&PK6CjgFP=uReqyn$fawzJC$g~*t$!Qt-~O-Y^>X!EhQK9ky~H8C zsca`Fy(;jug)#_#2=d#nDlSMP?p4v#!~Z)f^n&&1@U z1Gw~03;^gr(JWoJH%{v#d(F~yGOe`7+mcCJx`dxLRFBi?xEE|D?W$%>6mymxtt>J% zd+p}&iiU)^Y9Nct+o8y85qx;#wfL<|&chG)?8LHe4@Fs2<9q#)l{tr*xqW!y5l_P}tT-Hd z_w0xBCTUPmEXmdAmKDX zzbvt=JAy5HcHyO$d<-A>`d6@`(*?9HDy0j|=*0$>X62y?CYv_HNKytAdG58G)AG(A zzW(8_{p&;6v`H|CtCU|{b-%-@OqnYtdn-LOsb-{J~_n#8hpq8Q9s|Akh1lZYUU0u` zBV(Z`PyM|P>iYAgKX&Etkr(~q;eRO`P=4dMMQVdgO?gbN%kRJBE8oiQe&{UcGN;ZJ zc6btP1udrKZ(8x+yi{iUM(Zs9<=qi{^`1NN;?I5vpZU&4EbH~q$#RsY|4_f|qvJeY zd&~<^6h)QmDb56e3m4WA8(@WKsP#>)*FqGSFXnN|GuC1#&(JT6@ct+P4CP%c8(E6W zzyEEVe9<|$X7eVj8Xm3!7jIs9X^-TCFSac(WVaYl2QLaKq}R!dyYD||=RaKgPncYn zv)~Ug0H6cwO(&=t$(SNIs)KD@nKo2-I-2B((4qO z2GvX5Vpjq*wEDFx7YapD7Fg2lV%N-G{QegRL5w_3IW9jHJ2pkGu!a=cjW*=Vq zte?ftA9@t__yR6->2iM38rW7z8i=CI#W!yxCvcfVff9!-UxHJg`CQD*&H(V}`#w|^ zW4$HVH8+bt`SN<4e$@roF}n{dhI=S{X^w){Ya!t^T_EvA(jZh+Y+a=4VS6oQq}%cL zK77NjbH4Crm|T}*YMO&TF#w-R|ljAsk6lJ5a|(4*>>x#4oZqAMZOE z?j*)ICc({S$ycW&MM^KSvYW{(o-%y?vsbM;WMciIrviZU&o3~!E+6^8O|O>+ciyIB z!@2i99Bj9Zbrf1{ceKVgX+IacM_&(?eeco9GK}|nxZuvOTSslF(FMT{Am$c&P4@RJFUUjtRy@NYy4I0+if39p|uSWE}hZ9xr+3v3S{$M`OqS{Z;#6Tcg0%6tInSY`H%MwVkqr-#e$`3`>dqI2>3D=);>Jv*^ts0S@elyzBS4Y?)~ zk|NV{e)a%~hE<;`LDGfCtpcd`>M{r2yU*#jH@{}r4PU z@fI~p+@X1nsV$&sV*xC*0WEPqAlXB2?kg0kF{>nY0mAk)h>48~GaQG3#zAxJpDj*W zG4%3}9COfrJ8>Gtb?Y7@A7El4+kDOR?fIQs{$OZMGq_Gtk5TO0G%SpSE>9*=F#W!| zFhspQRGk9p_xs38U_8%p>;3oOcQ5%ke(jR;@wp#-3w=1`SqJYr;Zz(kG=}+p5o$+> z#(;8Q*}ra7Wa~^Ms}%LG`FB$_sJ&WuL+~wr<9X-YAB| zp)C6K5kOsVsm9i$2+@Yd6{T=3Y&{Cs907G@=5pum+4hHze)h}Ln4ILkKQREH19Szz zH8UeqzO^3F?T28pFlvhBXqZ*n!7(mz$FjV(Y-(IN5L1Aq%H)P!d#IBV%}icwjc(sI3l{Qn=CK+<*7pPwf22Wgo}-i40Ry1wTdT--7TG z&`)jVzYe)fqqyo94*m2?4(YwR-`7G^bCGx;E)-}nEmD)YuPHL4s#P8KCLjo%|ICSW zkwxF;Vv5q(4uWxJs;vSnga3NMEIaJ^OI$TDmGNSe9BmEQ8iNwG-2~&P(6qg6KNmsW z^>?vG0yYSdG}};2F{I{_s(O@V>AOQ6ck3>{=|x|C_@sT4`ya;CqN$g_ zulj&K{h&Yfd*}Kh=)_Dfa!8iJ zn^ei9-cmSAZ%$*hVz1D}u?fJMN`|G!qjUQ(JkrHzcci-RzO3q3)YnZ+po&GhILEt+ zGynKBWA>>^I4W9T$#CwsY`b{p*%!PD*G&Q^PL<%kKXCt9(*R^KWGH?K3uvG8BtXu{Q%)~us1Y=&Ha;=K7@;$FA2Z?4QY|`>%E*y^g{c)qZ{x_QbNr651jUPRd2?`3Ong8_5bz zCh%V2`vry-SUxn2W{V{gdkxH>I#^Po6~vi8o4OHzLLIPbWC=#313%wK+3#010>UIc zzNu_7r<&&_X|sZ`3N%p0x>@ll%R)waIks%O^{BHicr~099Q=s^03E>ARd2d%!BMx> zSQeproozZJt)=)d`#1afcD8J*;i*ZCvm;O3mq#e96YIxItPrR15yS)Twh)eO*NWK8 zMymn3-Jjkz`3aLj^F1MxA!%>d4Bi-MvLkhMJJmEOO0R2OR(O#PgjG_ap`?S_|TTee**qocW&TDrPPUNS(|UGm!H7pfc# zQx$Pw8%0dJToZh$qVT@z%$HPMG-Z0xx^A|~Uek~Yg9)ZhfaaX5m-^GdtX;z@ZZB0q zj&n_*8TWJsoBpILQqBp)`w}DFyu9zht@4kzy=tRKAOA-k2Y+G!KnFe_Kjf>=U$Mu- zk&eomd_XE%eQxdGRUfr-&Fs1v6r0dN?0u!JUSfD5aUg1qb9S02^(g=im4};E377V5 z4+F6zGe$85YwWo=O_~=;t9^m#a}a6`sM$-`)^)6R-OcHQ5D>MuIYMW}>RSu%bL|)9 zD-Rof+XtU}&~LncG6re(r5GYAYUoQ&ZTpuT)ZbRBc z+MdFw9}i6PD$&^HSL5s9oI`TJR0UdUnT-x-vg6UM{Tnu&^vJiqbyt<~&q{y90Dul? z(*S@%eOiLGRWUaHx3%=Hx_IKeA}I=}?fq`Vqgm1noTfg0QmaQL=_}tJv~77Am=>*S z+S1z6#oFWsgoZ&2J$NMR#yAKHy24;s~^hYsbyeu1>$U32A)B)&7iFrbqM@K7s<&V?5wDg?m4OG!f!Tn+*FfoiFtpeNP}fa#|mXLhlrYN*xT z{sBT1%~Im%MaVSr1B@RWp z2a<$8)m8x?jSXy)JjJkJ(yF~Lb(!9QJ#iB#nBYq5Ymte_e82S193RTxw070s9W*w! z`fNqHXpx|)DUZoX_k*kNxNq!B8-H82Zr_HHUZ#Ct+7a58CDwYxR51%aN!#bv>m6F} z#x)!buOeFY9%B6iS)jyZYzhS{*y#R}hv89bYDS47*Bw6JV$ zlj#Nv2WZ?|w!PP7uMS$}`O{MqrLwI4rv{EH%RXHQHlezc;l5pd6q zw|&cBb^A-?f$jIpa4$oFGBmnzgGq1UN@<+^$4@645Tq*sws~ZHRdwK96IU0^b!^oJ z2+4XtlB}n#s)6y1Xv_Kzt~lA%hNK39leE#4N|}%JGMS&?T{Mw__5EE+F91*Z1W#O*xU+N5&2DT-4wZiM{Jg?6`@)Tl zX7cEs``y>R`{HfqeUUSNVgNu-h6n(4^Bpzi_+=o$&sI&diCk)u*i2RXWCjg0tFtQz z_DsDXtuYXHd?oCai?(LQk`}eGiFvkFa56xpHIL1y2Ga>n(r1l@I>v^hd*(LP1Tv-w z4Wwy%TyJ-%BzX}@!60p20X207%=U;lhk4C9T|xhKPapr&zgoNI-7-C0eB(HD7JaR& zlEH5Kmv7tp^egW8)qLwCUy+gF4!ri^eNz9RSydV6iWlP-Q_CSH>xG01JJZ)zXU0}n zNY#a|a~eI^hE!r7K=LLu0pGyu7gwZ`B0&A3kl~>m4{W<1|8mDk+c#YIT@L=l0Dzt} z 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..bb991a4a69f3b9dd508b681fca0691273a4ae69a GIT binary patch literal 12784 zcmeHuhgVbE6K)E<-oO>5D*~c4CG?IWNLNvMvk*$8iS&|0RN_@ckz(j7V8PG>NJ~%@ zg;1o0&JhJv1OlN12+2GA-hc7dTPtfND><{zKC}0H^UXIq_s=_83kygIKp+rdn{$>I zArKh&5eDJo0pHeRhjt*4Qw=tj7MEhjm!@L!-vzkSS7&sL60RP8en;Uj{zc1r?s@Hd z8j6Qyxh;YeUjKVMI5Wdl@r1(J)6Os+E`%`O9X)6%*Jn|W4nYm)id4}ifizp5d8KcXn{l50Ll5xN z5-IEtE*Rg0O#cSfa80H?r0$^T;9b0%r1b-f?SxkwRdOR&`=MfAa7BxmXk-_sp^~;E z)jC4vCDSCC$@OjVlrD2Y^9GrZYp=W1kVZ@9Sp-+rl@*hQb_K3%+#KFbYGFf_0150I zVjX>vL(t=qgHMVz`tavA+onZ5=lV1?cf<=_vt(|!9XAdovnH=@Z(ZDev9Y^EX*C zHD_*P;?7N%aB-qJ&AQP=is4Z}yAoaV-Njwsuhb-5>Y#jI@wd0cQ{-HN-iax7(@sTk z2r;s7)fapyTSd+N zlDN&6Y;~TmT;|e0;E~!k6qRJF410+7H;<=^;6};-y>>1GG_%RL4m)fE{-PK(pi11_ZlAiYmb`nmuXehKbOm9;6je4Sil}rG0 zgl+|dbt{{DPV-l>l9#N0y)Q4`A94p~Mj=BqAL7IQ_B_ZgkbcR<@>$%iSN_6fo>6~c zX`vFwF`=j`%)V4QAA`%PQD>l1Fv4pDLIX@rAYU-?utKlgIg$NY=3>>_$vo^WxaNJl zIGWC*rJ&pT=5o;TF$EVb1`ov!J5x5pO@B&FOtzG{oRPBZGHso!R-!_E);s6AgT0hO99->WWhwKSo=8g+#1P@-bGFHMNPls;pFOr z3!S0up{n#x3@#FT49;uS;b}|vdEK5MNMhf`f5E%;Qu9#r&4Hg0UKk*$S&82rD#Gpf zl^QVIaRk|1iZ%z`S%TnVkN0uJ0e^}GBI1^%*NDJ$o?Ip^^wbw>IclDPbH=3#wS;+w zB)LH=pe-`YbEs)0h3umWb_eh5FO6rv9B`0Qj+?OTD6;H$PpWVIL?MGQjWU&D%Fq>n z+;tk_kV~F#Ac;ptC_kT|*+VVQxpTEW6bi2Jz&wm|Gj5)XZVH4VR@d>&X#RH>&=a0~ ztf}#JJhgbgq%Gtb)Hwzl$Uu6Wm@;>OqHiO`0#a(iDhO*WWjtGE8cR_ zOu^IzJ)~3>8s*`;w7?moCc4L^�_SOqdffql{{ie_8&38InOHY%`(-!8NY^EcHNH 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 literal 0 HcmV?d00001 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 @@ + + +