first version of firewall for android
This commit is contained in:
+14
@@ -0,0 +1,14 @@
|
|||||||
|
.gradle/
|
||||||
|
.kotlin/
|
||||||
|
.idea/
|
||||||
|
build/
|
||||||
|
local.properties
|
||||||
|
*.iml
|
||||||
|
captures/
|
||||||
|
.externalNativeBuild/
|
||||||
|
.cxx/
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.settings/
|
||||||
|
bin/
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.krrishg.kwall"
|
||||||
|
compileSdk = 36
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.krrishg.kwall"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 36
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "0.1.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
aidl = true
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
|
||||||
|
packaging {
|
||||||
|
resources {
|
||||||
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget.set(JvmTarget.JVM_17)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
|
implementation(libs.androidx.compose.ui)
|
||||||
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
|
implementation(libs.androidx.compose.material3)
|
||||||
|
implementation(libs.androidx.compose.material.icons.core)
|
||||||
|
implementation(libs.androidx.datastore.preferences)
|
||||||
|
implementation(libs.androidx.biometric)
|
||||||
|
implementation(libs.androidx.fragment)
|
||||||
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
implementation(libs.shizuku.api)
|
||||||
|
implementation(libs.shizuku.provider)
|
||||||
|
|
||||||
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
|
|
||||||
|
testImplementation(libs.junit)
|
||||||
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||||
|
tools:ignore="QueryAllPackagesPermission" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.PACKAGE_USAGE_STATS"
|
||||||
|
tools:ignore="ProtectedPermissions" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||||
|
<uses-permission android:name="moe.shizuku.manager.permission.API_V23" />
|
||||||
|
|
||||||
|
<queries>
|
||||||
|
<package android:name="moe.shizuku.privileged.api" />
|
||||||
|
</queries>
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".KwallApplication"
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.Kwall">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="rikka.shizuku.ShizukuProvider"
|
||||||
|
android:authorities="${applicationId}.shizuku"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="true"
|
||||||
|
android:multiprocess="false"
|
||||||
|
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".receivers.BootReceiver"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".receivers.FirewallNotificationReceiver"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".receivers.ScheduleAlarmReceiver"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".receivers.ScheduleMaintenanceReceiver"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.TIME_SET" />
|
||||||
|
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||||
|
<action android:name="android.intent.action.DATE_CHANGED" />
|
||||||
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||||
|
<action android:name="android.app.action.SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".receivers.PackageAddedReceiver"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.PACKAGE_ADDED" />
|
||||||
|
<data android:scheme="package" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.krrishg.kwall.firewall;
|
||||||
|
|
||||||
|
interface IFirewallService {
|
||||||
|
String probe() = 1;
|
||||||
|
String setUidFirewallRule(int chain, int uid, int rule) = 2;
|
||||||
|
String[] setUidFirewallRules(int chain, in int[] uids, in int[] rules) = 3;
|
||||||
|
String setFirewallChainEnabled(int chain, boolean enabled) = 4;
|
||||||
|
String replaceUidFirewallChain(int chain, in int[] uids) = 5;
|
||||||
|
int getUidFirewallRule(int chain, int uid) = 6;
|
||||||
|
int getFirewallChainEnabled(int chain) = 7;
|
||||||
|
void destroy() = 16777114;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.krrishg.kwall
|
||||||
|
|
||||||
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
|
||||||
|
class AppChangeNotifier {
|
||||||
|
|
||||||
|
private val _events = MutableSharedFlow<Unit>(
|
||||||
|
extraBufferCapacity = 1,
|
||||||
|
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
val events: SharedFlow<Unit> = _events.asSharedFlow()
|
||||||
|
|
||||||
|
fun notifyChange() {
|
||||||
|
_events.tryEmit(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
package com.krrishg.kwall
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.content.pm.LauncherApps
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.os.UserHandle
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.krrishg.kwall.data.AppRepository
|
||||||
|
import com.krrishg.kwall.data.PackageManagerAppSource
|
||||||
|
import com.krrishg.kwall.data.ProfileAwareAppRepository
|
||||||
|
import com.krrishg.kwall.data.WorkProfileAppSource
|
||||||
|
import com.krrishg.kwall.firewall.AppKey
|
||||||
|
import com.krrishg.kwall.firewall.AppUidResolver
|
||||||
|
import com.krrishg.kwall.firewall.AutoBlockNewApps
|
||||||
|
import com.krrishg.kwall.firewall.BinderFirewallClient
|
||||||
|
import com.krrishg.kwall.firewall.DataStoreFirewallStateStore
|
||||||
|
import com.krrishg.kwall.firewall.FirewallBackend
|
||||||
|
import com.krrishg.kwall.firewall.FirewallController
|
||||||
|
import com.krrishg.kwall.firewall.FirewallManager
|
||||||
|
import com.krrishg.kwall.firewall.FirewallStateStore
|
||||||
|
import com.krrishg.kwall.firewall.ShizukuFirewallBackend
|
||||||
|
import com.krrishg.kwall.network.ConnectionInspector
|
||||||
|
import com.krrishg.kwall.network.NetworkStatsUsageSource
|
||||||
|
import com.krrishg.kwall.network.NetworkUsageSource
|
||||||
|
import com.krrishg.kwall.network.ProcNetConnectionInspector
|
||||||
|
import com.krrishg.kwall.notifications.FirewallNotifications
|
||||||
|
import com.krrishg.kwall.notifications.FirewallStatusNotifier
|
||||||
|
import com.krrishg.kwall.scheduler.AlarmManagerScheduleAlarms
|
||||||
|
import com.krrishg.kwall.scheduler.DataStoreScheduleStore
|
||||||
|
import com.krrishg.kwall.scheduler.ProfileScheduler
|
||||||
|
import com.krrishg.kwall.scheduler.ScheduleAlarms
|
||||||
|
import com.krrishg.kwall.scheduler.ScheduleStore
|
||||||
|
import com.krrishg.kwall.settings.AppSettingsStore
|
||||||
|
import com.krrishg.kwall.settings.DataStoreAppSettingsStore
|
||||||
|
import com.krrishg.kwall.shizuku.ShizukuShellExecutor
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class AppContainer(
|
||||||
|
context: Context,
|
||||||
|
private val applicationScope: CoroutineScope,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val appContext = context.applicationContext
|
||||||
|
private val launcherApps: LauncherApps? = appContext.getSystemService(LauncherApps::class.java)
|
||||||
|
val appChangeNotifier = AppChangeNotifier()
|
||||||
|
|
||||||
|
val appRepository: AppRepository by lazy {
|
||||||
|
ProfileAwareAppRepository(
|
||||||
|
sources = listOf(
|
||||||
|
PackageManagerAppSource(
|
||||||
|
packageManager = appContext.packageManager,
|
||||||
|
),
|
||||||
|
WorkProfileAppSource(
|
||||||
|
launcherApps = launcherApps,
|
||||||
|
density = appContext.resources.displayMetrics.densityDpi,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ownPackageName = appContext.packageName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val firewallStateStore: FirewallStateStore by lazy {
|
||||||
|
DataStoreFirewallStateStore(appContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
val settingsStore: AppSettingsStore by lazy {
|
||||||
|
DataStoreAppSettingsStore(appContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
val firewallStatusNotifier: FirewallStatusNotifier by lazy {
|
||||||
|
FirewallStatusNotifier(appContext, settingsStore, applicationScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val shellExecutor by lazy { ShizukuShellExecutor() }
|
||||||
|
|
||||||
|
val connectionInspector: ConnectionInspector by lazy {
|
||||||
|
ProcNetConnectionInspector(shellExecutor)
|
||||||
|
}
|
||||||
|
|
||||||
|
val networkUsageSource: NetworkUsageSource by lazy {
|
||||||
|
NetworkStatsUsageSource(appContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val firewallBackend: FirewallBackend by lazy {
|
||||||
|
ShizukuFirewallBackend(
|
||||||
|
context = appContext,
|
||||||
|
shell = shellExecutor,
|
||||||
|
binder = BinderFirewallClient(appContext),
|
||||||
|
scope = applicationScope,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val firewallManager: FirewallController by lazy {
|
||||||
|
FirewallManager(
|
||||||
|
store = firewallStateStore,
|
||||||
|
backend = firewallBackend,
|
||||||
|
uidResolver = AppUidResolver(appContext.packageManager, launcherApps),
|
||||||
|
scope = applicationScope,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val scheduleStore: ScheduleStore by lazy {
|
||||||
|
DataStoreScheduleStore(appContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
val scheduleAlarms: ScheduleAlarms by lazy {
|
||||||
|
AlarmManagerScheduleAlarms(appContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
val profileScheduler: ProfileScheduler by lazy {
|
||||||
|
ProfileScheduler(
|
||||||
|
scheduleStore = scheduleStore,
|
||||||
|
firewallController = firewallManager,
|
||||||
|
alarms = scheduleAlarms,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val autoBlockNewApps: AutoBlockNewApps by lazy {
|
||||||
|
AutoBlockNewApps(
|
||||||
|
settingsStore = settingsStore,
|
||||||
|
repository = appRepository,
|
||||||
|
firewallController = firewallManager,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun autoBlockNewlyInstalled(packageName: String, userId: Int) {
|
||||||
|
val app = runCatching {
|
||||||
|
autoBlockNewApps.onPackageAdded(packageName, userId)
|
||||||
|
}.getOrNull() ?: return
|
||||||
|
FirewallNotifications.notifyAutoBlocked(appContext, app.label)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val launcherCallback = object : LauncherApps.Callback() {
|
||||||
|
override fun onPackageRemoved(packageName: String, user: UserHandle) {
|
||||||
|
appChangeNotifier.notifyChange()
|
||||||
|
applicationScope.launch {
|
||||||
|
runCatching {
|
||||||
|
firewallManager.removeBlockedEverywhere(AppKey(user.userId(), packageName))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPackageAdded(packageName: String, user: UserHandle) {
|
||||||
|
appChangeNotifier.notifyChange()
|
||||||
|
applicationScope.launch {
|
||||||
|
runCatching {
|
||||||
|
autoBlockNewlyInstalled(packageName, user.userId())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPackageChanged(packageName: String, user: UserHandle) {
|
||||||
|
appChangeNotifier.notifyChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPackagesAvailable(
|
||||||
|
packageNames: Array<String>,
|
||||||
|
user: UserHandle,
|
||||||
|
replacing: Boolean,
|
||||||
|
) {
|
||||||
|
appChangeNotifier.notifyChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPackagesUnavailable(
|
||||||
|
packageNames: Array<String>,
|
||||||
|
user: UserHandle,
|
||||||
|
replacing: Boolean,
|
||||||
|
) {
|
||||||
|
appChangeNotifier.notifyChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val profileReceiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent?) {
|
||||||
|
val action = intent?.action ?: return
|
||||||
|
appChangeNotifier.notifyChange()
|
||||||
|
if (action == Intent.ACTION_MANAGED_PROFILE_ADDED ||
|
||||||
|
action == Intent.ACTION_MANAGED_PROFILE_UNLOCKED
|
||||||
|
) {
|
||||||
|
applicationScope.launch {
|
||||||
|
runCatching { firewallManager.restore() }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action != Intent.ACTION_MANAGED_PROFILE_REMOVED &&
|
||||||
|
action != Intent.ACTION_PROFILE_REMOVED
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val removedUser = intent.userHandleExtra() ?: return
|
||||||
|
applicationScope.launch {
|
||||||
|
runCatching {
|
||||||
|
firewallManager.removeBlockedForUserEverywhere(removedUser.userId())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
runCatching {
|
||||||
|
launcherApps?.registerCallback(launcherCallback, Handler(Looper.getMainLooper()))
|
||||||
|
}
|
||||||
|
val profileFilter = IntentFilter().apply {
|
||||||
|
addAction(Intent.ACTION_MANAGED_PROFILE_ADDED)
|
||||||
|
addAction(Intent.ACTION_MANAGED_PROFILE_REMOVED)
|
||||||
|
addAction(Intent.ACTION_MANAGED_PROFILE_UNLOCKED)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
addAction(Intent.ACTION_PROFILE_ADDED)
|
||||||
|
addAction(Intent.ACTION_PROFILE_REMOVED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
ContextCompat.registerReceiver(
|
||||||
|
appContext,
|
||||||
|
profileReceiver,
|
||||||
|
profileFilter,
|
||||||
|
ContextCompat.RECEIVER_EXPORTED,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Intent.userHandleExtra(): UserHandle? =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
getParcelableExtra(Intent.EXTRA_USER, UserHandle::class.java)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
getParcelableExtra(Intent.EXTRA_USER)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun UserHandle.userId(): Int = hashCode()
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.krrishg.kwall
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import com.krrishg.kwall.notifications.FirewallNotifications
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
|
||||||
|
class KwallApplication : Application() {
|
||||||
|
|
||||||
|
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
|
val container: AppContainer by lazy { AppContainer(this, applicationScope) }
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
FirewallNotifications.createChannel(this)
|
||||||
|
val container = container
|
||||||
|
container.firewallStatusNotifier.start(container.firewallManager)
|
||||||
|
container.profileScheduler.start(applicationScope)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
package com.krrishg.kwall
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.KeyguardManager
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.biometric.BiometricManager
|
||||||
|
import androidx.biometric.BiometricPrompt
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Lock
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.krrishg.kwall.notifications.FirewallNotifications
|
||||||
|
import com.krrishg.kwall.settings.AppSettingsStore
|
||||||
|
import com.krrishg.kwall.ui.apps.AppListRoute
|
||||||
|
import com.krrishg.kwall.ui.apps.AppListViewModel
|
||||||
|
import com.krrishg.kwall.ui.detail.AppDetailViewModel
|
||||||
|
import com.krrishg.kwall.ui.theme.KwallTheme
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class MainActivity : FragmentActivity() {
|
||||||
|
|
||||||
|
private val lockViewModel: LockViewModel by viewModels()
|
||||||
|
private lateinit var container: AppContainer
|
||||||
|
private lateinit var settingsStore: AppSettingsStore
|
||||||
|
private var settingsLoaded by mutableStateOf(false)
|
||||||
|
private var appLockEnabled by mutableStateOf(true)
|
||||||
|
private var autoBlockNewApps by mutableStateOf(false)
|
||||||
|
private var deviceLockConfigured by mutableStateOf(false)
|
||||||
|
private var biometricPromptActive = false
|
||||||
|
private var credentialIntentPending = false
|
||||||
|
private var pendingFirewallToggle: Boolean? = null
|
||||||
|
|
||||||
|
private val notificationPermissionLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission(),
|
||||||
|
) { granted ->
|
||||||
|
if (granted) {
|
||||||
|
container.firewallStatusNotifier.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val credentialLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.StartActivityForResult(),
|
||||||
|
) { result ->
|
||||||
|
credentialIntentPending = false
|
||||||
|
if (result.resultCode == RESULT_OK) {
|
||||||
|
unlock()
|
||||||
|
} else {
|
||||||
|
pendingFirewallToggle = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
deviceLockConfigured = isDeviceSecured()
|
||||||
|
container = (application as KwallApplication).container
|
||||||
|
settingsStore = container.settingsStore
|
||||||
|
if (savedInstanceState?.containsKey(STATE_PENDING_FIREWALL_TOGGLE) == true) {
|
||||||
|
pendingFirewallToggle = savedInstanceState.getBoolean(STATE_PENDING_FIREWALL_TOGGLE)
|
||||||
|
}
|
||||||
|
handleFirewallToggleIntent(intent)
|
||||||
|
setContent {
|
||||||
|
KwallTheme {
|
||||||
|
when {
|
||||||
|
!settingsLoaded -> Surface(modifier = Modifier.fillMaxSize()) {}
|
||||||
|
appLockEnabled && !lockViewModel.isUnlocked -> LockScreen(
|
||||||
|
deviceLockConfigured = deviceLockConfigured,
|
||||||
|
onUnlock = ::authenticate,
|
||||||
|
onOpenSecuritySettings = ::openSecuritySettings,
|
||||||
|
onDisableAppLock = { updateAppLockEnabled(false) },
|
||||||
|
)
|
||||||
|
else -> AppListRoute(
|
||||||
|
viewModel = viewModel(
|
||||||
|
factory = AppListViewModel.factory(
|
||||||
|
repository = container.appRepository,
|
||||||
|
firewallController = container.firewallManager,
|
||||||
|
appChangeNotifier = container.appChangeNotifier,
|
||||||
|
scheduleStore = container.scheduleStore,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
detailViewModelFactory = AppDetailViewModel.factory(
|
||||||
|
firewallController = container.firewallManager,
|
||||||
|
connectionInspector = container.connectionInspector,
|
||||||
|
usageSource = container.networkUsageSource,
|
||||||
|
),
|
||||||
|
appLockEnabled = appLockEnabled,
|
||||||
|
onAppLockEnabledChange = ::updateAppLockEnabled,
|
||||||
|
autoBlockNewApps = autoBlockNewApps,
|
||||||
|
onAutoBlockNewAppsChange = ::updateAutoBlockNewApps,
|
||||||
|
scheduleStore = container.scheduleStore,
|
||||||
|
scheduleAlarms = container.scheduleAlarms,
|
||||||
|
firewallController = container.firewallManager,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lifecycleScope.launch {
|
||||||
|
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
|
settingsStore.appLockEnabled.collect { enabled ->
|
||||||
|
val firstLoad = !settingsLoaded
|
||||||
|
appLockEnabled = enabled
|
||||||
|
if (firstLoad) {
|
||||||
|
settingsLoaded = true
|
||||||
|
if (enabled) {
|
||||||
|
authenticate()
|
||||||
|
} else {
|
||||||
|
lockViewModel.isUnlocked = true
|
||||||
|
requestNotificationPermission()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applyPendingFirewallToggleIfAllowed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lifecycleScope.launch {
|
||||||
|
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
|
settingsStore.autoBlockNewApps.collect { enabled ->
|
||||||
|
autoBlockNewApps = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
container.firewallStatusNotifier.refresh()
|
||||||
|
if (!settingsLoaded) return
|
||||||
|
if (!appLockEnabled) {
|
||||||
|
lockViewModel.isUnlocked = true
|
||||||
|
applyPendingFirewallToggleIfAllowed()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (lockViewModel.isUnlocked) {
|
||||||
|
applyPendingFirewallToggleIfAllowed()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deviceLockConfigured = isDeviceSecured()
|
||||||
|
if (deviceLockConfigured && !biometricPromptActive && !credentialIntentPending) {
|
||||||
|
authenticate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
handleFirewallToggleIntent(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop() {
|
||||||
|
super.onStop()
|
||||||
|
if (!isChangingConfigurations) {
|
||||||
|
lockViewModel.isUnlocked = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
|
super.onSaveInstanceState(outState)
|
||||||
|
pendingFirewallToggle?.let { target ->
|
||||||
|
outState.putBoolean(STATE_PENDING_FIREWALL_TOGGLE, target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isDeviceSecured(): Boolean =
|
||||||
|
getSystemService(KeyguardManager::class.java)?.isDeviceSecure == true
|
||||||
|
|
||||||
|
private fun authenticate() {
|
||||||
|
if (lockViewModel.isUnlocked || biometricPromptActive || credentialIntentPending) return
|
||||||
|
if (!isDeviceSecured()) {
|
||||||
|
deviceLockConfigured = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deviceLockConfigured = true
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
showBiometricPrompt()
|
||||||
|
} else {
|
||||||
|
showDeviceCredentialPrompt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showBiometricPrompt() {
|
||||||
|
biometricPromptActive = true
|
||||||
|
val prompt = BiometricPrompt(
|
||||||
|
this,
|
||||||
|
ContextCompat.getMainExecutor(this),
|
||||||
|
object : BiometricPrompt.AuthenticationCallback() {
|
||||||
|
override fun onAuthenticationSucceeded(
|
||||||
|
result: BiometricPrompt.AuthenticationResult,
|
||||||
|
) {
|
||||||
|
biometricPromptActive = false
|
||||||
|
unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||||
|
biometricPromptActive = false
|
||||||
|
pendingFirewallToggle = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAuthenticationFailed() = Unit
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||||
|
.setTitle(getString(R.string.lock_title))
|
||||||
|
.setSubtitle(getString(R.string.lock_subtitle))
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
promptInfo.setAllowedAuthenticators(
|
||||||
|
BiometricManager.Authenticators.BIOMETRIC_STRONG or
|
||||||
|
BiometricManager.Authenticators.DEVICE_CREDENTIAL,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
promptInfo.setDeviceCredentialAllowed(true)
|
||||||
|
}
|
||||||
|
prompt.authenticate(promptInfo.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showDeviceCredentialPrompt() {
|
||||||
|
val keyguardManager = getSystemService(KeyguardManager::class.java) ?: return
|
||||||
|
val intent = keyguardManager.createConfirmDeviceCredentialIntent(
|
||||||
|
getString(R.string.lock_title),
|
||||||
|
getString(R.string.lock_subtitle),
|
||||||
|
) ?: return
|
||||||
|
credentialIntentPending = true
|
||||||
|
credentialLauncher.launch(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unlock() {
|
||||||
|
if (lockViewModel.isUnlocked) {
|
||||||
|
applyPendingFirewallToggleIfAllowed()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lockViewModel.isUnlocked = true
|
||||||
|
requestNotificationPermission()
|
||||||
|
applyPendingFirewallToggleIfAllowed()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateAppLockEnabled(enabled: Boolean) {
|
||||||
|
appLockEnabled = enabled
|
||||||
|
if (!enabled) {
|
||||||
|
lockViewModel.isUnlocked = true
|
||||||
|
requestNotificationPermission()
|
||||||
|
}
|
||||||
|
applyPendingFirewallToggleIfAllowed()
|
||||||
|
lifecycleScope.launch {
|
||||||
|
settingsStore.setAppLockEnabled(enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateAutoBlockNewApps(enabled: Boolean) {
|
||||||
|
autoBlockNewApps = enabled
|
||||||
|
lifecycleScope.launch {
|
||||||
|
settingsStore.setAutoBlockNewApps(enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleFirewallToggleIntent(intent: Intent) {
|
||||||
|
if (!intent.hasExtra(FirewallNotifications.EXTRA_TOGGLE_TARGET)) return
|
||||||
|
pendingFirewallToggle = intent.getBooleanExtra(
|
||||||
|
FirewallNotifications.EXTRA_TOGGLE_TARGET,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
intent.removeExtra(FirewallNotifications.EXTRA_TOGGLE_TARGET)
|
||||||
|
applyPendingFirewallToggleIfAllowed()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyPendingFirewallToggleIfAllowed() {
|
||||||
|
val target = pendingFirewallToggle ?: return
|
||||||
|
if (!settingsLoaded) return
|
||||||
|
if (appLockEnabled && !lockViewModel.isUnlocked) return
|
||||||
|
pendingFirewallToggle = null
|
||||||
|
lifecycleScope.launch {
|
||||||
|
container.firewallManager.setEnabled(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openSecuritySettings() {
|
||||||
|
val intent = Intent(Settings.ACTION_SECURITY_SETTINGS)
|
||||||
|
runCatching { startActivity(intent) }
|
||||||
|
.onFailure { runCatching { startActivity(Intent(Settings.ACTION_SETTINGS)) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestNotificationPermission() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
|
||||||
|
if (ContextCompat.checkSelfPermission(
|
||||||
|
this,
|
||||||
|
Manifest.permission.POST_NOTIFICATIONS,
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val STATE_PENDING_FIREWALL_TOGGLE = "pending_firewall_toggle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class LockViewModel : ViewModel() {
|
||||||
|
var isUnlocked by mutableStateOf(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LockScreen(
|
||||||
|
deviceLockConfigured: Boolean,
|
||||||
|
onUnlock: () -> Unit,
|
||||||
|
onOpenSecuritySettings: () -> Unit,
|
||||||
|
onDisableAppLock: () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(32.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Lock,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
if (deviceLockConfigured) {
|
||||||
|
R.string.lock_prompt_title
|
||||||
|
} else {
|
||||||
|
R.string.lock_no_device_lock_title
|
||||||
|
},
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
if (deviceLockConfigured) {
|
||||||
|
R.string.lock_prompt_description
|
||||||
|
} else {
|
||||||
|
R.string.lock_no_device_lock_description
|
||||||
|
},
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
Button(
|
||||||
|
onClick = if (deviceLockConfigured) onUnlock else onOpenSecuritySettings,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
if (deviceLockConfigured) {
|
||||||
|
R.string.lock_unlock
|
||||||
|
} else {
|
||||||
|
R.string.lock_open_settings
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!deviceLockConfigured) {
|
||||||
|
TextButton(onClick = onDisableAppLock) {
|
||||||
|
Text(stringResource(R.string.lock_disable_app_lock))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package com.krrishg.kwall.data
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.ApplicationInfo
|
||||||
|
import android.content.pm.LauncherActivityInfo
|
||||||
|
import android.content.pm.LauncherApps
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Process
|
||||||
|
import android.os.UserHandle
|
||||||
|
import java.text.Collator
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
interface AppRepository {
|
||||||
|
suspend fun loadInstalledApps(): List<InstalledApp>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppSource {
|
||||||
|
suspend fun loadInstalledApps(): List<InstalledApp>
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProfileAwareAppRepository(
|
||||||
|
private val sources: List<AppSource>,
|
||||||
|
private val ownPackageName: String,
|
||||||
|
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
|
) : AppRepository {
|
||||||
|
|
||||||
|
override suspend fun loadInstalledApps(): List<InstalledApp> = withContext(ioDispatcher) {
|
||||||
|
val collator = Collator.getInstance()
|
||||||
|
val loaded = mutableListOf<InstalledApp>()
|
||||||
|
for (source in sources) {
|
||||||
|
loaded += source.loadInstalledApps()
|
||||||
|
}
|
||||||
|
loaded.asSequence()
|
||||||
|
.filter { it.packageName != ownPackageName }
|
||||||
|
.distinctBy { it.key }
|
||||||
|
.sortedWith { first, second ->
|
||||||
|
val labelOrder = collator.compare(first.label, second.label)
|
||||||
|
when {
|
||||||
|
labelOrder != 0 -> labelOrder
|
||||||
|
first.userId != second.userId -> first.userId.compareTo(second.userId)
|
||||||
|
else -> first.packageName.compareTo(second.packageName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PackageManagerAppSource(
|
||||||
|
private val packageManager: PackageManager,
|
||||||
|
) : AppSource {
|
||||||
|
|
||||||
|
override suspend fun loadInstalledApps(): List<InstalledApp> =
|
||||||
|
packageManager.getInstalledApplications(0)
|
||||||
|
.asSequence()
|
||||||
|
.map { appInfo ->
|
||||||
|
InstalledApp(
|
||||||
|
packageName = appInfo.packageName,
|
||||||
|
label = appInfo.loadDisplayLabel(packageManager),
|
||||||
|
isSystem = appInfo.isSystemApp,
|
||||||
|
icon = runCatching { appInfo.loadIcon(packageManager) }.getOrNull(),
|
||||||
|
uid = appInfo.uid,
|
||||||
|
userId = appInfo.uid / PER_USER_RANGE,
|
||||||
|
isWorkProfile = false,
|
||||||
|
hasInternetPermission = packageManager.checkPermission(
|
||||||
|
Manifest.permission.INTERNET,
|
||||||
|
appInfo.packageName,
|
||||||
|
) == PackageManager.PERMISSION_GRANTED,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
class WorkProfileAppSource(
|
||||||
|
private val launcherApps: LauncherApps?,
|
||||||
|
private val density: Int,
|
||||||
|
) : AppSource {
|
||||||
|
|
||||||
|
override suspend fun loadInstalledApps(): List<InstalledApp> {
|
||||||
|
val service = launcherApps ?: return emptyList()
|
||||||
|
val profiles = runCatching { service.profiles }.getOrElse { return emptyList() }
|
||||||
|
val currentUser = Process.myUserHandle()
|
||||||
|
return profiles
|
||||||
|
.filter { it != currentUser }
|
||||||
|
.flatMap { user -> loadProfileApps(service, user) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadProfileApps(service: LauncherApps, user: UserHandle): List<InstalledApp> {
|
||||||
|
val activities = runCatching { service.getActivityList(null, user) }.getOrElse { return emptyList() }
|
||||||
|
return activities
|
||||||
|
.groupBy { it.applicationInfo.packageName }
|
||||||
|
.mapNotNull { (packageName, entries) ->
|
||||||
|
val entry = entries.firstOrNull() ?: return@mapNotNull null
|
||||||
|
val appInfo = entry.applicationInfo
|
||||||
|
if (appInfo.uid <= 0) return@mapNotNull null
|
||||||
|
InstalledApp(
|
||||||
|
packageName = packageName,
|
||||||
|
label = entry.loadLabel(packageName),
|
||||||
|
isSystem = appInfo.isSystemApp,
|
||||||
|
icon = runCatching { entry.getBadgedIcon(density) }.getOrNull(),
|
||||||
|
uid = appInfo.uid,
|
||||||
|
userId = appInfo.uid / PER_USER_RANGE,
|
||||||
|
isWorkProfile = true,
|
||||||
|
hasInternetPermission = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun LauncherActivityInfo.loadLabel(packageName: String): String =
|
||||||
|
runCatching { label?.toString() }
|
||||||
|
.getOrNull()
|
||||||
|
.orEmpty()
|
||||||
|
.ifBlank { packageName }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ApplicationInfo.loadDisplayLabel(packageManager: PackageManager): String =
|
||||||
|
runCatching { loadLabel(packageManager).toString() }
|
||||||
|
.getOrDefault("")
|
||||||
|
.ifBlank { packageName }
|
||||||
|
|
||||||
|
private val ApplicationInfo.isSystemApp: Boolean
|
||||||
|
get() = flags and (ApplicationInfo.FLAG_SYSTEM or ApplicationInfo.FLAG_UPDATED_SYSTEM_APP) != 0
|
||||||
|
|
||||||
|
private const val PER_USER_RANGE = 100_000
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.krrishg.kwall.data
|
||||||
|
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import com.krrishg.kwall.firewall.AppKey
|
||||||
|
|
||||||
|
data class InstalledApp(
|
||||||
|
val packageName: String,
|
||||||
|
val label: String,
|
||||||
|
val isSystem: Boolean,
|
||||||
|
val icon: Drawable?,
|
||||||
|
val uid: Int,
|
||||||
|
val userId: Int,
|
||||||
|
val isWorkProfile: Boolean,
|
||||||
|
val hasInternetPermission: Boolean,
|
||||||
|
) {
|
||||||
|
val key: AppKey get() = AppKey(userId, packageName)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.krrishg.kwall.firewall
|
||||||
|
|
||||||
|
data class AppKey(
|
||||||
|
val userId: Int,
|
||||||
|
val packageName: String,
|
||||||
|
) {
|
||||||
|
override fun toString(): String = "$userId:$packageName"
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun parse(value: String): AppKey? {
|
||||||
|
val separator = value.indexOf(':')
|
||||||
|
if (separator <= 0 || separator == value.lastIndex) return null
|
||||||
|
val userId = value.substring(0, separator).toIntOrNull() ?: return null
|
||||||
|
return AppKey(userId, value.substring(separator + 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.krrishg.kwall.firewall
|
||||||
|
|
||||||
|
import android.content.pm.LauncherApps
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Process
|
||||||
|
import android.os.UserHandle
|
||||||
|
|
||||||
|
interface UidResolver {
|
||||||
|
fun resolve(key: AppKey): Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppUidResolver(
|
||||||
|
private val packageManager: PackageManager,
|
||||||
|
private val launcherApps: LauncherApps?,
|
||||||
|
) : UidResolver {
|
||||||
|
|
||||||
|
override fun resolve(key: AppKey): Int? = runCatching {
|
||||||
|
if (key.userId == Process.myUid() / PER_USER_RANGE) {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
packageManager.getApplicationInfo(key.packageName, 0).uid
|
||||||
|
} else {
|
||||||
|
launcherApps
|
||||||
|
?.getApplicationInfo(
|
||||||
|
key.packageName,
|
||||||
|
0,
|
||||||
|
UserHandle.getUserHandleForUid(key.userId * PER_USER_RANGE),
|
||||||
|
)
|
||||||
|
?.uid
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val PER_USER_RANGE = 100_000
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.krrishg.kwall.firewall
|
||||||
|
|
||||||
|
import com.krrishg.kwall.data.AppRepository
|
||||||
|
import com.krrishg.kwall.data.InstalledApp
|
||||||
|
import com.krrishg.kwall.settings.AppSettingsStore
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
|
||||||
|
class AutoBlockNewApps(
|
||||||
|
private val settingsStore: AppSettingsStore,
|
||||||
|
private val repository: AppRepository,
|
||||||
|
private val firewallController: FirewallController,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val operationMutex = Mutex()
|
||||||
|
|
||||||
|
suspend fun onPackageAdded(packageName: String, userId: Int): InstalledApp? =
|
||||||
|
operationMutex.withLock {
|
||||||
|
if (!settingsStore.autoBlockNewApps.first()) return@withLock null
|
||||||
|
val apps = repository.loadInstalledApps()
|
||||||
|
val app = apps.firstOrNull {
|
||||||
|
it.packageName == packageName && it.userId == userId
|
||||||
|
} ?: return@withLock null
|
||||||
|
if (app.isSystem) return@withLock null
|
||||||
|
|
||||||
|
val packageCounts = apps.groupingBy { it.packageName }.eachCount()
|
||||||
|
val rulesTransport = (firewallController.availability.value as? FirewallAvailability.Ready)
|
||||||
|
?.rulesTransport
|
||||||
|
val isWorkProfileOnly = app.isWorkProfile && (packageCounts[app.packageName] ?: 1) == 1
|
||||||
|
if (isWorkProfileOnly && rulesTransport != RulesTransport.BINDER) return@withLock null
|
||||||
|
|
||||||
|
val assessment = blockabilityFor(
|
||||||
|
app = app,
|
||||||
|
uidCounts = apps.groupingBy { it.uid }.eachCount(),
|
||||||
|
packageCounts = packageCounts,
|
||||||
|
rulesTransport = rulesTransport,
|
||||||
|
firewallPackages = FIREWALL_PACKAGES,
|
||||||
|
)
|
||||||
|
if (assessment.blockability != AppBlockability.BLOCKABLE) return@withLock null
|
||||||
|
|
||||||
|
if (firewallController.addBlockedSelection(app)) app else null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package com.krrishg.kwall.firewall
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.ServiceConnection
|
||||||
|
import android.content.pm.ApplicationInfo
|
||||||
|
import android.os.IBinder
|
||||||
|
import androidx.core.content.pm.PackageInfoCompat
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlinx.coroutines.CancellableContinuation
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import rikka.shizuku.Shizuku
|
||||||
|
|
||||||
|
class BinderFirewallClient(context: Context) {
|
||||||
|
|
||||||
|
private val appContext = context.applicationContext
|
||||||
|
private val lock = Mutex()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var service: IFirewallService? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var pending: CancellableContinuation<IFirewallService?>? = null
|
||||||
|
|
||||||
|
private val serviceArgs by lazy {
|
||||||
|
val debuggable = appContext.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0
|
||||||
|
Shizuku.UserServiceArgs(
|
||||||
|
ComponentName(appContext.packageName, FirewallUserService::class.java.name),
|
||||||
|
)
|
||||||
|
.daemon(true)
|
||||||
|
.processNameSuffix("firewall")
|
||||||
|
.version(userServiceVersion())
|
||||||
|
.debuggable(debuggable)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun userServiceVersion(): Int {
|
||||||
|
val versionCode = runCatching {
|
||||||
|
val packageInfo = appContext.packageManager.getPackageInfo(appContext.packageName, 0)
|
||||||
|
PackageInfoCompat.getLongVersionCode(packageInfo)
|
||||||
|
}.getOrDefault(1L)
|
||||||
|
return (((versionCode and 0x7fffffffL) * 100) + USER_SERVICE_REVISION).toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun probe(): Boolean {
|
||||||
|
if (!runCatching { Shizuku.pingBinder() }.getOrDefault(false)) return false
|
||||||
|
val binderService = runCatching { obtain() }.getOrNull() ?: return false
|
||||||
|
val result = runCatching { binderService.probe() }
|
||||||
|
return result.isSuccess && result.getOrNull().isNullOrBlank()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setUidFirewallRule(chain: Int, uid: Int, rule: Int) {
|
||||||
|
val binderService = obtain() ?: throw FirewallException("Shizuku firewall service is unavailable")
|
||||||
|
val error = binderService.setUidFirewallRule(chain, uid, rule)
|
||||||
|
if (!error.isNullOrBlank()) throw FirewallException(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setUidFirewallRules(chain: Int, uids: IntArray, rules: IntArray): List<String?> {
|
||||||
|
val binderService = obtain() ?: throw FirewallException("Shizuku firewall service is unavailable")
|
||||||
|
return binderService.setUidFirewallRules(chain, uids, rules).toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setFirewallChainEnabled(chain: Int, enabled: Boolean) {
|
||||||
|
val binderService = obtain() ?: throw FirewallException("Shizuku firewall service is unavailable")
|
||||||
|
val error = binderService.setFirewallChainEnabled(chain, enabled)
|
||||||
|
if (!error.isNullOrBlank()) throw FirewallException(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun replaceUidFirewallChain(chain: Int, uids: IntArray) {
|
||||||
|
val binderService = obtain() ?: throw FirewallException("Shizuku firewall service is unavailable")
|
||||||
|
val error = binderService.replaceUidFirewallChain(chain, uids)
|
||||||
|
if (!error.isNullOrBlank()) throw FirewallException(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getUidFirewallRule(chain: Int, uid: Int): Int? {
|
||||||
|
val binderService = obtain() ?: return null
|
||||||
|
return runCatching { binderService.getUidFirewallRule(chain, uid) }
|
||||||
|
.getOrNull()
|
||||||
|
?.takeUnless { it == FirewallCommands.RULE_UNKNOWN }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getFirewallChainEnabled(chain: Int): Boolean? {
|
||||||
|
val binderService = obtain() ?: return null
|
||||||
|
return when (runCatching { binderService.getFirewallChainEnabled(chain) }.getOrNull()) {
|
||||||
|
FirewallCommands.CHAIN_ENABLED -> true
|
||||||
|
FirewallCommands.CHAIN_DISABLED -> false
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun obtain(): IFirewallService? {
|
||||||
|
alive()?.let { return it }
|
||||||
|
return lock.withLock {
|
||||||
|
alive() ?: bind().also { service = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun alive(): IFirewallService? =
|
||||||
|
service?.takeIf { runCatching { it.asBinder().pingBinder() }.getOrDefault(false) }
|
||||||
|
|
||||||
|
private suspend fun bind(): IFirewallService? {
|
||||||
|
val connection = object : ServiceConnection {
|
||||||
|
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
|
||||||
|
val bound = binder
|
||||||
|
?.takeIf { runCatching { it.pingBinder() }.getOrDefault(false) }
|
||||||
|
?.let { IFirewallService.Stub.asInterface(it) }
|
||||||
|
service = bound
|
||||||
|
pending?.takeIf { it.isActive }?.resume(bound)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceDisconnected(name: ComponentName?) {
|
||||||
|
service = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val bound = withTimeoutOrNull(BIND_TIMEOUT_MS) {
|
||||||
|
suspendCancellableCoroutine<IFirewallService?> { continuation ->
|
||||||
|
pending = continuation
|
||||||
|
try {
|
||||||
|
Shizuku.bindUserService(serviceArgs, connection)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
if (continuation.isActive) continuation.resume(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pending = null
|
||||||
|
|
||||||
|
if (bound == null) {
|
||||||
|
runCatching { Shizuku.unbindUserService(serviceArgs, connection, false) }
|
||||||
|
}
|
||||||
|
return bound
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val BIND_TIMEOUT_MS = 4_000L
|
||||||
|
const val USER_SERVICE_REVISION = 3
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package com.krrishg.kwall.firewall
|
||||||
|
|
||||||
|
import com.krrishg.kwall.data.InstalledApp
|
||||||
|
|
||||||
|
enum class AppBlockability {
|
||||||
|
BLOCKABLE,
|
||||||
|
SYSTEM_UID,
|
||||||
|
SHARED_UID,
|
||||||
|
NO_INTERNET,
|
||||||
|
FIREWALL_APP,
|
||||||
|
WORK_PROFILE_ONLY,
|
||||||
|
UNRESOLVED,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class BlockabilityAssessment(
|
||||||
|
val blockability: AppBlockability,
|
||||||
|
val warnAllProfiles: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
const val SHIZUKU_PACKAGE = "moe.shizuku.privileged.api"
|
||||||
|
|
||||||
|
val FIREWALL_PACKAGES: Set<String> = setOf(SHIZUKU_PACKAGE)
|
||||||
|
|
||||||
|
fun blockabilityOf(
|
||||||
|
app: InstalledApp,
|
||||||
|
uidCounts: Map<Int, Int>,
|
||||||
|
firewallPackages: Set<String>,
|
||||||
|
): AppBlockability = when {
|
||||||
|
app.packageName in firewallPackages -> AppBlockability.FIREWALL_APP
|
||||||
|
app.uid <= 0 -> AppBlockability.UNRESOLVED
|
||||||
|
app.uid % PER_USER_RANGE < FIRST_APPLICATION_UID -> AppBlockability.SYSTEM_UID
|
||||||
|
!app.hasInternetPermission -> AppBlockability.NO_INTERNET
|
||||||
|
(uidCounts[app.uid] ?: 0) > 1 -> AppBlockability.SHARED_UID
|
||||||
|
else -> AppBlockability.BLOCKABLE
|
||||||
|
}
|
||||||
|
|
||||||
|
fun blockabilityFor(
|
||||||
|
app: InstalledApp,
|
||||||
|
uidCounts: Map<Int, Int>,
|
||||||
|
packageCounts: Map<String, Int>,
|
||||||
|
rulesTransport: RulesTransport?,
|
||||||
|
firewallPackages: Set<String>,
|
||||||
|
): BlockabilityAssessment {
|
||||||
|
val baseBlockability = blockabilityOf(app, uidCounts, firewallPackages)
|
||||||
|
val profileCopies = packageCounts[app.packageName] ?: 1
|
||||||
|
val workProfileOnly = baseBlockability == AppBlockability.BLOCKABLE &&
|
||||||
|
rulesTransport == RulesTransport.SHELL &&
|
||||||
|
app.isWorkProfile &&
|
||||||
|
profileCopies == 1
|
||||||
|
return BlockabilityAssessment(
|
||||||
|
blockability = if (workProfileOnly) {
|
||||||
|
AppBlockability.WORK_PROFILE_ONLY
|
||||||
|
} else {
|
||||||
|
baseBlockability
|
||||||
|
},
|
||||||
|
warnAllProfiles = rulesTransport == RulesTransport.SHELL && profileCopies > 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val PER_USER_RANGE = 100_000
|
||||||
|
private const val FIRST_APPLICATION_UID = 10_000
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
package com.krrishg.kwall.firewall
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.preferences.core.MutablePreferences
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.emptyPreferences
|
||||||
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.emitAll
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
private val Context.firewallDataStore by preferencesDataStore(name = "firewall_state")
|
||||||
|
|
||||||
|
class DataStoreFirewallStateStore(context: Context) : FirewallStateStore {
|
||||||
|
|
||||||
|
private val dataStore = context.applicationContext.firewallDataStore
|
||||||
|
|
||||||
|
override val state: Flow<FirewallState> = flow {
|
||||||
|
migrateIfNeeded()
|
||||||
|
emitAll(
|
||||||
|
dataStore.data
|
||||||
|
.catch { error ->
|
||||||
|
if (error is IOException) {
|
||||||
|
emit(emptyPreferences())
|
||||||
|
} else {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map(::toState),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setEnabled(enabled: Boolean) {
|
||||||
|
dataStore.edit { preferences ->
|
||||||
|
preferences.ensureProfiles()
|
||||||
|
preferences[ENABLED_KEY] = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setBlocked(key: AppKey, blocked: Boolean) {
|
||||||
|
dataStore.edit { preferences ->
|
||||||
|
preferences.ensureProfiles()
|
||||||
|
val activeId = preferences.activeProfileId() ?: return@edit
|
||||||
|
val blockedApps = preferences[profileAppsKey(activeId)].orEmpty().toMutableSet()
|
||||||
|
if (blocked) {
|
||||||
|
blockedApps.add(key.toString())
|
||||||
|
} else {
|
||||||
|
blockedApps.remove(key.toString())
|
||||||
|
}
|
||||||
|
preferences[profileAppsKey(activeId)] = blockedApps
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun createProfile(name: String): RuleProfile {
|
||||||
|
var created: RuleProfile? = null
|
||||||
|
dataStore.edit { preferences ->
|
||||||
|
preferences.ensureProfiles()
|
||||||
|
val profile = RuleProfile(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
name = name.trim(),
|
||||||
|
blockedApps = emptySet(),
|
||||||
|
createdAtMillis = System.currentTimeMillis(),
|
||||||
|
)
|
||||||
|
preferences.addProfile(profile)
|
||||||
|
created = profile
|
||||||
|
}
|
||||||
|
return checkNotNull(created)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun duplicateProfile(sourceId: String, name: String): RuleProfile? {
|
||||||
|
var created: RuleProfile? = null
|
||||||
|
dataStore.edit { preferences ->
|
||||||
|
preferences.ensureProfiles()
|
||||||
|
val source = preferences.readProfile(sourceId) ?: return@edit
|
||||||
|
val profile = RuleProfile(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
name = name.trim(),
|
||||||
|
blockedApps = source.blockedApps,
|
||||||
|
createdAtMillis = System.currentTimeMillis(),
|
||||||
|
)
|
||||||
|
preferences.addProfile(profile)
|
||||||
|
created = profile
|
||||||
|
}
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun renameProfile(id: String, name: String): Boolean {
|
||||||
|
var renamed = false
|
||||||
|
dataStore.edit { preferences ->
|
||||||
|
preferences.ensureProfiles()
|
||||||
|
if (preferences.readProfile(id) == null) return@edit
|
||||||
|
preferences[profileNameKey(id)] = name.trim()
|
||||||
|
renamed = true
|
||||||
|
}
|
||||||
|
return renamed
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteProfile(id: String): Boolean {
|
||||||
|
var deleted = false
|
||||||
|
dataStore.edit { preferences ->
|
||||||
|
preferences.ensureProfiles()
|
||||||
|
val profileIds = preferences[PROFILE_IDS_KEY].orEmpty()
|
||||||
|
if (id !in profileIds) return@edit
|
||||||
|
val remainingIds = profileIds - id
|
||||||
|
if (remainingIds.isEmpty()) return@edit
|
||||||
|
preferences.remove(profileNameKey(id))
|
||||||
|
preferences.remove(profileAppsKey(id))
|
||||||
|
preferences.remove(profileCreatedKey(id))
|
||||||
|
preferences[PROFILE_IDS_KEY] = remainingIds
|
||||||
|
deleted = true
|
||||||
|
val activeId = preferences[ACTIVE_PROFILE_ID_KEY]
|
||||||
|
if (activeId == id || activeId == null) {
|
||||||
|
preferences[ACTIVE_PROFILE_ID_KEY] = preferences.oldestProfileId(remainingIds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deleted
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setActiveProfile(id: String): Boolean {
|
||||||
|
var activated = false
|
||||||
|
dataStore.edit { preferences ->
|
||||||
|
preferences.ensureProfiles()
|
||||||
|
if (preferences.readProfile(id) == null) return@edit
|
||||||
|
preferences[ACTIVE_PROFILE_ID_KEY] = id
|
||||||
|
activated = true
|
||||||
|
}
|
||||||
|
return activated
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun removeBlockedEverywhere(key: AppKey) {
|
||||||
|
dataStore.edit { preferences ->
|
||||||
|
preferences.ensureProfiles()
|
||||||
|
preferences.profileIds().forEach { id ->
|
||||||
|
val blockedApps = preferences[profileAppsKey(id)].orEmpty()
|
||||||
|
if (key.toString() in blockedApps) {
|
||||||
|
preferences[profileAppsKey(id)] = blockedApps - key.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun removeBlockedForUserEverywhere(userId: Int) {
|
||||||
|
dataStore.edit { preferences ->
|
||||||
|
preferences.ensureProfiles()
|
||||||
|
preferences.profileIds().forEach { id ->
|
||||||
|
val blockedApps = preferences[profileAppsKey(id)].orEmpty()
|
||||||
|
val filtered = blockedApps
|
||||||
|
.mapNotNull(AppKey::parse)
|
||||||
|
.filterNot { it.userId == userId }
|
||||||
|
.map(AppKey::toString)
|
||||||
|
.toSet()
|
||||||
|
if (filtered.size != blockedApps.size) {
|
||||||
|
preferences[profileAppsKey(id)] = filtered
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun migrateIfNeeded() {
|
||||||
|
val preferences = dataStore.data
|
||||||
|
.catch { error ->
|
||||||
|
if (error is IOException) {
|
||||||
|
emit(emptyPreferences())
|
||||||
|
} else {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.first()
|
||||||
|
if (preferences.profileIds().isNotEmpty()) return
|
||||||
|
dataStore.edit { mutablePreferences ->
|
||||||
|
mutablePreferences.ensureProfiles()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MutablePreferences.ensureProfiles() {
|
||||||
|
if (profileIds().isNotEmpty()) return
|
||||||
|
val legacyBlockedApps = this[LEGACY_BLOCKED_APPS_KEY]
|
||||||
|
.orEmpty()
|
||||||
|
.mapNotNull(AppKey::parse)
|
||||||
|
.toSet()
|
||||||
|
val profile = defaultRuleProfile(
|
||||||
|
blockedApps = legacyBlockedApps,
|
||||||
|
createdAtMillis = System.currentTimeMillis(),
|
||||||
|
)
|
||||||
|
addProfile(profile)
|
||||||
|
this[ACTIVE_PROFILE_ID_KEY] = profile.id
|
||||||
|
remove(LEGACY_BLOCKED_APPS_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MutablePreferences.addProfile(profile: RuleProfile) {
|
||||||
|
this[PROFILE_IDS_KEY] = profileIds() + profile.id
|
||||||
|
this[profileNameKey(profile.id)] = profile.name
|
||||||
|
this[profileAppsKey(profile.id)] = profile.blockedApps.map(AppKey::toString).toSet()
|
||||||
|
this[profileCreatedKey(profile.id)] = profile.createdAtMillis
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Preferences.profileIds(): Set<String> =
|
||||||
|
this[PROFILE_IDS_KEY].orEmpty()
|
||||||
|
|
||||||
|
private fun Preferences.activeProfileId(): String? {
|
||||||
|
val activeId = this[ACTIVE_PROFILE_ID_KEY]
|
||||||
|
val ids = profileIds()
|
||||||
|
return activeId?.takeIf { it in ids } ?: oldestProfileId(ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Preferences.oldestProfileId(ids: Set<String>): String {
|
||||||
|
val profiles = ids.mapNotNull { id -> readProfile(id) }
|
||||||
|
return profiles.minWithOrNull(
|
||||||
|
compareBy<RuleProfile> { it.createdAtMillis }.thenBy { it.id },
|
||||||
|
)?.id ?: defaultRuleProfile().id
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Preferences.readProfile(id: String): RuleProfile? {
|
||||||
|
if (id !in profileIds()) return null
|
||||||
|
val name = this[profileNameKey(id)]?.takeIf { it.isNotBlank() } ?: id
|
||||||
|
val blockedApps = this[profileAppsKey(id)]
|
||||||
|
.orEmpty()
|
||||||
|
.mapNotNull(AppKey::parse)
|
||||||
|
.toSet()
|
||||||
|
val createdAtMillis = this[profileCreatedKey(id)] ?: 0L
|
||||||
|
return RuleProfile(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
blockedApps = blockedApps,
|
||||||
|
createdAtMillis = createdAtMillis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toState(preferences: Preferences): FirewallState {
|
||||||
|
val profiles = preferences.profileIds()
|
||||||
|
.mapNotNull { id -> preferences.readProfile(id) }
|
||||||
|
.sortedWith(compareBy<RuleProfile> { it.createdAtMillis }.thenBy { it.id })
|
||||||
|
.ifEmpty { listOf(defaultRuleProfile()) }
|
||||||
|
val activeId = preferences[ACTIVE_PROFILE_ID_KEY]
|
||||||
|
val activeProfileId = activeId?.takeIf { id -> profiles.any { it.id == id } }
|
||||||
|
?: profiles.first().id
|
||||||
|
return FirewallState(
|
||||||
|
enabled = preferences[ENABLED_KEY] ?: false,
|
||||||
|
profiles = profiles,
|
||||||
|
activeProfileId = activeProfileId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val ENABLED_KEY = booleanPreferencesKey("enabled")
|
||||||
|
val LEGACY_BLOCKED_APPS_KEY = stringSetPreferencesKey("blocked_apps")
|
||||||
|
val PROFILE_IDS_KEY = stringSetPreferencesKey("profile_ids")
|
||||||
|
val ACTIVE_PROFILE_ID_KEY = stringPreferencesKey("active_profile_id")
|
||||||
|
|
||||||
|
fun profileNameKey(id: String) = stringPreferencesKey("profile_name_$id")
|
||||||
|
|
||||||
|
fun profileAppsKey(id: String) = stringSetPreferencesKey("profile_apps_$id")
|
||||||
|
|
||||||
|
fun profileCreatedKey(id: String) = longPreferencesKey("profile_created_$id")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.krrishg.kwall.firewall
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
sealed interface FirewallAvailability {
|
||||||
|
data object Checking : FirewallAvailability
|
||||||
|
data object UnsupportedAndroid : FirewallAvailability
|
||||||
|
data object ShizukuNotInstalled : FirewallAvailability
|
||||||
|
data object ShizukuNotRunning : FirewallAvailability
|
||||||
|
data object PermissionDenied : FirewallAvailability
|
||||||
|
data object CommandUnavailable : FirewallAvailability
|
||||||
|
data class Ready(
|
||||||
|
val rulesTransport: RulesTransport,
|
||||||
|
val chainTransport: ChainTransport,
|
||||||
|
) : FirewallAvailability
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class RulesTransport { BINDER, SHELL }
|
||||||
|
|
||||||
|
enum class ChainTransport { SHELL, BINDER }
|
||||||
|
|
||||||
|
data class BlockedApp(
|
||||||
|
val key: AppKey,
|
||||||
|
val uid: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
interface FirewallBackend {
|
||||||
|
val availability: StateFlow<FirewallAvailability>
|
||||||
|
|
||||||
|
suspend fun refreshAvailability()
|
||||||
|
|
||||||
|
suspend fun setChainEnabled(enabled: Boolean)
|
||||||
|
|
||||||
|
suspend fun block(app: BlockedApp)
|
||||||
|
|
||||||
|
suspend fun unblock(app: BlockedApp)
|
||||||
|
|
||||||
|
suspend fun reapply(apps: Set<BlockedApp>)
|
||||||
|
|
||||||
|
suspend fun clearRules()
|
||||||
|
|
||||||
|
suspend fun unblockAll(apps: Set<BlockedApp>)
|
||||||
|
|
||||||
|
suspend fun observe(apps: Set<BlockedApp>): FirewallObservation
|
||||||
|
|
||||||
|
fun requestPermission()
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.krrishg.kwall.firewall
|
||||||
|
|
||||||
|
object FirewallCommands {
|
||||||
|
|
||||||
|
const val CHAIN = 9
|
||||||
|
const val RULE_DEFAULT = 0
|
||||||
|
const val RULE_ALLOW = 1
|
||||||
|
const val RULE_DENY = 2
|
||||||
|
const val RULE_UNKNOWN = -1
|
||||||
|
const val CHAIN_DISABLED = 0
|
||||||
|
const val CHAIN_ENABLED = 1
|
||||||
|
const val CHAIN_UNKNOWN = -1
|
||||||
|
|
||||||
|
private const val CHAIN_COMMAND = "cmd connectivity set-chain3-enabled"
|
||||||
|
private const val PACKAGE_COMMAND = "cmd connectivity set-package-networking-enabled"
|
||||||
|
private const val GET_CHAIN_COMMAND = "cmd connectivity get-chain3-enabled"
|
||||||
|
private const val GET_PACKAGE_COMMAND = "cmd connectivity get-package-networking-enabled"
|
||||||
|
private val PACKAGE_NAME = Regex("[A-Za-z0-9_.]+")
|
||||||
|
|
||||||
|
fun chainEnabled(enabled: Boolean): String = "$CHAIN_COMMAND $enabled"
|
||||||
|
|
||||||
|
fun packageNetworking(packageName: String, enabled: Boolean): String {
|
||||||
|
require(PACKAGE_NAME.matches(packageName)) { "Invalid package name: $packageName" }
|
||||||
|
return "$PACKAGE_COMMAND $enabled $packageName"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun chainState(): String = GET_CHAIN_COMMAND
|
||||||
|
|
||||||
|
fun packageState(packageName: String): String {
|
||||||
|
require(PACKAGE_NAME.matches(packageName)) { "Invalid package name: $packageName" }
|
||||||
|
return "$GET_PACKAGE_COMMAND $packageName"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.krrishg.kwall.firewall
|
||||||
|
|
||||||
|
import com.krrishg.kwall.data.InstalledApp
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
interface FirewallController {
|
||||||
|
val state: StateFlow<FirewallState>
|
||||||
|
val availability: StateFlow<FirewallAvailability>
|
||||||
|
val error: StateFlow<String?>
|
||||||
|
val drift: StateFlow<FirewallDriftState>
|
||||||
|
|
||||||
|
suspend fun setBlocked(app: InstalledApp, blocked: Boolean)
|
||||||
|
|
||||||
|
suspend fun addBlockedSelection(app: InstalledApp): Boolean
|
||||||
|
|
||||||
|
suspend fun setEnabled(enabled: Boolean)
|
||||||
|
|
||||||
|
suspend fun applyScheduledProfile(profileId: String, enableFirewall: Boolean?): Boolean
|
||||||
|
|
||||||
|
suspend fun restore()
|
||||||
|
|
||||||
|
suspend fun checkForDrift()
|
||||||
|
|
||||||
|
suspend fun refreshAvailability()
|
||||||
|
|
||||||
|
fun requestPermission()
|
||||||
|
|
||||||
|
suspend fun removeBlocked(key: AppKey)
|
||||||
|
|
||||||
|
suspend fun removeBlockedForUser(userId: Int)
|
||||||
|
|
||||||
|
suspend fun switchProfile(id: String)
|
||||||
|
|
||||||
|
suspend fun createProfile(name: String): Boolean
|
||||||
|
|
||||||
|
suspend fun duplicateProfile(sourceId: String, name: String): Boolean
|
||||||
|
|
||||||
|
suspend fun renameProfile(id: String, name: String): Boolean
|
||||||
|
|
||||||
|
suspend fun deleteProfile(id: String): Boolean
|
||||||
|
|
||||||
|
suspend fun removeBlockedEverywhere(key: AppKey)
|
||||||
|
|
||||||
|
suspend fun removeBlockedForUserEverywhere(userId: Int)
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.krrishg.kwall.firewall
|
||||||
|
|
||||||
|
data class FirewallObservation(
|
||||||
|
val chainEnabled: Boolean? = null,
|
||||||
|
val appStates: Map<AppKey, Boolean> = emptyMap(),
|
||||||
|
) {
|
||||||
|
val hasAnyData: Boolean get() = chainEnabled != null || appStates.isNotEmpty()
|
||||||
|
|
||||||
|
fun covers(expected: Set<BlockedApp>): Boolean =
|
||||||
|
chainEnabled != null && expected.all { it.key in appStates }
|
||||||
|
}
|
||||||
|
|
||||||
|
data class FirewallDrift(
|
||||||
|
val chainMismatch: Boolean = false,
|
||||||
|
val missingRules: Set<AppKey> = emptySet(),
|
||||||
|
val staleRules: Set<AppKey> = emptySet(),
|
||||||
|
) {
|
||||||
|
val detected: Boolean
|
||||||
|
get() = chainMismatch || missingRules.isNotEmpty() || staleRules.isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class FirewallDriftStatus {
|
||||||
|
IDLE,
|
||||||
|
CHECKING,
|
||||||
|
IN_SYNC,
|
||||||
|
REPAIRED,
|
||||||
|
DRIFT_DETECTED,
|
||||||
|
UNAVAILABLE,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class FirewallDriftState(
|
||||||
|
val status: FirewallDriftStatus = FirewallDriftStatus.IDLE,
|
||||||
|
val drift: FirewallDrift? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
object FirewallDriftDetector {
|
||||||
|
|
||||||
|
fun detect(
|
||||||
|
expectedEnabled: Boolean,
|
||||||
|
expectedBlocked: Set<BlockedApp>,
|
||||||
|
candidates: Set<BlockedApp>,
|
||||||
|
observation: FirewallObservation,
|
||||||
|
): FirewallDrift {
|
||||||
|
val expectedKeys = expectedBlocked.mapTo(HashSet()) { it.key }
|
||||||
|
val missingRules = expectedBlocked
|
||||||
|
.asSequence()
|
||||||
|
.map { it.key }
|
||||||
|
.filter { key -> observation.appStates[key] == false }
|
||||||
|
.toSet()
|
||||||
|
val staleRules = candidates
|
||||||
|
.asSequence()
|
||||||
|
.map { it.key }
|
||||||
|
.filter { key -> key !in expectedKeys && observation.appStates[key] == true }
|
||||||
|
.toSet()
|
||||||
|
return FirewallDrift(
|
||||||
|
chainMismatch = observation.chainEnabled != null &&
|
||||||
|
observation.chainEnabled != expectedEnabled,
|
||||||
|
missingRules = missingRules,
|
||||||
|
staleRules = staleRules,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.krrishg.kwall.firewall
|
||||||
|
|
||||||
|
class FirewallException(
|
||||||
|
message: String,
|
||||||
|
cause: Throwable? = null,
|
||||||
|
) : Exception(message, cause)
|
||||||
@@ -0,0 +1,507 @@
|
|||||||
|
package com.krrishg.kwall.firewall
|
||||||
|
|
||||||
|
import com.krrishg.kwall.data.InstalledApp
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
class FirewallManager(
|
||||||
|
private val store: FirewallStateStore,
|
||||||
|
private val backend: FirewallBackend,
|
||||||
|
private val uidResolver: UidResolver,
|
||||||
|
scope: CoroutineScope,
|
||||||
|
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
|
private val driftCheckIntervalMillis: Long = DEFAULT_DRIFT_CHECK_INTERVAL_MS,
|
||||||
|
) : FirewallController {
|
||||||
|
|
||||||
|
private val operationMutex = Mutex()
|
||||||
|
private val _error = MutableStateFlow<String?>(null)
|
||||||
|
private val _drift = MutableStateFlow(FirewallDriftState())
|
||||||
|
|
||||||
|
override val error: StateFlow<String?> = _error.asStateFlow()
|
||||||
|
override val drift: StateFlow<FirewallDriftState> = _drift.asStateFlow()
|
||||||
|
override val state: StateFlow<FirewallState> = store.state
|
||||||
|
.stateIn(scope, SharingStarted.Eagerly, FirewallState())
|
||||||
|
override val availability: StateFlow<FirewallAvailability> = backend.availability
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch {
|
||||||
|
availability.collect { value ->
|
||||||
|
if (value is FirewallAvailability.Ready) {
|
||||||
|
restore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
while (isActive) {
|
||||||
|
delay(driftCheckIntervalMillis)
|
||||||
|
val current = state.value
|
||||||
|
if (!current.enabled && current.blockedApps.isEmpty()) continue
|
||||||
|
if (availability.value !is FirewallAvailability.Ready) continue
|
||||||
|
checkForDrift()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setBlocked(app: InstalledApp, blocked: Boolean) {
|
||||||
|
operationMutex.withLock {
|
||||||
|
_error.value = null
|
||||||
|
val current = store.state.first()
|
||||||
|
try {
|
||||||
|
val blockedApp = BlockedApp(app.key, app.uid)
|
||||||
|
if (blocked) {
|
||||||
|
if (current.enabled) {
|
||||||
|
backend.setChainEnabled(true)
|
||||||
|
backend.block(blockedApp)
|
||||||
|
}
|
||||||
|
store.setBlocked(app.key, true)
|
||||||
|
} else {
|
||||||
|
if (current.enabled) {
|
||||||
|
backend.unblock(blockedApp)
|
||||||
|
}
|
||||||
|
store.setBlocked(app.key, false)
|
||||||
|
}
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
_error.value = error.message ?: error.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun addBlockedSelection(app: InstalledApp): Boolean = operationMutex.withLock {
|
||||||
|
_error.value = null
|
||||||
|
val current = store.state.first()
|
||||||
|
if (app.key in current.blockedApps) return@withLock false
|
||||||
|
try {
|
||||||
|
if (current.enabled) {
|
||||||
|
backend.setChainEnabled(true)
|
||||||
|
backend.block(BlockedApp(app.key, app.uid))
|
||||||
|
}
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
_error.value = error.message ?: error.toString()
|
||||||
|
}
|
||||||
|
store.setBlocked(app.key, true)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setEnabled(enabled: Boolean) {
|
||||||
|
operationMutex.withLock {
|
||||||
|
_error.value = null
|
||||||
|
val current = store.state.first()
|
||||||
|
try {
|
||||||
|
if (enabled) {
|
||||||
|
backend.reapply(resolve(current.blockedApps))
|
||||||
|
store.setEnabled(true)
|
||||||
|
} else {
|
||||||
|
val apps = resolve(current.blockedApps)
|
||||||
|
if (apps.isNotEmpty()) backend.unblockAll(apps)
|
||||||
|
backend.setChainEnabled(false)
|
||||||
|
try {
|
||||||
|
backend.clearRules()
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
_error.value = error.message ?: error.toString()
|
||||||
|
}
|
||||||
|
store.setEnabled(false)
|
||||||
|
}
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
_error.value = error.message ?: error.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun applyScheduledProfile(
|
||||||
|
profileId: String,
|
||||||
|
enableFirewall: Boolean?,
|
||||||
|
): Boolean = operationMutex.withLock {
|
||||||
|
_error.value = null
|
||||||
|
val current = store.state.first()
|
||||||
|
val target = current.profiles.firstOrNull { it.id == profileId } ?: return@withLock false
|
||||||
|
val desiredEnabled = enableFirewall ?: current.enabled
|
||||||
|
val targetApps = resolve(target.blockedApps)
|
||||||
|
val currentApps = resolve(current.blockedApps)
|
||||||
|
try {
|
||||||
|
if (desiredEnabled != current.enabled) {
|
||||||
|
store.setEnabled(desiredEnabled)
|
||||||
|
}
|
||||||
|
if (target.id != current.activeProfileId && !store.setActiveProfile(target.id)) {
|
||||||
|
return@withLock false
|
||||||
|
}
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
_error.value = error.message ?: error.toString()
|
||||||
|
return@withLock false
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (desiredEnabled) {
|
||||||
|
val removed = currentApps - targetApps
|
||||||
|
if (removed.isNotEmpty()) backend.unblockAll(removed)
|
||||||
|
backend.reapply(targetApps)
|
||||||
|
} else {
|
||||||
|
if (currentApps.isNotEmpty()) backend.unblockAll(currentApps)
|
||||||
|
backend.setChainEnabled(false)
|
||||||
|
try {
|
||||||
|
backend.clearRules()
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
_error.value = error.message ?: error.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
_error.value = error.message ?: error.toString()
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun restore() {
|
||||||
|
operationMutex.withLock {
|
||||||
|
val current = store.state.first()
|
||||||
|
if (!current.enabled) {
|
||||||
|
_error.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
backend.reapply(resolve(current.blockedApps))
|
||||||
|
_error.value = null
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
_error.value = error.message ?: error.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun checkForDrift() {
|
||||||
|
operationMutex.withLock {
|
||||||
|
if (availability.value !is FirewallAvailability.Ready) {
|
||||||
|
_drift.value = FirewallDriftState(FirewallDriftStatus.UNAVAILABLE)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_drift.value = FirewallDriftState(FirewallDriftStatus.CHECKING)
|
||||||
|
val current = store.state.first()
|
||||||
|
val candidates = resolve(current.blockedApps)
|
||||||
|
val expected = if (current.enabled) candidates else emptySet()
|
||||||
|
try {
|
||||||
|
val observation = backend.observe(candidates)
|
||||||
|
if (!observation.covers(expected)) {
|
||||||
|
if (!observation.hasAnyData && current.enabled) {
|
||||||
|
_error.value = null
|
||||||
|
backend.reapply(expected)
|
||||||
|
}
|
||||||
|
_drift.value = FirewallDriftState(FirewallDriftStatus.UNAVAILABLE)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val drift = FirewallDriftDetector.detect(
|
||||||
|
expectedEnabled = current.enabled,
|
||||||
|
expectedBlocked = expected,
|
||||||
|
candidates = candidates,
|
||||||
|
observation = observation,
|
||||||
|
)
|
||||||
|
if (!drift.detected) {
|
||||||
|
_drift.value = FirewallDriftState(FirewallDriftStatus.IN_SYNC, drift)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_error.value = null
|
||||||
|
repairDrift(current.enabled, expected, candidates)
|
||||||
|
_drift.value = FirewallDriftState(FirewallDriftStatus.REPAIRED, drift)
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
_drift.value = FirewallDriftState(FirewallDriftStatus.DRIFT_DETECTED)
|
||||||
|
_error.value = error.message ?: error.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun repairDrift(
|
||||||
|
enabled: Boolean,
|
||||||
|
expected: Set<BlockedApp>,
|
||||||
|
candidates: Set<BlockedApp>,
|
||||||
|
) {
|
||||||
|
if (enabled) {
|
||||||
|
backend.reapply(expected)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (candidates.isNotEmpty()) backend.unblockAll(candidates)
|
||||||
|
backend.setChainEnabled(false)
|
||||||
|
try {
|
||||||
|
backend.clearRules()
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
_error.value = error.message ?: error.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun refreshAvailability() {
|
||||||
|
try {
|
||||||
|
backend.refreshAvailability()
|
||||||
|
_error.value = null
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
_error.value = error.message ?: error.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun requestPermission() {
|
||||||
|
backend.requestPermission()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun removeBlocked(key: AppKey) {
|
||||||
|
operationMutex.withLock {
|
||||||
|
val current = store.state.first()
|
||||||
|
if (key !in current.blockedApps) return
|
||||||
|
if (current.enabled) {
|
||||||
|
replaceWith(current.blockedApps - key)
|
||||||
|
}
|
||||||
|
store.setBlocked(key, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
override suspend fun removeBlockedForUser(userId: Int) {
|
||||||
|
operationMutex.withLock {
|
||||||
|
val current = store.state.first()
|
||||||
|
val keys = current.blockedApps.filter { it.userId == userId }
|
||||||
|
if (keys.isEmpty()) return
|
||||||
|
if (current.enabled) {
|
||||||
|
replaceWith(current.blockedApps - keys.toSet())
|
||||||
|
}
|
||||||
|
keys.forEach { key -> store.setBlocked(key, false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun switchProfile(id: String) {
|
||||||
|
operationMutex.withLock {
|
||||||
|
_error.value = null
|
||||||
|
val current = store.state.first()
|
||||||
|
if (current.activeProfileId == id) return@withLock
|
||||||
|
val target = current.profiles.firstOrNull { it.id == id } ?: return@withLock
|
||||||
|
try {
|
||||||
|
applyProfileChange(
|
||||||
|
oldBlockedApps = current.blockedApps,
|
||||||
|
newBlockedApps = target.blockedApps,
|
||||||
|
enabled = current.enabled,
|
||||||
|
)
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
_error.value = error.message ?: error.toString()
|
||||||
|
return@withLock
|
||||||
|
}
|
||||||
|
if (!store.setActiveProfile(id)) {
|
||||||
|
_error.value = "Could not switch profile"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun createProfile(name: String): Boolean = operationMutex.withLock {
|
||||||
|
_error.value = null
|
||||||
|
val current = store.state.first()
|
||||||
|
val normalized = name.trim()
|
||||||
|
val issue = validateProfileName(normalized, current.profiles)
|
||||||
|
if (issue != null) {
|
||||||
|
_error.value = issue.message()
|
||||||
|
return@withLock false
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
store.createProfile(normalized)
|
||||||
|
true
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
_error.value = error.message ?: error.toString()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun duplicateProfile(sourceId: String, name: String): Boolean =
|
||||||
|
operationMutex.withLock {
|
||||||
|
_error.value = null
|
||||||
|
val current = store.state.first()
|
||||||
|
val source = current.profiles.firstOrNull { it.id == sourceId } ?: return@withLock false
|
||||||
|
val normalized = name.trim().ifBlank {
|
||||||
|
duplicateProfileName(source.name, current.profiles)
|
||||||
|
}
|
||||||
|
val issue = validateProfileName(normalized, current.profiles)
|
||||||
|
if (issue != null) {
|
||||||
|
_error.value = issue.message()
|
||||||
|
return@withLock false
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
store.duplicateProfile(sourceId, normalized) != null
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
_error.value = error.message ?: error.toString()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun renameProfile(id: String, name: String): Boolean =
|
||||||
|
operationMutex.withLock {
|
||||||
|
_error.value = null
|
||||||
|
val current = store.state.first()
|
||||||
|
val target = current.profiles.firstOrNull { it.id == id } ?: return@withLock false
|
||||||
|
val normalized = name.trim()
|
||||||
|
val issue = validateProfileName(normalized, current.profiles, excludingId = id)
|
||||||
|
if (issue != null) {
|
||||||
|
_error.value = issue.message()
|
||||||
|
return@withLock false
|
||||||
|
}
|
||||||
|
if (target.name == normalized) return@withLock true
|
||||||
|
try {
|
||||||
|
store.renameProfile(id, normalized)
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
_error.value = error.message ?: error.toString()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteProfile(id: String): Boolean = operationMutex.withLock {
|
||||||
|
_error.value = null
|
||||||
|
val current = store.state.first()
|
||||||
|
current.profiles.firstOrNull { it.id == id } ?: return@withLock false
|
||||||
|
if (current.profiles.size <= 1) {
|
||||||
|
_error.value = "At least one profile is required"
|
||||||
|
return@withLock false
|
||||||
|
}
|
||||||
|
if (current.activeProfileId == id) {
|
||||||
|
val fallback = current.profiles
|
||||||
|
.filterNot { it.id == id }
|
||||||
|
.minWithOrNull(compareBy<RuleProfile> { it.createdAtMillis }.thenBy { it.id })
|
||||||
|
?: return@withLock false
|
||||||
|
try {
|
||||||
|
applyProfileChange(
|
||||||
|
oldBlockedApps = current.blockedApps,
|
||||||
|
newBlockedApps = fallback.blockedApps,
|
||||||
|
enabled = current.enabled,
|
||||||
|
)
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
_error.value = error.message ?: error.toString()
|
||||||
|
return@withLock false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val deleted = store.deleteProfile(id)
|
||||||
|
if (!deleted) _error.value = "Could not delete profile"
|
||||||
|
deleted
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
_error.value = error.message ?: error.toString()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun removeBlockedEverywhere(key: AppKey) {
|
||||||
|
operationMutex.withLock {
|
||||||
|
val current = store.state.first()
|
||||||
|
if (key in current.blockedApps) {
|
||||||
|
replaceWith(current.blockedApps - key)
|
||||||
|
}
|
||||||
|
store.removeBlockedEverywhere(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun removeBlockedForUserEverywhere(userId: Int) {
|
||||||
|
operationMutex.withLock {
|
||||||
|
val current = store.state.first()
|
||||||
|
val keys = current.blockedApps.filter { it.userId == userId }.toSet()
|
||||||
|
if (keys.isNotEmpty()) {
|
||||||
|
replaceWith(current.blockedApps - keys)
|
||||||
|
}
|
||||||
|
store.removeBlockedForUserEverywhere(userId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun applyProfileChange(
|
||||||
|
oldBlockedApps: Set<AppKey>,
|
||||||
|
newBlockedApps: Set<AppKey>,
|
||||||
|
enabled: Boolean,
|
||||||
|
) {
|
||||||
|
val oldResolved = resolve(oldBlockedApps)
|
||||||
|
val newResolved = resolve(newBlockedApps)
|
||||||
|
val removed = oldResolved - newResolved
|
||||||
|
if (enabled) {
|
||||||
|
if (removed.isNotEmpty()) backend.unblockAll(removed)
|
||||||
|
backend.reapply(newResolved)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (removed.isEmpty()) return
|
||||||
|
try {
|
||||||
|
backend.unblockAll(removed)
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun duplicateProfileName(
|
||||||
|
sourceName: String,
|
||||||
|
profiles: List<RuleProfile>,
|
||||||
|
): String {
|
||||||
|
val baseName = "$sourceName copy"
|
||||||
|
if (profiles.none { it.name.equals(baseName, ignoreCase = true) }) return baseName
|
||||||
|
var index = 2
|
||||||
|
while (true) {
|
||||||
|
val candidate = "$baseName $index"
|
||||||
|
if (profiles.none { it.name.equals(candidate, ignoreCase = true) }) return candidate
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ProfileNameIssue.message(): String = when (this) {
|
||||||
|
ProfileNameIssue.BLANK -> "Profile name cannot be empty"
|
||||||
|
ProfileNameIssue.TOO_LONG -> "Profile name is too long"
|
||||||
|
ProfileNameIssue.DUPLICATE -> "A profile with this name already exists"
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun replaceWith(keys: Set<AppKey>) {
|
||||||
|
try {
|
||||||
|
backend.reapply(resolve(keys))
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
_error.value = error.message ?: error.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun resolve(keys: Set<AppKey>): Set<BlockedApp> = withContext(ioDispatcher) {
|
||||||
|
keys.mapNotNull { key ->
|
||||||
|
uidResolver.resolve(key)?.let { uid -> BlockedApp(key, uid) }
|
||||||
|
}.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val DEFAULT_DRIFT_CHECK_INTERVAL_MS = 60_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.krrishg.kwall.firewall
|
||||||
|
|
||||||
|
object FirewallObservationParser {
|
||||||
|
|
||||||
|
fun chainEnabled(result: ShellResult): Boolean? {
|
||||||
|
if (!result.isEffectivelySuccessful) return null
|
||||||
|
val value = result.stdout
|
||||||
|
.lineSequence()
|
||||||
|
.map(String::trim)
|
||||||
|
.firstOrNull { it.startsWith(CHAIN_PREFIX) }
|
||||||
|
?.substringAfter(':')
|
||||||
|
?.trim()
|
||||||
|
?.lowercase()
|
||||||
|
?: return null
|
||||||
|
return when (value) {
|
||||||
|
"enabled", "true" -> true
|
||||||
|
"disabled", "false" -> false
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun packageDenied(result: ShellResult, packageName: String): Boolean? {
|
||||||
|
if (!result.isEffectivelySuccessful) return null
|
||||||
|
val prefix = "$packageName:"
|
||||||
|
val value = result.stdout
|
||||||
|
.lineSequence()
|
||||||
|
.map(String::trim)
|
||||||
|
.lastOrNull { it.startsWith(prefix) }
|
||||||
|
?.substringAfter(':')
|
||||||
|
?.trim()
|
||||||
|
?.lowercase()
|
||||||
|
?: return null
|
||||||
|
return when (value) {
|
||||||
|
"deny", "denied", "false" -> true
|
||||||
|
"allow", "allowed", "true" -> false
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val CHAIN_PREFIX = "chain:"
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.krrishg.kwall.firewall
|
||||||
|
|
||||||
|
data class FirewallState(
|
||||||
|
val enabled: Boolean = false,
|
||||||
|
val profiles: List<RuleProfile> = listOf(defaultRuleProfile()),
|
||||||
|
val activeProfileId: String = DEFAULT_PROFILE_ID,
|
||||||
|
) {
|
||||||
|
val activeProfile: RuleProfile
|
||||||
|
get() = profiles.firstOrNull { it.id == activeProfileId }
|
||||||
|
?: profiles.firstOrNull()
|
||||||
|
?: defaultRuleProfile()
|
||||||
|
|
||||||
|
val blockedApps: Set<AppKey>
|
||||||
|
get() = activeProfile.blockedApps
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.krrishg.kwall.firewall
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
interface FirewallStateStore {
|
||||||
|
val state: Flow<FirewallState>
|
||||||
|
|
||||||
|
suspend fun setEnabled(enabled: Boolean)
|
||||||
|
|
||||||
|
suspend fun setBlocked(key: AppKey, blocked: Boolean)
|
||||||
|
|
||||||
|
suspend fun createProfile(name: String): RuleProfile
|
||||||
|
|
||||||
|
suspend fun duplicateProfile(sourceId: String, name: String): RuleProfile?
|
||||||
|
|
||||||
|
suspend fun renameProfile(id: String, name: String): Boolean
|
||||||
|
|
||||||
|
suspend fun deleteProfile(id: String): Boolean
|
||||||
|
|
||||||
|
suspend fun setActiveProfile(id: String): Boolean
|
||||||
|
|
||||||
|
suspend fun removeBlockedEverywhere(key: AppKey)
|
||||||
|
|
||||||
|
suspend fun removeBlockedForUserEverywhere(userId: Int)
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
package com.krrishg.kwall.firewall
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.DeadObjectException
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.Process
|
||||||
|
import android.util.Log
|
||||||
|
import java.lang.reflect.InvocationTargetException
|
||||||
|
import java.lang.reflect.Method
|
||||||
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
|
class FirewallUserService(
|
||||||
|
private val appContext: Context? = null,
|
||||||
|
) : IFirewallService.Stub() {
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var cachedService: Any? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var cachedRuleMethod: Method? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var cachedChainMethod: Method? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var cachedReplaceMethod: Method? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var cachedRuleQueryMethod: Method? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var cachedChainQueryMethod: Method? = null
|
||||||
|
|
||||||
|
override fun probe(): String? =
|
||||||
|
applyRule(FirewallCommands.CHAIN, Process.myUid(), FirewallCommands.RULE_DEFAULT)
|
||||||
|
|
||||||
|
override fun setUidFirewallRule(chain: Int, uid: Int, rule: Int): String? =
|
||||||
|
applyRule(chain, uid, rule)
|
||||||
|
|
||||||
|
override fun setUidFirewallRules(chain: Int, uids: IntArray, rules: IntArray): Array<String?> {
|
||||||
|
val size = minOf(uids.size, rules.size)
|
||||||
|
return Array(size) { index -> applyRule(chain, uids[index], rules[index]) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setFirewallChainEnabled(chain: Int, enabled: Boolean): String? {
|
||||||
|
return try {
|
||||||
|
val service = connectivityService() ?: return "Connectivity service is unavailable"
|
||||||
|
val method = chainMethod(service) ?: return "setFirewallChainEnabled is unavailable"
|
||||||
|
method.invoke(service, chain, enabled)
|
||||||
|
null
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
failure(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun replaceUidFirewallChain(chain: Int, uids: IntArray): String? {
|
||||||
|
return try {
|
||||||
|
val service = connectivityService() ?: return "Connectivity service is unavailable"
|
||||||
|
val method = replaceMethod(service)
|
||||||
|
if (method != null) {
|
||||||
|
method.invoke(service, chain, uids)
|
||||||
|
} else {
|
||||||
|
val ruleMethod = ruleMethod(service)
|
||||||
|
?: return "replaceFirewallChain and setUidFirewallRule are unavailable"
|
||||||
|
uids.forEach { uid ->
|
||||||
|
ruleMethod.invoke(service, chain, uid, FirewallCommands.RULE_DENY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
null
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
failure(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getUidFirewallRule(chain: Int, uid: Int): Int {
|
||||||
|
return try {
|
||||||
|
val service = connectivityService() ?: return FirewallCommands.RULE_UNKNOWN
|
||||||
|
val method = ruleQueryMethod(service) ?: return FirewallCommands.RULE_UNKNOWN
|
||||||
|
method.invoke(service, chain, uid) as? Int ?: FirewallCommands.RULE_UNKNOWN
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
failure(error)
|
||||||
|
FirewallCommands.RULE_UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getFirewallChainEnabled(chain: Int): Int {
|
||||||
|
return try {
|
||||||
|
val service = connectivityService() ?: return FirewallCommands.CHAIN_UNKNOWN
|
||||||
|
val method = chainQueryMethod(service) ?: return FirewallCommands.CHAIN_UNKNOWN
|
||||||
|
when (method.invoke(service, chain)) {
|
||||||
|
true -> FirewallCommands.CHAIN_ENABLED
|
||||||
|
false -> FirewallCommands.CHAIN_DISABLED
|
||||||
|
else -> FirewallCommands.CHAIN_UNKNOWN
|
||||||
|
}
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
failure(error)
|
||||||
|
FirewallCommands.CHAIN_UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun destroy() {
|
||||||
|
if (runCatching { isPackageRemoved() }.getOrDefault(false)) {
|
||||||
|
runCatching { setFirewallChainEnabled(FirewallCommands.CHAIN, false) }
|
||||||
|
runCatching { replaceUidFirewallChain(FirewallCommands.CHAIN, IntArray(0)) }
|
||||||
|
}
|
||||||
|
exitProcess(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isPackageRemoved(): Boolean {
|
||||||
|
val context = appContext ?: return false
|
||||||
|
repeat(UNINSTALL_CHECK_ATTEMPTS) {
|
||||||
|
if (!isPackageInstalled(context)) {
|
||||||
|
Thread.sleep(REMOVAL_CONFIRM_DELAY_MS)
|
||||||
|
return !isPackageInstalled(context)
|
||||||
|
}
|
||||||
|
Thread.sleep(UNINSTALL_CHECK_DELAY_MS)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isPackageInstalled(context: Context): Boolean =
|
||||||
|
runCatching {
|
||||||
|
context.packageManager.getApplicationInfo(context.packageName, 0)
|
||||||
|
}.isSuccess
|
||||||
|
|
||||||
|
private fun applyRule(chain: Int, uid: Int, rule: Int): String? {
|
||||||
|
return try {
|
||||||
|
val service = connectivityService() ?: return "Connectivity service is unavailable"
|
||||||
|
val method = ruleMethod(service) ?: return "setUidFirewallRule is unavailable"
|
||||||
|
method.invoke(service, chain, uid, rule)
|
||||||
|
null
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
failure(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ruleMethod(service: Any): Method? {
|
||||||
|
cachedRuleMethod?.let { return it }
|
||||||
|
val method = runCatching {
|
||||||
|
service.javaClass.getMethod(
|
||||||
|
"setUidFirewallRule",
|
||||||
|
Int::class.javaPrimitiveType,
|
||||||
|
Int::class.javaPrimitiveType,
|
||||||
|
Int::class.javaPrimitiveType,
|
||||||
|
)
|
||||||
|
}.getOrNull() ?: return null
|
||||||
|
cachedRuleMethod = method
|
||||||
|
return method
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun chainMethod(service: Any): Method? {
|
||||||
|
cachedChainMethod?.let { return it }
|
||||||
|
val method = runCatching {
|
||||||
|
service.javaClass.getMethod(
|
||||||
|
"setFirewallChainEnabled",
|
||||||
|
Int::class.javaPrimitiveType,
|
||||||
|
Boolean::class.javaPrimitiveType,
|
||||||
|
)
|
||||||
|
}.getOrNull() ?: return null
|
||||||
|
cachedChainMethod = method
|
||||||
|
return method
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun replaceMethod(service: Any): Method? {
|
||||||
|
cachedReplaceMethod?.let { return it }
|
||||||
|
val method = runCatching {
|
||||||
|
service.javaClass.getMethod(
|
||||||
|
"replaceFirewallChain",
|
||||||
|
Int::class.javaPrimitiveType,
|
||||||
|
IntArray::class.java,
|
||||||
|
)
|
||||||
|
}.getOrNull() ?: return null
|
||||||
|
cachedReplaceMethod = method
|
||||||
|
return method
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ruleQueryMethod(service: Any): Method? {
|
||||||
|
cachedRuleQueryMethod?.let { return it }
|
||||||
|
val method = runCatching {
|
||||||
|
service.javaClass.getMethod(
|
||||||
|
"getUidFirewallRule",
|
||||||
|
Int::class.javaPrimitiveType,
|
||||||
|
Int::class.javaPrimitiveType,
|
||||||
|
)
|
||||||
|
}.getOrNull() ?: return null
|
||||||
|
cachedRuleQueryMethod = method
|
||||||
|
return method
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun chainQueryMethod(service: Any): Method? {
|
||||||
|
cachedChainQueryMethod?.let { return it }
|
||||||
|
val method = runCatching {
|
||||||
|
service.javaClass.getMethod(
|
||||||
|
"getFirewallChainEnabled",
|
||||||
|
Int::class.javaPrimitiveType,
|
||||||
|
)
|
||||||
|
}.getOrNull() ?: return null
|
||||||
|
cachedChainQueryMethod = method
|
||||||
|
return method
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun connectivityService(): Any? {
|
||||||
|
cachedService?.let { return it }
|
||||||
|
val binder = Class.forName("android.os.ServiceManager")
|
||||||
|
.getMethod("getService", String::class.java)
|
||||||
|
.invoke(null, "connectivity") as? IBinder ?: return null
|
||||||
|
val service = Class.forName("android.net.IConnectivityManager\$Stub")
|
||||||
|
.getMethod("asInterface", IBinder::class.java)
|
||||||
|
.invoke(null, binder) ?: return null
|
||||||
|
cachedService = service
|
||||||
|
return service
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun failure(error: Throwable): String? {
|
||||||
|
val cause = (error as? InvocationTargetException)?.targetException ?: error
|
||||||
|
val text = cause.message ?: cause.toString()
|
||||||
|
if (text.contains(UID_OWNER_MAP_MISSING, ignoreCase = true)) return null
|
||||||
|
if (cause is DeadObjectException) {
|
||||||
|
cachedService = null
|
||||||
|
cachedRuleMethod = null
|
||||||
|
cachedChainMethod = null
|
||||||
|
cachedReplaceMethod = null
|
||||||
|
cachedRuleQueryMethod = null
|
||||||
|
cachedChainQueryMethod = null
|
||||||
|
}
|
||||||
|
Log.w(TAG, "Firewall binder call failed", cause)
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "FirewallUserService"
|
||||||
|
const val UID_OWNER_MAP_MISSING = "suidownermap does not have entry for uid"
|
||||||
|
const val UNINSTALL_CHECK_ATTEMPTS = 20
|
||||||
|
const val UNINSTALL_CHECK_DELAY_MS = 100L
|
||||||
|
const val REMOVAL_CONFIRM_DELAY_MS = 250L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.krrishg.kwall.firewall
|
||||||
|
|
||||||
|
const val DEFAULT_PROFILE_ID = "default"
|
||||||
|
const val DEFAULT_PROFILE_NAME = "Default"
|
||||||
|
const val MAX_PROFILE_NAME_LENGTH = 40
|
||||||
|
|
||||||
|
data class RuleProfile(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val blockedApps: Set<AppKey> = emptySet(),
|
||||||
|
val createdAtMillis: Long = 0L,
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class ProfileNameIssue {
|
||||||
|
BLANK,
|
||||||
|
TOO_LONG,
|
||||||
|
DUPLICATE,
|
||||||
|
}
|
||||||
|
|
||||||
|
fun defaultRuleProfile(
|
||||||
|
blockedApps: Set<AppKey> = emptySet(),
|
||||||
|
createdAtMillis: Long = 0L,
|
||||||
|
): RuleProfile = RuleProfile(
|
||||||
|
id = DEFAULT_PROFILE_ID,
|
||||||
|
name = DEFAULT_PROFILE_NAME,
|
||||||
|
blockedApps = blockedApps,
|
||||||
|
createdAtMillis = createdAtMillis,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun validateProfileName(
|
||||||
|
name: String,
|
||||||
|
profiles: List<RuleProfile>,
|
||||||
|
excludingId: String? = null,
|
||||||
|
): ProfileNameIssue? {
|
||||||
|
val normalized = name.trim()
|
||||||
|
if (normalized.isEmpty()) return ProfileNameIssue.BLANK
|
||||||
|
if (normalized.length > MAX_PROFILE_NAME_LENGTH) return ProfileNameIssue.TOO_LONG
|
||||||
|
val duplicate = profiles.any { profile ->
|
||||||
|
profile.id != excludingId && profile.name.equals(normalized, ignoreCase = true)
|
||||||
|
}
|
||||||
|
return if (duplicate) ProfileNameIssue.DUPLICATE else null
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.krrishg.kwall.firewall
|
||||||
|
|
||||||
|
data class ShellResult(
|
||||||
|
val exitCode: Int,
|
||||||
|
val stdout: String,
|
||||||
|
val stderr: String,
|
||||||
|
) {
|
||||||
|
val success: Boolean get() = exitCode == 0
|
||||||
|
|
||||||
|
private val details: String by lazy { "$stdout\n$stderr".lowercase() }
|
||||||
|
|
||||||
|
val isUnknownCommand: Boolean get() = details.contains("unknown command")
|
||||||
|
|
||||||
|
val isUnsupportedPlatform: Boolean
|
||||||
|
get() = details.contains("not available on pre-t devices") ||
|
||||||
|
details.contains("unknown command")
|
||||||
|
|
||||||
|
val isPermissionDenied: Boolean
|
||||||
|
get() = details.contains("permission denied") ||
|
||||||
|
details.contains("securityexception")
|
||||||
|
|
||||||
|
val isSystemAppError: Boolean get() = details.contains("system app")
|
||||||
|
|
||||||
|
val isPackageNotFound: Boolean
|
||||||
|
get() = details.contains("namenotfound") ||
|
||||||
|
details.contains("name not found") ||
|
||||||
|
details.contains("not installed")
|
||||||
|
|
||||||
|
val isUidOwnerMapMissing: Boolean
|
||||||
|
get() = details.contains("suidownermap does not have entry for uid")
|
||||||
|
|
||||||
|
val isEffectivelySuccessful: Boolean
|
||||||
|
get() = (success && !isUnknownCommand && !isUnsupportedPlatform) || isUidOwnerMapMissing
|
||||||
|
|
||||||
|
val errorMessage: String
|
||||||
|
get() = stderr.trim().ifBlank { stdout.trim() }.ifBlank { "Command failed" }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShellExecutor {
|
||||||
|
suspend fun exec(command: String): ShellResult
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
package com.krrishg.kwall.firewall
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import rikka.shizuku.Shizuku
|
||||||
|
|
||||||
|
class ShizukuFirewallBackend(
|
||||||
|
context: Context,
|
||||||
|
private val shell: ShellExecutor,
|
||||||
|
private val binder: BinderFirewallClient,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
|
) : FirewallBackend {
|
||||||
|
|
||||||
|
private val appContext = context.applicationContext
|
||||||
|
private val _availability = MutableStateFlow<FirewallAvailability>(FirewallAvailability.Checking)
|
||||||
|
override val availability: StateFlow<FirewallAvailability> = _availability.asStateFlow()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var shellChainAvailable = false
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var shellRulesAvailable = false
|
||||||
|
|
||||||
|
init {
|
||||||
|
Shizuku.addBinderReceivedListener {
|
||||||
|
scope.launch { refreshAvailability() }
|
||||||
|
}
|
||||||
|
Shizuku.addBinderDeadListener {
|
||||||
|
_availability.value = FirewallAvailability.ShizukuNotRunning
|
||||||
|
}
|
||||||
|
Shizuku.addRequestPermissionResultListener { _, _ ->
|
||||||
|
scope.launch { refreshAvailability() }
|
||||||
|
}
|
||||||
|
scope.launch { refreshAvailability() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun refreshAvailability(): Unit = withContext(ioDispatcher) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
_availability.value = FirewallAvailability.UnsupportedAndroid
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
if (!isShizukuInstalled()) {
|
||||||
|
_availability.value = FirewallAvailability.ShizukuNotInstalled
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
if (!runCatching { Shizuku.pingBinder() }.getOrDefault(false)) {
|
||||||
|
_availability.value = FirewallAvailability.ShizukuNotRunning
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
val permission = runCatching { Shizuku.checkSelfPermission() }.getOrElse {
|
||||||
|
_availability.value = FirewallAvailability.ShizukuNotRunning
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
if (permission != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
_availability.value = FirewallAvailability.PermissionDenied
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
|
||||||
|
val chainProbe = runCatching { shell.exec(FirewallCommands.chainState()) }.getOrNull()
|
||||||
|
shellChainAvailable = chainProbe?.let {
|
||||||
|
it.success && !it.isUnknownCommand && !it.isUnsupportedPlatform
|
||||||
|
} == true
|
||||||
|
|
||||||
|
val rulesProbe = runCatching {
|
||||||
|
shell.exec(FirewallCommands.packageState(appContext.packageName))
|
||||||
|
}.getOrNull()
|
||||||
|
shellRulesAvailable = rulesProbe?.let {
|
||||||
|
it.success && !it.isUnknownCommand && !it.isUnsupportedPlatform
|
||||||
|
} == true
|
||||||
|
|
||||||
|
val binderRulesAvailable = runCatching { binder.probe() }.getOrDefault(false)
|
||||||
|
val chainTransport = when {
|
||||||
|
shellChainAvailable -> ChainTransport.SHELL
|
||||||
|
binderRulesAvailable -> ChainTransport.BINDER
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
val rulesTransport = when {
|
||||||
|
binderRulesAvailable -> RulesTransport.BINDER
|
||||||
|
shellRulesAvailable -> RulesTransport.SHELL
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
_availability.value = if (chainTransport != null && rulesTransport != null) {
|
||||||
|
FirewallAvailability.Ready(rulesTransport, chainTransport)
|
||||||
|
} else {
|
||||||
|
FirewallAvailability.CommandUnavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setChainEnabled(enabled: Boolean): Unit = withContext(ioDispatcher) {
|
||||||
|
val ready = readyOrThrow()
|
||||||
|
when (ready.chainTransport) {
|
||||||
|
ChainTransport.SHELL -> setChainViaShell(enabled, ready)
|
||||||
|
ChainTransport.BINDER -> setChainViaBinder(enabled, ready)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun block(app: BlockedApp): Unit = withContext(ioDispatcher) {
|
||||||
|
if (app.uid % PER_USER_RANGE < FIRST_APPLICATION_UID) {
|
||||||
|
throw FirewallException("System UID apps cannot be blocked")
|
||||||
|
}
|
||||||
|
val ready = readyOrThrow()
|
||||||
|
if (ready.rulesTransport == RulesTransport.BINDER) {
|
||||||
|
val binderError = runCatching {
|
||||||
|
binder.setUidFirewallRule(FirewallCommands.CHAIN, app.uid, FirewallCommands.RULE_DENY)
|
||||||
|
}.exceptionOrNull()
|
||||||
|
if (binderError == null) return@withContext
|
||||||
|
if (!shellRulesAvailable) throw FirewallException(binderError.message ?: "Binder rule failed", binderError)
|
||||||
|
_availability.value = ready.copy(rulesTransport = RulesTransport.SHELL)
|
||||||
|
}
|
||||||
|
blockViaShell(app.key.packageName, throwOnMissing = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun unblock(app: BlockedApp): Unit = withContext(ioDispatcher) {
|
||||||
|
val ready = readyOrThrow()
|
||||||
|
if (ready.rulesTransport == RulesTransport.BINDER) {
|
||||||
|
val binderError = runCatching {
|
||||||
|
binder.setUidFirewallRule(FirewallCommands.CHAIN, app.uid, FirewallCommands.RULE_DEFAULT)
|
||||||
|
}.exceptionOrNull()
|
||||||
|
if (binderError == null) return@withContext
|
||||||
|
if (!shellRulesAvailable) throw FirewallException(binderError.message ?: "Binder rule failed", binderError)
|
||||||
|
_availability.value = ready.copy(rulesTransport = RulesTransport.SHELL)
|
||||||
|
}
|
||||||
|
unblockViaShell(app.key.packageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun reapply(apps: Set<BlockedApp>): Unit = withContext(ioDispatcher) {
|
||||||
|
val ready = readyOrThrow()
|
||||||
|
when (ready.chainTransport) {
|
||||||
|
ChainTransport.SHELL -> setChainViaShell(true, ready)
|
||||||
|
ChainTransport.BINDER -> setChainViaBinder(true, ready)
|
||||||
|
}
|
||||||
|
if (ready.rulesTransport == RulesTransport.BINDER) {
|
||||||
|
val uids = apps.map { it.uid }.toIntArray()
|
||||||
|
val replaceError = runCatching {
|
||||||
|
binder.replaceUidFirewallChain(FirewallCommands.CHAIN, uids)
|
||||||
|
}.exceptionOrNull()
|
||||||
|
if (replaceError == null) return@withContext
|
||||||
|
if (!shellRulesAvailable) {
|
||||||
|
throw FirewallException(replaceError.message ?: "Binder rule failed", replaceError)
|
||||||
|
}
|
||||||
|
_availability.value = ready.copy(rulesTransport = RulesTransport.SHELL)
|
||||||
|
}
|
||||||
|
apps.forEach { blockViaShell(it.key.packageName, throwOnMissing = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun clearRules(): Unit = withContext(ioDispatcher) {
|
||||||
|
val ready = readyOrThrow()
|
||||||
|
if (ready.rulesTransport != RulesTransport.BINDER) return@withContext
|
||||||
|
val replaceError = runCatching {
|
||||||
|
binder.replaceUidFirewallChain(FirewallCommands.CHAIN, IntArray(0))
|
||||||
|
}.exceptionOrNull() ?: return@withContext
|
||||||
|
if (!shellRulesAvailable) {
|
||||||
|
throw FirewallException(replaceError.message ?: "Could not clear firewall rules", replaceError)
|
||||||
|
}
|
||||||
|
_availability.value = ready.copy(rulesTransport = RulesTransport.SHELL)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun unblockAll(apps: Set<BlockedApp>): Unit = withContext(ioDispatcher) {
|
||||||
|
if (apps.isEmpty()) return@withContext
|
||||||
|
val ready = readyOrThrow()
|
||||||
|
if (ready.rulesTransport == RulesTransport.BINDER) {
|
||||||
|
val uids = apps.map { it.uid }.toIntArray()
|
||||||
|
val rules = IntArray(uids.size).apply { fill(FirewallCommands.RULE_DEFAULT) }
|
||||||
|
val errors = runCatching {
|
||||||
|
binder.setUidFirewallRules(FirewallCommands.CHAIN, uids, rules)
|
||||||
|
}.getOrElse { error ->
|
||||||
|
if (!shellRulesAvailable) throw error
|
||||||
|
apps.forEach { unblockViaShell(it.key.packageName) }
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
val failure = errors.firstOrNull { !it.isNullOrBlank() }
|
||||||
|
if (failure != null) {
|
||||||
|
if (shellRulesAvailable) {
|
||||||
|
_availability.value = ready.copy(rulesTransport = RulesTransport.SHELL)
|
||||||
|
apps.forEach { unblockViaShell(it.key.packageName) }
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
throw FirewallException(failure)
|
||||||
|
}
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
apps.forEach { unblockViaShell(it.key.packageName) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun observe(apps: Set<BlockedApp>): FirewallObservation =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
val ready = _availability.value as? FirewallAvailability.Ready
|
||||||
|
?: return@withContext FirewallObservation()
|
||||||
|
val appStates = apps
|
||||||
|
.mapNotNull { app ->
|
||||||
|
observeDenied(app, ready)?.let { denied -> app.key to denied }
|
||||||
|
}
|
||||||
|
.toMap()
|
||||||
|
FirewallObservation(
|
||||||
|
chainEnabled = observeChainEnabled(ready),
|
||||||
|
appStates = appStates,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun requestPermission() {
|
||||||
|
if (runCatching { Shizuku.pingBinder() }.getOrDefault(false)) {
|
||||||
|
runCatching { Shizuku.requestPermission(REQUEST_CODE) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun observeChainEnabled(ready: FirewallAvailability.Ready): Boolean? =
|
||||||
|
when (ready.chainTransport) {
|
||||||
|
ChainTransport.SHELL -> observeChainViaShell() ?: observeChainViaBinder()
|
||||||
|
ChainTransport.BINDER -> observeChainViaBinder() ?: observeChainViaShell()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun observeChainViaShell(): Boolean? {
|
||||||
|
if (!shellChainAvailable) return null
|
||||||
|
val result = runCatching { shell.exec(FirewallCommands.chainState()) }.getOrNull()
|
||||||
|
?: return null
|
||||||
|
return FirewallObservationParser.chainEnabled(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun observeChainViaBinder(): Boolean? =
|
||||||
|
runCatching { binder.getFirewallChainEnabled(FirewallCommands.CHAIN) }.getOrNull()
|
||||||
|
|
||||||
|
private suspend fun observeDenied(
|
||||||
|
app: BlockedApp,
|
||||||
|
ready: FirewallAvailability.Ready,
|
||||||
|
): Boolean? = when (ready.rulesTransport) {
|
||||||
|
RulesTransport.BINDER -> observeDeniedViaBinder(app) ?: observeDeniedViaShell(app)
|
||||||
|
RulesTransport.SHELL -> observeDeniedViaShell(app) ?: observeDeniedViaBinder(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun observeDeniedViaShell(app: BlockedApp): Boolean? {
|
||||||
|
if (!shellRulesAvailable || app.key.userId != SHELL_USER_ID) return null
|
||||||
|
val result = runCatching {
|
||||||
|
shell.exec(FirewallCommands.packageState(app.key.packageName))
|
||||||
|
}.getOrNull() ?: return null
|
||||||
|
return FirewallObservationParser.packageDenied(result, app.key.packageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun observeDeniedViaBinder(app: BlockedApp): Boolean? {
|
||||||
|
val rule = runCatching {
|
||||||
|
binder.getUidFirewallRule(FirewallCommands.CHAIN, app.uid)
|
||||||
|
}.getOrNull() ?: return null
|
||||||
|
return rule == FirewallCommands.RULE_DENY
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun readyOrThrow(): FirewallAvailability.Ready {
|
||||||
|
val current = _availability.value
|
||||||
|
if (current is FirewallAvailability.Ready) return current
|
||||||
|
refreshAvailability()
|
||||||
|
return _availability.value as? FirewallAvailability.Ready
|
||||||
|
?: throw FirewallException("Shizuku firewall is unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun setChainViaShell(enabled: Boolean, ready: FirewallAvailability.Ready) {
|
||||||
|
val result = shell.exec(FirewallCommands.chainEnabled(enabled))
|
||||||
|
if (result.isEffectivelySuccessful) return
|
||||||
|
if (ready.chainTransport == ChainTransport.SHELL) {
|
||||||
|
setChainViaBinder(enabled, ready)
|
||||||
|
} else {
|
||||||
|
throw FirewallException(result.errorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun setChainViaBinder(enabled: Boolean, ready: FirewallAvailability.Ready) {
|
||||||
|
val error = runCatching {
|
||||||
|
binder.setFirewallChainEnabled(FirewallCommands.CHAIN, enabled)
|
||||||
|
}.exceptionOrNull()
|
||||||
|
if (error == null) return
|
||||||
|
if (shellChainAvailable && ready.chainTransport == ChainTransport.BINDER) {
|
||||||
|
val result = shell.exec(FirewallCommands.chainEnabled(enabled))
|
||||||
|
if (result.isEffectivelySuccessful) return
|
||||||
|
}
|
||||||
|
throw FirewallException(error.message ?: "Could not change firewall chain", error)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun blockViaShell(packageName: String, throwOnMissing: Boolean) {
|
||||||
|
val result = shell.exec(FirewallCommands.packageNetworking(packageName, false))
|
||||||
|
if (result.isEffectivelySuccessful) return
|
||||||
|
if (result.isPackageNotFound && !throwOnMissing) return
|
||||||
|
if (result.isSystemAppError) throw FirewallException("System apps cannot be blocked")
|
||||||
|
throw FirewallException(result.errorMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun unblockViaShell(packageName: String) {
|
||||||
|
val result = shell.exec(FirewallCommands.packageNetworking(packageName, true))
|
||||||
|
if (result.isEffectivelySuccessful) return
|
||||||
|
if (result.isPackageNotFound || result.isSystemAppError) return
|
||||||
|
throw FirewallException(result.errorMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isShizukuInstalled(): Boolean =
|
||||||
|
runCatching {
|
||||||
|
appContext.packageManager.getPackageInfo(SHIZUKU_PACKAGE, 0)
|
||||||
|
}.isSuccess
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val SHIZUKU_PACKAGE = "moe.shizuku.privileged.api"
|
||||||
|
const val REQUEST_CODE = 1001
|
||||||
|
const val FIRST_APPLICATION_UID = 10_000
|
||||||
|
const val PER_USER_RANGE = 100_000
|
||||||
|
const val SHELL_USER_ID = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.krrishg.kwall.network
|
||||||
|
|
||||||
|
sealed interface ConnectionSnapshot {
|
||||||
|
data class Available(val sockets: List<SocketEntry>) : ConnectionSnapshot
|
||||||
|
|
||||||
|
data class Unavailable(val reason: String) : ConnectionSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConnectionInspector {
|
||||||
|
suspend fun snapshot(): ConnectionSnapshot
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.krrishg.kwall.network
|
||||||
|
|
||||||
|
import android.app.AppOpsManager
|
||||||
|
import android.app.usage.NetworkStats
|
||||||
|
import android.app.usage.NetworkStatsManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Process
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
class NetworkStatsUsageSource(
|
||||||
|
context: Context,
|
||||||
|
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
|
private val clock: () -> Long = System::currentTimeMillis,
|
||||||
|
) : NetworkUsageSource {
|
||||||
|
|
||||||
|
private val appContext = context.applicationContext
|
||||||
|
private val appOps: AppOpsManager? = appContext.getSystemService(AppOpsManager::class.java)
|
||||||
|
private val statsManager: NetworkStatsManager? =
|
||||||
|
appContext.getSystemService(NetworkStatsManager::class.java)
|
||||||
|
|
||||||
|
override fun hasUsageAccess(): Boolean {
|
||||||
|
val ops = appOps ?: return false
|
||||||
|
val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
ops.unsafeCheckOpNoThrow(
|
||||||
|
AppOpsManager.OPSTR_GET_USAGE_STATS,
|
||||||
|
Process.myUid(),
|
||||||
|
appContext.packageName,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
ops.checkOpNoThrow(
|
||||||
|
AppOpsManager.OPSTR_GET_USAGE_STATS,
|
||||||
|
Process.myUid(),
|
||||||
|
appContext.packageName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return mode == AppOpsManager.MODE_ALLOWED
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun usageFor(uid: Int, range: UsageRange): UsageResult =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
if (!hasUsageAccess()) return@withContext UsageResult.PermissionRequired
|
||||||
|
val end = clock()
|
||||||
|
val start = UsageWindow.start(range, end)
|
||||||
|
val outcomes = listOf(
|
||||||
|
runCatching { query(ConnectivityManager.TYPE_WIFI, uid, start, end) },
|
||||||
|
runCatching { query(ConnectivityManager.TYPE_MOBILE, uid, start, end) },
|
||||||
|
)
|
||||||
|
val usages = outcomes.mapNotNull { it.getOrNull() }
|
||||||
|
if (usages.isEmpty()) {
|
||||||
|
val error = outcomes.firstNotNullOfOrNull { it.exceptionOrNull() }
|
||||||
|
return@withContext UsageResult.Unavailable(
|
||||||
|
error?.message ?: "Data usage is unavailable",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
UsageResult.Available(usages.fold(DataUsage.Zero) { total, usage -> total + usage })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun query(type: Int, uid: Int, start: Long, end: Long): DataUsage? {
|
||||||
|
val manager = statsManager ?: return null
|
||||||
|
return manager.queryDetailsForUid(type, null, start, end, uid).use { stats ->
|
||||||
|
var received = 0L
|
||||||
|
var sent = 0L
|
||||||
|
val bucket = NetworkStats.Bucket()
|
||||||
|
while (stats.hasNextBucket()) {
|
||||||
|
stats.getNextBucket(bucket)
|
||||||
|
received += bucket.rxBytes
|
||||||
|
sent += bucket.txBytes
|
||||||
|
}
|
||||||
|
DataUsage(receivedBytes = received, sentBytes = sent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.krrishg.kwall.network
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
|
data class DataUsage(
|
||||||
|
val receivedBytes: Long = 0,
|
||||||
|
val sentBytes: Long = 0,
|
||||||
|
) {
|
||||||
|
val totalBytes: Long get() = receivedBytes + sentBytes
|
||||||
|
|
||||||
|
operator fun plus(other: DataUsage): DataUsage = DataUsage(
|
||||||
|
receivedBytes = receivedBytes + other.receivedBytes,
|
||||||
|
sentBytes = sentBytes + other.sentBytes,
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val Zero = DataUsage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class UsageRange {
|
||||||
|
TODAY,
|
||||||
|
WEEK,
|
||||||
|
MONTH,
|
||||||
|
}
|
||||||
|
|
||||||
|
object UsageWindow {
|
||||||
|
fun start(range: UsageRange, nowMillis: Long, zone: ZoneId = ZoneId.systemDefault()): Long {
|
||||||
|
val today = Instant.ofEpochMilli(nowMillis).atZone(zone).toLocalDate()
|
||||||
|
val start = when (range) {
|
||||||
|
UsageRange.TODAY -> today
|
||||||
|
UsageRange.WEEK -> today.minusDays(6)
|
||||||
|
UsageRange.MONTH -> today.minusDays(29)
|
||||||
|
}
|
||||||
|
return start.atStartOfDay(zone).toInstant().toEpochMilli()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface UsageResult {
|
||||||
|
data class Available(val usage: DataUsage) : UsageResult
|
||||||
|
|
||||||
|
data object PermissionRequired : UsageResult
|
||||||
|
|
||||||
|
data class Unavailable(val reason: String) : UsageResult
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NetworkUsageSource {
|
||||||
|
fun hasUsageAccess(): Boolean
|
||||||
|
|
||||||
|
suspend fun usageFor(uid: Int, range: UsageRange): UsageResult
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.krrishg.kwall.network
|
||||||
|
|
||||||
|
import com.krrishg.kwall.firewall.ShellExecutor
|
||||||
|
import com.krrishg.kwall.firewall.ShellResult
|
||||||
|
|
||||||
|
class ProcNetConnectionInspector(
|
||||||
|
private val shell: ShellExecutor,
|
||||||
|
) : ConnectionInspector {
|
||||||
|
|
||||||
|
override suspend fun snapshot(): ConnectionSnapshot {
|
||||||
|
val result = shell.exec(COMMAND)
|
||||||
|
val sections = parseSections(result.stdout)
|
||||||
|
if (sections.isEmpty()) {
|
||||||
|
return ConnectionSnapshot.Unavailable(unavailableReason(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
val sockets = mutableListOf<SocketEntry>()
|
||||||
|
var readableSections = 0
|
||||||
|
for ((name, body) in sections) {
|
||||||
|
if (body.containsAnyDenial()) continue
|
||||||
|
val protocol = PROTOCOLS[name] ?: continue
|
||||||
|
readableSections++
|
||||||
|
sockets += ProcNetParser.parse(protocol, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (readableSections == 0) {
|
||||||
|
return ConnectionSnapshot.Unavailable(unavailableReason(result))
|
||||||
|
}
|
||||||
|
return ConnectionSnapshot.Available(sockets)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseSections(output: String): Map<String, String> {
|
||||||
|
val sections = linkedMapOf<String, StringBuilder>()
|
||||||
|
var current: StringBuilder? = null
|
||||||
|
for (line in output.lineSequence()) {
|
||||||
|
val marker = MARKER.matchEntire(line.trim())
|
||||||
|
if (marker != null) {
|
||||||
|
current = StringBuilder()
|
||||||
|
sections[marker.groupValues[1]] = current
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
current?.appendLine(line)
|
||||||
|
}
|
||||||
|
return sections.mapValues { (_, body) -> body.toString() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unavailableReason(result: ShellResult): String {
|
||||||
|
val details = "${result.stderr}\n${result.stdout}".trim()
|
||||||
|
return when {
|
||||||
|
details.isBlank() -> "Socket information is unavailable"
|
||||||
|
details.containsAnyDenial() -> "This device does not allow reading socket information"
|
||||||
|
else -> details.lineSequence().firstOrNull { it.isNotBlank() }
|
||||||
|
?: "Socket information is unavailable"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.containsAnyDenial(): Boolean =
|
||||||
|
contains("permission denied", ignoreCase = true) ||
|
||||||
|
contains("no such file", ignoreCase = true) ||
|
||||||
|
contains("not permitted", ignoreCase = true) ||
|
||||||
|
contains("operation not permitted", ignoreCase = true)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
private val MARKER = Regex("===\\s*(\\w+)\\s*===")
|
||||||
|
private val PROTOCOLS = mapOf(
|
||||||
|
"tcp" to SocketProtocol.TCP,
|
||||||
|
"tcp6" to SocketProtocol.TCP6,
|
||||||
|
"udp" to SocketProtocol.UDP,
|
||||||
|
"udp6" to SocketProtocol.UDP6,
|
||||||
|
)
|
||||||
|
private const val COMMAND =
|
||||||
|
"for f in tcp tcp6 udp udp6; do echo \"=== \$f ===\"; cat \"/proc/net/\$f\" 2>&1; done"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package com.krrishg.kwall.network
|
||||||
|
|
||||||
|
import java.net.InetAddress
|
||||||
|
|
||||||
|
object ProcNetParser {
|
||||||
|
|
||||||
|
private val WHITESPACE = Regex("\\s+")
|
||||||
|
private const val WORD_LENGTH = 8
|
||||||
|
private const val IPV4_LENGTH = 8
|
||||||
|
private const val IPV6_LENGTH = 32
|
||||||
|
private const val UID_INDEX = 7
|
||||||
|
private const val STATE_INDEX = 3
|
||||||
|
private const val LOCAL_INDEX = 1
|
||||||
|
private const val REMOTE_INDEX = 2
|
||||||
|
private const val MIN_FIELDS = 10
|
||||||
|
|
||||||
|
fun parse(protocol: SocketProtocol, output: String): List<SocketEntry> =
|
||||||
|
output.lineSequence()
|
||||||
|
.mapNotNull { line -> runCatching { parseLine(protocol, line) }.getOrNull() }
|
||||||
|
.toList()
|
||||||
|
|
||||||
|
private fun parseLine(protocol: SocketProtocol, line: String): SocketEntry? {
|
||||||
|
val trimmed = line.trim()
|
||||||
|
if (trimmed.isEmpty() || trimmed.startsWith("sl")) return null
|
||||||
|
val fields = trimmed.split(WHITESPACE)
|
||||||
|
if (fields.size < MIN_FIELDS || !fields[0].endsWith(':')) return null
|
||||||
|
val local = parseEndpoint(fields[LOCAL_INDEX]) ?: return null
|
||||||
|
val remote = parseEndpoint(fields[REMOTE_INDEX]) ?: return null
|
||||||
|
val uid = fields[UID_INDEX].toIntOrNull() ?: return null
|
||||||
|
val state = if (protocol.isTcp) TcpState.fromHex(fields[STATE_INDEX]) else null
|
||||||
|
return SocketEntry(
|
||||||
|
uid = uid,
|
||||||
|
protocol = protocol,
|
||||||
|
localAddress = local.address,
|
||||||
|
localPort = local.port,
|
||||||
|
remoteAddress = remote.address,
|
||||||
|
remotePort = remote.port,
|
||||||
|
state = state,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseEndpoint(field: String): Endpoint? {
|
||||||
|
val separator = field.indexOf(':')
|
||||||
|
if (separator <= 0 || separator == field.lastIndex) return null
|
||||||
|
val hexAddress = field.substring(0, separator)
|
||||||
|
if (hexAddress.length != IPV4_LENGTH && hexAddress.length != IPV6_LENGTH) return null
|
||||||
|
val address = if (hexAddress.all { it == '0' }) null else decodeAddress(hexAddress)
|
||||||
|
val port = field.substring(separator + 1).toIntOrNull(16) ?: return null
|
||||||
|
return Endpoint(address, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeAddress(hex: String): InetAddress? {
|
||||||
|
val bytes = hex.chunked(WORD_LENGTH)
|
||||||
|
.flatMap { word ->
|
||||||
|
word.chunked(2)
|
||||||
|
.map { pair -> pair.toInt(16).toByte() }
|
||||||
|
.reversed()
|
||||||
|
}
|
||||||
|
.toByteArray()
|
||||||
|
return InetAddress.getByAddress(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class Endpoint(
|
||||||
|
val address: InetAddress?,
|
||||||
|
val port: Int,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.krrishg.kwall.network
|
||||||
|
|
||||||
|
import java.net.InetAddress
|
||||||
|
|
||||||
|
enum class SocketProtocol {
|
||||||
|
TCP,
|
||||||
|
TCP6,
|
||||||
|
UDP,
|
||||||
|
UDP6,
|
||||||
|
;
|
||||||
|
|
||||||
|
val isTcp: Boolean get() = this == TCP || this == TCP6
|
||||||
|
|
||||||
|
val label: String get() = if (isTcp) "TCP" else "UDP"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class TcpState(val code: String) {
|
||||||
|
ESTABLISHED("01"),
|
||||||
|
SYN_SENT("02"),
|
||||||
|
SYN_RECV("03"),
|
||||||
|
FIN_WAIT1("04"),
|
||||||
|
FIN_WAIT2("05"),
|
||||||
|
TIME_WAIT("06"),
|
||||||
|
CLOSE("07"),
|
||||||
|
CLOSE_WAIT("08"),
|
||||||
|
LAST_ACK("09"),
|
||||||
|
LISTEN("0A"),
|
||||||
|
CLOSING("0B"),
|
||||||
|
NEW_SYN_RECV("0C"),
|
||||||
|
UNKNOWN(""),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromHex(hex: String): TcpState =
|
||||||
|
entries.firstOrNull { it.code == hex.uppercase() } ?: UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class SocketEntry(
|
||||||
|
val uid: Int,
|
||||||
|
val protocol: SocketProtocol,
|
||||||
|
val localAddress: InetAddress?,
|
||||||
|
val localPort: Int,
|
||||||
|
val remoteAddress: InetAddress?,
|
||||||
|
val remotePort: Int,
|
||||||
|
val state: TcpState?,
|
||||||
|
) {
|
||||||
|
val isListening: Boolean get() = protocol.isTcp && state == TcpState.LISTEN
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package com.krrishg.kwall.notifications
|
||||||
|
|
||||||
|
import com.krrishg.kwall.firewall.FirewallAvailability
|
||||||
|
|
||||||
|
enum class FirewallNotificationTitle {
|
||||||
|
FIREWALL,
|
||||||
|
ON,
|
||||||
|
OFF,
|
||||||
|
PAUSED,
|
||||||
|
ATTENTION,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class FirewallNotificationAction {
|
||||||
|
PAUSE,
|
||||||
|
RESUME,
|
||||||
|
OPEN,
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface FirewallNotificationText {
|
||||||
|
data object NoBlockedApps : FirewallNotificationText
|
||||||
|
data object NoSelectedApps : FirewallNotificationText
|
||||||
|
data class BlockedApps(val count: Int) : FirewallNotificationText
|
||||||
|
data class SelectedApps(val count: Int) : FirewallNotificationText
|
||||||
|
data class Availability(val availability: FirewallAvailability) : FirewallNotificationText
|
||||||
|
data class Error(val message: String) : FirewallNotificationText
|
||||||
|
}
|
||||||
|
|
||||||
|
data class FirewallNotificationContent(
|
||||||
|
val title: FirewallNotificationTitle,
|
||||||
|
val text: FirewallNotificationText,
|
||||||
|
val action: FirewallNotificationAction,
|
||||||
|
val directToggle: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun firewallNotificationContent(
|
||||||
|
enabled: Boolean,
|
||||||
|
appCount: Int,
|
||||||
|
availability: FirewallAvailability,
|
||||||
|
error: String?,
|
||||||
|
appLockEnabled: Boolean,
|
||||||
|
): FirewallNotificationContent {
|
||||||
|
if (error != null) {
|
||||||
|
return FirewallNotificationContent(
|
||||||
|
title = FirewallNotificationTitle.ATTENTION,
|
||||||
|
text = FirewallNotificationText.Error(error),
|
||||||
|
action = FirewallNotificationAction.OPEN,
|
||||||
|
directToggle = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (availability == FirewallAvailability.Checking) {
|
||||||
|
return FirewallNotificationContent(
|
||||||
|
title = FirewallNotificationTitle.FIREWALL,
|
||||||
|
text = FirewallNotificationText.Availability(availability),
|
||||||
|
action = FirewallNotificationAction.OPEN,
|
||||||
|
directToggle = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (availability !is FirewallAvailability.Ready) {
|
||||||
|
return FirewallNotificationContent(
|
||||||
|
title = if (enabled) {
|
||||||
|
FirewallNotificationTitle.PAUSED
|
||||||
|
} else {
|
||||||
|
FirewallNotificationTitle.OFF
|
||||||
|
},
|
||||||
|
text = FirewallNotificationText.Availability(availability),
|
||||||
|
action = FirewallNotificationAction.OPEN,
|
||||||
|
directToggle = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (enabled) {
|
||||||
|
return FirewallNotificationContent(
|
||||||
|
title = FirewallNotificationTitle.ON,
|
||||||
|
text = if (appCount == 0) {
|
||||||
|
FirewallNotificationText.NoBlockedApps
|
||||||
|
} else {
|
||||||
|
FirewallNotificationText.BlockedApps(appCount)
|
||||||
|
},
|
||||||
|
action = FirewallNotificationAction.PAUSE,
|
||||||
|
directToggle = !appLockEnabled,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return FirewallNotificationContent(
|
||||||
|
title = FirewallNotificationTitle.OFF,
|
||||||
|
text = if (appCount == 0) {
|
||||||
|
FirewallNotificationText.NoSelectedApps
|
||||||
|
} else {
|
||||||
|
FirewallNotificationText.SelectedApps(appCount)
|
||||||
|
},
|
||||||
|
action = FirewallNotificationAction.RESUME,
|
||||||
|
directToggle = !appLockEnabled,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
package com.krrishg.kwall.notifications
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.krrishg.kwall.MainActivity
|
||||||
|
import com.krrishg.kwall.R
|
||||||
|
import com.krrishg.kwall.firewall.FirewallAvailability
|
||||||
|
import com.krrishg.kwall.receivers.FirewallNotificationReceiver
|
||||||
|
|
||||||
|
object FirewallNotifications {
|
||||||
|
|
||||||
|
const val CHANNEL_ID = "firewall_status"
|
||||||
|
const val AUTO_BLOCK_CHANNEL_ID = "auto_block"
|
||||||
|
const val NOTIFICATION_ID = 1001
|
||||||
|
const val AUTO_BLOCK_NOTIFICATION_ID = 1002
|
||||||
|
const val ACTION_TOGGLE = "com.krrishg.kwall.action.TOGGLE_FIREWALL"
|
||||||
|
const val EXTRA_TOGGLE_TARGET = "com.krrishg.kwall.extra.TOGGLE_TARGET"
|
||||||
|
|
||||||
|
private const val REQUEST_CONTENT = 0
|
||||||
|
private const val REQUEST_OPEN = 1
|
||||||
|
private const val REQUEST_TOGGLE_APP = 2
|
||||||
|
private const val REQUEST_TOGGLE_BROADCAST = 3
|
||||||
|
private const val REQUEST_AUTO_BLOCK_CONTENT = 4
|
||||||
|
private const val PENDING_FLAGS = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
private const val ACTIVITY_FLAGS =
|
||||||
|
Intent.FLAG_ACTIVITY_NEW_TASK or
|
||||||
|
Intent.FLAG_ACTIVITY_CLEAR_TOP or
|
||||||
|
Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||||
|
|
||||||
|
fun createChannel(context: Context) {
|
||||||
|
val manager = context.getSystemService(NotificationManager::class.java) ?: return
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
context.getString(R.string.firewall_channel_name),
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
).apply {
|
||||||
|
setShowBadge(false)
|
||||||
|
setSound(null, null)
|
||||||
|
enableVibration(false)
|
||||||
|
}
|
||||||
|
manager.createNotificationChannel(channel)
|
||||||
|
val autoBlockChannel = NotificationChannel(
|
||||||
|
AUTO_BLOCK_CHANNEL_ID,
|
||||||
|
context.getString(R.string.firewall_auto_block_channel_name),
|
||||||
|
NotificationManager.IMPORTANCE_DEFAULT,
|
||||||
|
).apply {
|
||||||
|
setShowBadge(true)
|
||||||
|
}
|
||||||
|
manager.createNotificationChannel(autoBlockChannel)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
fun notifyAutoBlocked(context: Context, label: String) {
|
||||||
|
if (!canPostNotifications(context)) return
|
||||||
|
val text = context.getString(R.string.firewall_auto_block_text, label)
|
||||||
|
val notification = NotificationCompat.Builder(context, AUTO_BLOCK_CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_launcher_monochrome)
|
||||||
|
.setContentTitle(context.getString(R.string.firewall_auto_block_title))
|
||||||
|
.setContentText(text)
|
||||||
|
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
|
||||||
|
.setContentIntent(autoBlockContentIntent(context))
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||||
|
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
NotificationManagerCompat.from(context)
|
||||||
|
.notify(AUTO_BLOCK_NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
fun update(context: Context, content: FirewallNotificationContent) {
|
||||||
|
if (!canPostNotifications(context)) return
|
||||||
|
val text = content.text.resolve(context)
|
||||||
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_launcher_monochrome)
|
||||||
|
.setContentTitle(context.getString(content.title.titleRes()))
|
||||||
|
.setContentText(text)
|
||||||
|
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
|
||||||
|
.setContentIntent(contentIntent(context))
|
||||||
|
.addAction(
|
||||||
|
content.action.iconRes(),
|
||||||
|
context.getString(content.action.labelRes()),
|
||||||
|
actionIntent(context, content),
|
||||||
|
)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setAutoCancel(false)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setShowWhen(false)
|
||||||
|
.setSilent(true)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||||
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun actionIntent(
|
||||||
|
context: Context,
|
||||||
|
content: FirewallNotificationContent,
|
||||||
|
): PendingIntent = when {
|
||||||
|
content.action == FirewallNotificationAction.OPEN -> openAppPendingIntent(context)
|
||||||
|
content.directToggle -> toggleBroadcastPendingIntent(context, content.action.toggleTarget())
|
||||||
|
else -> toggleAppPendingIntent(context, content.action.toggleTarget())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun contentIntent(context: Context): PendingIntent {
|
||||||
|
val intent = Intent(context, MainActivity::class.java).apply {
|
||||||
|
flags = ACTIVITY_FLAGS
|
||||||
|
}
|
||||||
|
return PendingIntent.getActivity(context, REQUEST_CONTENT, intent, PENDING_FLAGS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openAppPendingIntent(context: Context): PendingIntent {
|
||||||
|
val intent = Intent(context, MainActivity::class.java).apply {
|
||||||
|
flags = ACTIVITY_FLAGS
|
||||||
|
}
|
||||||
|
return PendingIntent.getActivity(context, REQUEST_OPEN, intent, PENDING_FLAGS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun autoBlockContentIntent(context: Context): PendingIntent {
|
||||||
|
val intent = Intent(context, MainActivity::class.java).apply {
|
||||||
|
flags = ACTIVITY_FLAGS
|
||||||
|
}
|
||||||
|
return PendingIntent.getActivity(context, REQUEST_AUTO_BLOCK_CONTENT, intent, PENDING_FLAGS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toggleAppPendingIntent(context: Context, enable: Boolean): PendingIntent {
|
||||||
|
val intent = Intent(context, MainActivity::class.java).apply {
|
||||||
|
flags = ACTIVITY_FLAGS
|
||||||
|
putExtra(EXTRA_TOGGLE_TARGET, enable)
|
||||||
|
}
|
||||||
|
return PendingIntent.getActivity(context, REQUEST_TOGGLE_APP, intent, PENDING_FLAGS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toggleBroadcastPendingIntent(context: Context, enable: Boolean): PendingIntent {
|
||||||
|
val intent = Intent(context, FirewallNotificationReceiver::class.java).apply {
|
||||||
|
action = ACTION_TOGGLE
|
||||||
|
putExtra(EXTRA_TOGGLE_TARGET, enable)
|
||||||
|
}
|
||||||
|
return PendingIntent.getBroadcast(context, REQUEST_TOGGLE_BROADCAST, intent, PENDING_FLAGS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun FirewallNotificationTitle.titleRes(): Int = when (this) {
|
||||||
|
FirewallNotificationTitle.FIREWALL -> R.string.firewall_title
|
||||||
|
FirewallNotificationTitle.ON -> R.string.firewall_notification_on_title
|
||||||
|
FirewallNotificationTitle.OFF -> R.string.firewall_state_off
|
||||||
|
FirewallNotificationTitle.PAUSED -> R.string.firewall_paused_title
|
||||||
|
FirewallNotificationTitle.ATTENTION -> R.string.firewall_notification_attention_title
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun FirewallNotificationText.resolve(context: Context): String = when (this) {
|
||||||
|
FirewallNotificationText.NoBlockedApps -> context.getString(R.string.firewall_state_no_apps)
|
||||||
|
FirewallNotificationText.NoSelectedApps ->
|
||||||
|
context.getString(R.string.firewall_notification_no_selected_apps)
|
||||||
|
is FirewallNotificationText.BlockedApps -> context.resources.getQuantityString(
|
||||||
|
R.plurals.firewall_blocked_apps,
|
||||||
|
count,
|
||||||
|
count,
|
||||||
|
)
|
||||||
|
is FirewallNotificationText.SelectedApps -> context.resources.getQuantityString(
|
||||||
|
R.plurals.firewall_notification_selected_apps,
|
||||||
|
count,
|
||||||
|
count,
|
||||||
|
)
|
||||||
|
is FirewallNotificationText.Availability -> availability.resolve(context)
|
||||||
|
is FirewallNotificationText.Error ->
|
||||||
|
context.getString(R.string.firewall_notification_error, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun FirewallAvailability.resolve(context: Context): String = when (this) {
|
||||||
|
FirewallAvailability.Checking -> context.getString(R.string.firewall_state_checking)
|
||||||
|
FirewallAvailability.UnsupportedAndroid -> context.getString(R.string.firewall_state_android)
|
||||||
|
FirewallAvailability.ShizukuNotInstalled ->
|
||||||
|
context.getString(R.string.firewall_state_shizuku_missing)
|
||||||
|
FirewallAvailability.ShizukuNotRunning ->
|
||||||
|
context.getString(R.string.firewall_state_shizuku_stopped)
|
||||||
|
FirewallAvailability.PermissionDenied -> context.getString(R.string.firewall_state_permission)
|
||||||
|
FirewallAvailability.CommandUnavailable ->
|
||||||
|
context.getString(R.string.firewall_state_unsupported)
|
||||||
|
is FirewallAvailability.Ready -> context.getString(R.string.firewall_state_checking)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun FirewallNotificationAction.labelRes(): Int = when (this) {
|
||||||
|
FirewallNotificationAction.PAUSE -> R.string.firewall_notification_pause
|
||||||
|
FirewallNotificationAction.RESUME -> R.string.firewall_notification_resume
|
||||||
|
FirewallNotificationAction.OPEN -> R.string.firewall_notification_open
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun FirewallNotificationAction.iconRes(): Int = when (this) {
|
||||||
|
FirewallNotificationAction.PAUSE -> R.drawable.ic_notification_pause
|
||||||
|
FirewallNotificationAction.RESUME -> R.drawable.ic_notification_play
|
||||||
|
FirewallNotificationAction.OPEN -> R.drawable.ic_notification_open
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun FirewallNotificationAction.toggleTarget(): Boolean =
|
||||||
|
this == FirewallNotificationAction.RESUME
|
||||||
|
|
||||||
|
private fun canPostNotifications(context: Context): Boolean =
|
||||||
|
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||||
|
ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.POST_NOTIFICATIONS,
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package com.krrishg.kwall.notifications
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.krrishg.kwall.firewall.FirewallController
|
||||||
|
import com.krrishg.kwall.settings.AppSettingsStore
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class FirewallStatusNotifier(
|
||||||
|
context: Context,
|
||||||
|
settingsStore: AppSettingsStore,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val appContext = context.applicationContext
|
||||||
|
private val appLockEnabled = settingsStore.appLockEnabled
|
||||||
|
.stateIn(scope, SharingStarted.Eagerly, true)
|
||||||
|
|
||||||
|
private var controller: FirewallController? = null
|
||||||
|
private var collectJob: Job? = null
|
||||||
|
|
||||||
|
fun start(controller: FirewallController) {
|
||||||
|
this.controller = controller
|
||||||
|
if (collectJob?.isActive == true) return
|
||||||
|
collectJob = scope.launch {
|
||||||
|
combine(
|
||||||
|
controller.state,
|
||||||
|
controller.availability,
|
||||||
|
controller.error,
|
||||||
|
appLockEnabled,
|
||||||
|
) { state, availability, error, appLockEnabled ->
|
||||||
|
firewallNotificationContent(
|
||||||
|
enabled = state.enabled,
|
||||||
|
appCount = state.blockedApps.size,
|
||||||
|
availability = availability,
|
||||||
|
error = error,
|
||||||
|
appLockEnabled = appLockEnabled,
|
||||||
|
)
|
||||||
|
}.collect { content ->
|
||||||
|
FirewallNotifications.update(appContext, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
val controller = controller ?: return
|
||||||
|
scope.launch {
|
||||||
|
val state = controller.state.value
|
||||||
|
FirewallNotifications.update(
|
||||||
|
appContext,
|
||||||
|
firewallNotificationContent(
|
||||||
|
enabled = state.enabled,
|
||||||
|
appCount = state.blockedApps.size,
|
||||||
|
availability = controller.availability.value,
|
||||||
|
error = controller.error.value,
|
||||||
|
appLockEnabled = appLockEnabled.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.krrishg.kwall.receivers
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.util.Log
|
||||||
|
import com.krrishg.kwall.KwallApplication
|
||||||
|
import com.krrishg.kwall.firewall.FirewallAvailability
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class BootReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent?) {
|
||||||
|
if (intent?.action != Intent.ACTION_BOOT_COMPLETED) return
|
||||||
|
val application = context.applicationContext as? KwallApplication ?: return
|
||||||
|
val pendingResult = goAsync()
|
||||||
|
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
try {
|
||||||
|
val container = application.container
|
||||||
|
container.firewallManager.refreshAvailability()
|
||||||
|
container.profileScheduler.onSystemEvent()
|
||||||
|
val state = container.firewallStateStore.state.first()
|
||||||
|
val availability = container.firewallManager.availability.value
|
||||||
|
if (state.enabled && availability is FirewallAvailability.Ready) {
|
||||||
|
container.firewallManager.restore()
|
||||||
|
}
|
||||||
|
container.firewallStatusNotifier.refresh()
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
Log.w(TAG, "Could not restore the firewall after boot", error)
|
||||||
|
} finally {
|
||||||
|
pendingResult.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "BootReceiver"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.krrishg.kwall.receivers
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.util.Log
|
||||||
|
import com.krrishg.kwall.KwallApplication
|
||||||
|
import com.krrishg.kwall.notifications.FirewallNotifications
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class FirewallNotificationReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent?) {
|
||||||
|
if (intent?.action != FirewallNotifications.ACTION_TOGGLE) return
|
||||||
|
val enable = intent.getBooleanExtra(FirewallNotifications.EXTRA_TOGGLE_TARGET, false)
|
||||||
|
val application = context.applicationContext as? KwallApplication ?: return
|
||||||
|
val pendingResult = goAsync()
|
||||||
|
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
try {
|
||||||
|
val container = application.container
|
||||||
|
if (container.settingsStore.appLockEnabled.first()) {
|
||||||
|
container.firewallStatusNotifier.refresh()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
container.firewallManager.setEnabled(enable)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
Log.w(TAG, "Could not toggle the firewall from the notification", error)
|
||||||
|
} finally {
|
||||||
|
pendingResult.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "FirewallNotification"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.krrishg.kwall.receivers
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.util.Log
|
||||||
|
import com.krrishg.kwall.KwallApplication
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class PackageAddedReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent?) {
|
||||||
|
if (intent?.action != Intent.ACTION_PACKAGE_ADDED) return
|
||||||
|
if (intent.getBooleanExtra(Intent.EXTRA_REPLACING, false)) return
|
||||||
|
val packageName = intent.data?.schemeSpecificPart ?: return
|
||||||
|
val uid = intent.getIntExtra(Intent.EXTRA_UID, -1)
|
||||||
|
if (uid < 0) return
|
||||||
|
val application = context.applicationContext as? KwallApplication ?: return
|
||||||
|
val pendingResult = goAsync()
|
||||||
|
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
try {
|
||||||
|
application.container.autoBlockNewlyInstalled(
|
||||||
|
packageName = packageName,
|
||||||
|
userId = uid / PER_USER_RANGE,
|
||||||
|
)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
Log.w(TAG, "Could not auto-block $packageName", error)
|
||||||
|
} finally {
|
||||||
|
pendingResult.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "PackageAddedReceiver"
|
||||||
|
const val PER_USER_RANGE = 100_000
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.krrishg.kwall.receivers
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.util.Log
|
||||||
|
import com.krrishg.kwall.KwallApplication
|
||||||
|
import com.krrishg.kwall.scheduler.AlarmManagerScheduleAlarms
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class ScheduleAlarmReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent?) {
|
||||||
|
if (intent?.action != AlarmManagerScheduleAlarms.ACTION_SCHEDULE_ALARM) return
|
||||||
|
val application = context.applicationContext as? KwallApplication ?: return
|
||||||
|
val pendingResult = goAsync()
|
||||||
|
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
try {
|
||||||
|
application.container.profileScheduler.onAlarm()
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
Log.w(TAG, "Could not apply the scheduled profile", error)
|
||||||
|
} finally {
|
||||||
|
pendingResult.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "ScheduleAlarm"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.krrishg.kwall.receivers
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.util.Log
|
||||||
|
import com.krrishg.kwall.KwallApplication
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class ScheduleMaintenanceReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent?) {
|
||||||
|
val action = intent?.action ?: return
|
||||||
|
if (action !in HANDLED_ACTIONS) return
|
||||||
|
val application = context.applicationContext as? KwallApplication ?: return
|
||||||
|
val pendingResult = goAsync()
|
||||||
|
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
try {
|
||||||
|
application.container.profileScheduler.onSystemEvent()
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
Log.w(TAG, "Could not reschedule profile activations", error)
|
||||||
|
} finally {
|
||||||
|
pendingResult.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "ScheduleMaintenance"
|
||||||
|
const val ACTION_EXACT_ALARM_PERMISSION_CHANGED =
|
||||||
|
"android.app.action.SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED"
|
||||||
|
|
||||||
|
val HANDLED_ACTIONS = setOf(
|
||||||
|
Intent.ACTION_TIME_CHANGED,
|
||||||
|
Intent.ACTION_TIMEZONE_CHANGED,
|
||||||
|
Intent.ACTION_DATE_CHANGED,
|
||||||
|
Intent.ACTION_MY_PACKAGE_REPLACED,
|
||||||
|
ACTION_EXACT_ALARM_PERMISSION_CHANGED,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package com.krrishg.kwall.scheduler
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.preferences.core.MutablePreferences
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.emptyPreferences
|
||||||
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import java.io.IOException
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
private val Context.scheduleDataStore by preferencesDataStore(name = "profile_schedules")
|
||||||
|
|
||||||
|
class DataStoreScheduleStore(context: Context) : ScheduleStore {
|
||||||
|
|
||||||
|
private val dataStore = context.applicationContext.scheduleDataStore
|
||||||
|
|
||||||
|
private val preferences = dataStore.data
|
||||||
|
.catch { error ->
|
||||||
|
if (error is IOException) {
|
||||||
|
emit(emptyPreferences())
|
||||||
|
} else {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override val schedules: Flow<List<ProfileSchedule>> = preferences.map { values ->
|
||||||
|
ScheduleJsonCodec.decode(values[SCHEDULES_KEY])
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun create(schedule: ProfileSchedule): Boolean {
|
||||||
|
var created = false
|
||||||
|
dataStore.edit { values ->
|
||||||
|
val current = ScheduleJsonCodec.decode(values[SCHEDULES_KEY])
|
||||||
|
if (current.any { it.id == schedule.id }) return@edit
|
||||||
|
values[SCHEDULES_KEY] = ScheduleJsonCodec.encode(current + schedule)
|
||||||
|
created = true
|
||||||
|
}
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun update(schedule: ProfileSchedule): Boolean {
|
||||||
|
var updated = false
|
||||||
|
dataStore.edit { values ->
|
||||||
|
val current = ScheduleJsonCodec.decode(values[SCHEDULES_KEY])
|
||||||
|
if (current.none { it.id == schedule.id }) return@edit
|
||||||
|
values[SCHEDULES_KEY] = ScheduleJsonCodec.encode(
|
||||||
|
current.map { existing -> if (existing.id == schedule.id) schedule else existing },
|
||||||
|
)
|
||||||
|
updated = true
|
||||||
|
}
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun delete(id: String): Boolean {
|
||||||
|
var deleted = false
|
||||||
|
dataStore.edit { values ->
|
||||||
|
val current = ScheduleJsonCodec.decode(values[SCHEDULES_KEY])
|
||||||
|
if (current.none { it.id == id }) return@edit
|
||||||
|
values[SCHEDULES_KEY] = ScheduleJsonCodec.encode(current.filterNot { it.id == id })
|
||||||
|
deleted = true
|
||||||
|
}
|
||||||
|
return deleted
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setEnabled(id: String, enabled: Boolean): Boolean {
|
||||||
|
var updated = false
|
||||||
|
dataStore.edit { values ->
|
||||||
|
val current = ScheduleJsonCodec.decode(values[SCHEDULES_KEY])
|
||||||
|
if (current.none { it.id == id }) return@edit
|
||||||
|
values[SCHEDULES_KEY] = ScheduleJsonCodec.encode(
|
||||||
|
current.map { schedule ->
|
||||||
|
if (schedule.id == id) schedule.copy(enabled = enabled) else schedule
|
||||||
|
},
|
||||||
|
)
|
||||||
|
updated = true
|
||||||
|
}
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun readRuntime(): SchedulerRuntime {
|
||||||
|
val values = preferences.first()
|
||||||
|
return values.toRuntime()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun writeRuntime(runtime: SchedulerRuntime) {
|
||||||
|
dataStore.edit { values ->
|
||||||
|
values.putRuntime(runtime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun clearRuntime() {
|
||||||
|
dataStore.edit { values ->
|
||||||
|
values.remove(RUNTIME_ACTIVE_SCHEDULE_ID_KEY)
|
||||||
|
values.remove(RUNTIME_ACTIVE_WINDOW_START_KEY)
|
||||||
|
values.remove(RUNTIME_ACTIVE_PROFILE_ID_KEY)
|
||||||
|
values.remove(RUNTIME_FALLBACK_PROFILE_ID_KEY)
|
||||||
|
values.remove(RUNTIME_FIREWALL_WAS_ENABLED_KEY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Preferences.toRuntime(): SchedulerRuntime = SchedulerRuntime(
|
||||||
|
activeScheduleId = this[RUNTIME_ACTIVE_SCHEDULE_ID_KEY],
|
||||||
|
activeWindowStartMillis = this[RUNTIME_ACTIVE_WINDOW_START_KEY],
|
||||||
|
activeProfileId = this[RUNTIME_ACTIVE_PROFILE_ID_KEY],
|
||||||
|
fallbackProfileId = this[RUNTIME_FALLBACK_PROFILE_ID_KEY],
|
||||||
|
firewallWasEnabledBefore = if (contains(RUNTIME_FIREWALL_WAS_ENABLED_KEY)) {
|
||||||
|
this[RUNTIME_FIREWALL_WAS_ENABLED_KEY]
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun MutablePreferences.putRuntime(runtime: SchedulerRuntime) {
|
||||||
|
runtime.activeScheduleId.putOrRemove(this, RUNTIME_ACTIVE_SCHEDULE_ID_KEY)
|
||||||
|
runtime.activeWindowStartMillis.putOrRemove(this, RUNTIME_ACTIVE_WINDOW_START_KEY)
|
||||||
|
runtime.activeProfileId.putOrRemove(this, RUNTIME_ACTIVE_PROFILE_ID_KEY)
|
||||||
|
runtime.fallbackProfileId.putOrRemove(this, RUNTIME_FALLBACK_PROFILE_ID_KEY)
|
||||||
|
runtime.firewallWasEnabledBefore.putOrRemove(this, RUNTIME_FIREWALL_WAS_ENABLED_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String?.putOrRemove(
|
||||||
|
preferences: MutablePreferences,
|
||||||
|
key: Preferences.Key<String>,
|
||||||
|
) {
|
||||||
|
if (this == null) preferences.remove(key) else preferences[key] = this
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Long?.putOrRemove(
|
||||||
|
preferences: MutablePreferences,
|
||||||
|
key: Preferences.Key<Long>,
|
||||||
|
) {
|
||||||
|
if (this == null) preferences.remove(key) else preferences[key] = this
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Boolean?.putOrRemove(
|
||||||
|
preferences: MutablePreferences,
|
||||||
|
key: Preferences.Key<Boolean>,
|
||||||
|
) {
|
||||||
|
if (this == null) preferences.remove(key) else preferences[key] = this
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val SCHEDULES_KEY = stringPreferencesKey("schedules")
|
||||||
|
val RUNTIME_ACTIVE_SCHEDULE_ID_KEY = stringPreferencesKey("runtime_active_schedule_id")
|
||||||
|
val RUNTIME_ACTIVE_WINDOW_START_KEY = longPreferencesKey("runtime_active_window_start")
|
||||||
|
val RUNTIME_ACTIVE_PROFILE_ID_KEY = stringPreferencesKey("runtime_active_profile_id")
|
||||||
|
val RUNTIME_FALLBACK_PROFILE_ID_KEY = stringPreferencesKey("runtime_fallback_profile_id")
|
||||||
|
val RUNTIME_FIREWALL_WAS_ENABLED_KEY =
|
||||||
|
booleanPreferencesKey("runtime_firewall_was_enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package com.krrishg.kwall.scheduler
|
||||||
|
|
||||||
|
import java.time.DayOfWeek
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
|
||||||
|
const val MAX_SCHEDULE_NAME_LENGTH = 40
|
||||||
|
|
||||||
|
data class ProfileSchedule(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val profileId: String,
|
||||||
|
val fallbackProfileId: String,
|
||||||
|
val recurrence: ScheduleRecurrence,
|
||||||
|
val startTime: LocalTime,
|
||||||
|
val endTime: LocalTime? = null,
|
||||||
|
val enabled: Boolean = true,
|
||||||
|
val createdAtMillis: Long = 0L,
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface ScheduleRecurrence {
|
||||||
|
val anchorDate: LocalDate
|
||||||
|
|
||||||
|
data class Once(val date: LocalDate) : ScheduleRecurrence {
|
||||||
|
override val anchorDate: LocalDate get() = date
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Daily(
|
||||||
|
override val anchorDate: LocalDate,
|
||||||
|
val intervalDays: Int = 1,
|
||||||
|
) : ScheduleRecurrence
|
||||||
|
|
||||||
|
data class Weekly(
|
||||||
|
override val anchorDate: LocalDate,
|
||||||
|
val daysOfWeek: Set<DayOfWeek>,
|
||||||
|
val intervalWeeks: Int = 1,
|
||||||
|
) : ScheduleRecurrence
|
||||||
|
|
||||||
|
data class Monthly(
|
||||||
|
override val anchorDate: LocalDate,
|
||||||
|
val dayOfMonth: Int,
|
||||||
|
val intervalMonths: Int = 1,
|
||||||
|
) : ScheduleRecurrence
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ScheduleIssue {
|
||||||
|
BLANK_NAME,
|
||||||
|
NAME_TOO_LONG,
|
||||||
|
PROFILE_MISSING,
|
||||||
|
FALLBACK_MISSING,
|
||||||
|
INVALID_INTERVAL,
|
||||||
|
NO_WEEKDAYS,
|
||||||
|
INVALID_DAY_OF_MONTH,
|
||||||
|
PAST_ONE_TIME,
|
||||||
|
OVERLAP,
|
||||||
|
}
|
||||||
|
|
||||||
|
fun validateScheduleBasics(
|
||||||
|
schedule: ProfileSchedule,
|
||||||
|
availableProfileIds: Set<String>,
|
||||||
|
): List<ScheduleIssue> {
|
||||||
|
val issues = mutableListOf<ScheduleIssue>()
|
||||||
|
val name = schedule.name.trim()
|
||||||
|
if (name.isEmpty()) issues += ScheduleIssue.BLANK_NAME
|
||||||
|
if (name.length > MAX_SCHEDULE_NAME_LENGTH) issues += ScheduleIssue.NAME_TOO_LONG
|
||||||
|
if (schedule.profileId !in availableProfileIds) issues += ScheduleIssue.PROFILE_MISSING
|
||||||
|
if (schedule.fallbackProfileId !in availableProfileIds) issues += ScheduleIssue.FALLBACK_MISSING
|
||||||
|
when (val recurrence = schedule.recurrence) {
|
||||||
|
is ScheduleRecurrence.Once -> Unit
|
||||||
|
is ScheduleRecurrence.Daily -> {
|
||||||
|
if (recurrence.intervalDays < 1) issues += ScheduleIssue.INVALID_INTERVAL
|
||||||
|
}
|
||||||
|
is ScheduleRecurrence.Weekly -> {
|
||||||
|
if (recurrence.daysOfWeek.isEmpty()) issues += ScheduleIssue.NO_WEEKDAYS
|
||||||
|
if (recurrence.intervalWeeks < 1) issues += ScheduleIssue.INVALID_INTERVAL
|
||||||
|
}
|
||||||
|
is ScheduleRecurrence.Monthly -> {
|
||||||
|
if (recurrence.dayOfMonth !in 1..31) issues += ScheduleIssue.INVALID_DAY_OF_MONTH
|
||||||
|
if (recurrence.intervalMonths < 1) issues += ScheduleIssue.INVALID_INTERVAL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return issues
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package com.krrishg.kwall.scheduler
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.krrishg.kwall.firewall.DEFAULT_PROFILE_ID
|
||||||
|
import com.krrishg.kwall.firewall.FirewallController
|
||||||
|
import java.time.Clock
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
|
||||||
|
class ProfileScheduler(
|
||||||
|
private val scheduleStore: ScheduleStore,
|
||||||
|
private val firewallController: FirewallController,
|
||||||
|
private val alarms: ScheduleAlarms,
|
||||||
|
private val clock: Clock = Clock.systemDefaultZone(),
|
||||||
|
private val zoneProvider: () -> ZoneId = { ZoneId.systemDefault() },
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val mutex = Mutex()
|
||||||
|
private val started = AtomicBoolean(false)
|
||||||
|
|
||||||
|
fun start(scope: CoroutineScope) {
|
||||||
|
if (!started.compareAndSet(false, true)) return
|
||||||
|
scope.launch {
|
||||||
|
scheduleStore.schedules.collect {
|
||||||
|
try {
|
||||||
|
reconcile()
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
Log.w(TAG, "Could not reconcile profile schedules", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun onAlarm() {
|
||||||
|
reconcile()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun onSystemEvent() {
|
||||||
|
reconcile()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun reconcile() = mutex.withLock {
|
||||||
|
val now = clock.instant()
|
||||||
|
val zone = zoneProvider()
|
||||||
|
val schedules = scheduleStore.schedules.first()
|
||||||
|
val firewallState = firewallController.state.value
|
||||||
|
val profileIds = firewallState.profiles.map { it.id }.toSet()
|
||||||
|
val usable = schedules.filter { schedule ->
|
||||||
|
schedule.enabled && validateScheduleBasics(schedule, profileIds).isEmpty()
|
||||||
|
}
|
||||||
|
val active = ScheduleEngine.activeSchedule(usable, now, zone)
|
||||||
|
val runtime = scheduleStore.readRuntime()
|
||||||
|
|
||||||
|
if (active != null) {
|
||||||
|
activate(active, runtime, firewallState.enabled)
|
||||||
|
} else if (runtime.activeScheduleId != null) {
|
||||||
|
deactivate(runtime, profileIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
val next = ScheduleEngine.nextTransition(usable, now, zone)
|
||||||
|
if (next == null) {
|
||||||
|
alarms.cancel()
|
||||||
|
} else {
|
||||||
|
alarms.scheduleNext(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun activate(
|
||||||
|
active: ActiveSchedule,
|
||||||
|
runtime: SchedulerRuntime,
|
||||||
|
firewallWasEnabled: Boolean,
|
||||||
|
) {
|
||||||
|
val windowStart = active.startedAt.toEpochMilli()
|
||||||
|
val sameWindow = runtime.activeScheduleId == active.schedule.id &&
|
||||||
|
runtime.activeWindowStartMillis == windowStart
|
||||||
|
val alreadyApplied = sameWindow && runtime.activeProfileId == active.schedule.profileId
|
||||||
|
if (alreadyApplied) {
|
||||||
|
if (runtime.fallbackProfileId != active.schedule.fallbackProfileId) {
|
||||||
|
scheduleStore.writeRuntime(
|
||||||
|
runtime.copy(fallbackProfileId = active.schedule.fallbackProfileId),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val baseline = runtime.firewallWasEnabledBefore ?: firewallWasEnabled
|
||||||
|
scheduleStore.writeRuntime(
|
||||||
|
SchedulerRuntime(
|
||||||
|
activeScheduleId = active.schedule.id,
|
||||||
|
activeWindowStartMillis = windowStart,
|
||||||
|
activeProfileId = active.schedule.profileId,
|
||||||
|
fallbackProfileId = active.schedule.fallbackProfileId,
|
||||||
|
firewallWasEnabledBefore = baseline,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val applied = firewallController.applyScheduledProfile(
|
||||||
|
active.schedule.profileId,
|
||||||
|
enableFirewall = true,
|
||||||
|
)
|
||||||
|
if (!applied) scheduleStore.clearRuntime()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun deactivate(
|
||||||
|
runtime: SchedulerRuntime,
|
||||||
|
profileIds: Set<String>,
|
||||||
|
) {
|
||||||
|
val fallback = runtime.fallbackProfileId?.takeIf { it in profileIds }
|
||||||
|
?: profileIds.firstOrNull { it == DEFAULT_PROFILE_ID }
|
||||||
|
?: profileIds.firstOrNull()
|
||||||
|
val enableFirewall = when (runtime.firewallWasEnabledBefore) {
|
||||||
|
true -> null
|
||||||
|
false -> false
|
||||||
|
null -> null
|
||||||
|
}
|
||||||
|
if (fallback != null) {
|
||||||
|
val applied = firewallController.applyScheduledProfile(fallback, enableFirewall)
|
||||||
|
if (applied) scheduleStore.clearRuntime()
|
||||||
|
} else {
|
||||||
|
scheduleStore.clearRuntime()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "ProfileScheduler"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package com.krrishg.kwall.scheduler
|
||||||
|
|
||||||
|
import android.app.AlarmManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.Settings
|
||||||
|
import com.krrishg.kwall.receivers.ScheduleAlarmReceiver
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
interface ScheduleAlarms {
|
||||||
|
fun canScheduleExactAlarms(): Boolean
|
||||||
|
|
||||||
|
fun scheduleNext(at: Instant)
|
||||||
|
|
||||||
|
fun cancel()
|
||||||
|
|
||||||
|
fun requestExactAlarmPermission()
|
||||||
|
}
|
||||||
|
|
||||||
|
class AlarmManagerScheduleAlarms(context: Context) : ScheduleAlarms {
|
||||||
|
|
||||||
|
private val appContext = context.applicationContext
|
||||||
|
private val alarmManager = appContext.getSystemService(AlarmManager::class.java)
|
||||||
|
|
||||||
|
override fun canScheduleExactAlarms(): Boolean {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return true
|
||||||
|
val manager = alarmManager ?: return false
|
||||||
|
return runCatching { manager.canScheduleExactAlarms() }.getOrDefault(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun scheduleNext(at: Instant) {
|
||||||
|
val manager = alarmManager ?: return
|
||||||
|
val triggerAtMillis = maxOf(at.toEpochMilli(), System.currentTimeMillis() + MIN_DELAY_MILLIS)
|
||||||
|
val pendingIntent = pendingIntent()
|
||||||
|
if (canScheduleExactAlarms()) {
|
||||||
|
try {
|
||||||
|
manager.setExactAndAllowWhileIdle(
|
||||||
|
AlarmManager.RTC_WAKEUP,
|
||||||
|
triggerAtMillis,
|
||||||
|
pendingIntent,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
} catch (_: SecurityException) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
manager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun cancel() {
|
||||||
|
val manager = alarmManager ?: return
|
||||||
|
val pendingIntent = PendingIntent.getBroadcast(
|
||||||
|
appContext,
|
||||||
|
REQUEST_CODE,
|
||||||
|
alarmIntent(),
|
||||||
|
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
) ?: return
|
||||||
|
manager.cancel(pendingIntent)
|
||||||
|
pendingIntent.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun requestExactAlarmPermission() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return
|
||||||
|
if (canScheduleExactAlarms()) return
|
||||||
|
val intent = Intent(
|
||||||
|
Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM,
|
||||||
|
Uri.fromParts("package", appContext.packageName, null),
|
||||||
|
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
runCatching { appContext.startActivity(intent) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pendingIntent(): PendingIntent = PendingIntent.getBroadcast(
|
||||||
|
appContext,
|
||||||
|
REQUEST_CODE,
|
||||||
|
alarmIntent(),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun alarmIntent(): Intent = Intent(appContext, ScheduleAlarmReceiver::class.java).apply {
|
||||||
|
action = ACTION_SCHEDULE_ALARM
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ACTION_SCHEDULE_ALARM = "com.krrishg.kwall.action.SCHEDULE_ALARM"
|
||||||
|
private const val REQUEST_CODE = 2001
|
||||||
|
private const val MIN_DELAY_MILLIS = 1_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
package com.krrishg.kwall.scheduler
|
||||||
|
|
||||||
|
import java.time.DayOfWeek
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.YearMonth
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
|
||||||
|
data class ActiveSchedule(
|
||||||
|
val schedule: ProfileSchedule,
|
||||||
|
val startedAt: Instant,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ScheduleOccurrence(
|
||||||
|
val schedule: ProfileSchedule,
|
||||||
|
val date: LocalDate,
|
||||||
|
val start: Instant,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ScheduleWindow(
|
||||||
|
val schedule: ProfileSchedule,
|
||||||
|
val start: Instant,
|
||||||
|
val end: Instant?,
|
||||||
|
)
|
||||||
|
|
||||||
|
object ScheduleEngine {
|
||||||
|
|
||||||
|
private const val MAX_ITERATIONS = 100_000
|
||||||
|
private const val OVERLAP_LOOKAHEAD_DAYS = 365L * 5
|
||||||
|
|
||||||
|
fun windowFor(
|
||||||
|
schedule: ProfileSchedule,
|
||||||
|
date: LocalDate,
|
||||||
|
zone: ZoneId,
|
||||||
|
): ScheduleWindow? {
|
||||||
|
val start = startInstant(schedule, date, zone)
|
||||||
|
val end = endInstant(schedule, date, zone)
|
||||||
|
return ScheduleWindow(schedule, start, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun nextOccurrence(
|
||||||
|
schedule: ProfileSchedule,
|
||||||
|
after: Instant,
|
||||||
|
zone: ZoneId,
|
||||||
|
): Instant? = nextOccurrenceAfter(schedule, after, zone)?.start
|
||||||
|
|
||||||
|
fun activeSchedule(
|
||||||
|
schedules: List<ProfileSchedule>,
|
||||||
|
at: Instant,
|
||||||
|
zone: ZoneId,
|
||||||
|
): ActiveSchedule? {
|
||||||
|
var latestStart: ScheduleOccurrence? = null
|
||||||
|
var latestEnd: Instant? = null
|
||||||
|
|
||||||
|
schedules.forEach { schedule ->
|
||||||
|
val occurrence = previousOccurrenceAtOrBefore(schedule, at, zone) ?: return@forEach
|
||||||
|
if (latestStart == null || isLater(occurrence, latestStart!!)) {
|
||||||
|
latestStart = occurrence
|
||||||
|
}
|
||||||
|
val end = endInstant(schedule, occurrence.date, zone)
|
||||||
|
if (end != null && !end.isAfter(at) && (latestEnd == null || end.isAfter(latestEnd))) {
|
||||||
|
latestEnd = end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val start = latestStart ?: return null
|
||||||
|
if (latestEnd != null && latestEnd!!.isAfter(start.start)) return null
|
||||||
|
return ActiveSchedule(start.schedule, start.start)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun nextTransition(
|
||||||
|
schedules: List<ProfileSchedule>,
|
||||||
|
after: Instant,
|
||||||
|
zone: ZoneId,
|
||||||
|
): Instant? {
|
||||||
|
var next: Instant? = null
|
||||||
|
schedules.forEach { schedule ->
|
||||||
|
val nextOccurrence = nextOccurrenceAfter(schedule, after, zone)
|
||||||
|
if (nextOccurrence != null) {
|
||||||
|
next = earliest(next, nextOccurrence.start)
|
||||||
|
val end = endInstant(schedule, nextOccurrence.date, zone)
|
||||||
|
if (end != null && end.isAfter(after)) next = earliest(next, end)
|
||||||
|
}
|
||||||
|
val previousOccurrence = previousOccurrenceAtOrBefore(schedule, after, zone)
|
||||||
|
if (previousOccurrence != null) {
|
||||||
|
val end = endInstant(schedule, previousOccurrence.date, zone)
|
||||||
|
if (end != null && end.isAfter(after)) next = earliest(next, end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
fun validateSchedule(
|
||||||
|
candidate: ProfileSchedule,
|
||||||
|
existing: List<ProfileSchedule>,
|
||||||
|
availableProfileIds: Set<String>,
|
||||||
|
now: Instant,
|
||||||
|
zone: ZoneId,
|
||||||
|
): List<ScheduleIssue> {
|
||||||
|
val issues = validateScheduleBasics(candidate, availableProfileIds).toMutableList()
|
||||||
|
val recurrence = candidate.recurrence
|
||||||
|
if (recurrence is ScheduleRecurrence.Once) {
|
||||||
|
val start = startInstant(candidate, recurrence.date, zone)
|
||||||
|
if (!start.isAfter(now)) issues += ScheduleIssue.PAST_ONE_TIME
|
||||||
|
}
|
||||||
|
if (issues.isNotEmpty()) return issues
|
||||||
|
|
||||||
|
val from = now.minus(2, ChronoUnit.DAYS)
|
||||||
|
val to = now.plus(OVERLAP_LOOKAHEAD_DAYS, ChronoUnit.DAYS)
|
||||||
|
val candidateWindows = occurrencesInRange(candidate, from, to, zone)
|
||||||
|
val overlaps = existing
|
||||||
|
.asSequence()
|
||||||
|
.filter { it.id != candidate.id }
|
||||||
|
.map { other -> occurrencesInRange(other, from, to, zone) }
|
||||||
|
.any { otherWindows -> overlaps(candidateWindows, otherWindows) }
|
||||||
|
if (overlaps) issues += ScheduleIssue.OVERLAP
|
||||||
|
return issues
|
||||||
|
}
|
||||||
|
|
||||||
|
fun occurrencesInRange(
|
||||||
|
schedule: ProfileSchedule,
|
||||||
|
from: Instant,
|
||||||
|
to: Instant,
|
||||||
|
zone: ZoneId,
|
||||||
|
): List<ScheduleWindow> {
|
||||||
|
if (to.isBefore(from)) return emptyList()
|
||||||
|
val firstDate = from.atZone(zone).toLocalDate().minusDays(2)
|
||||||
|
var date = nextOccurrenceDate(schedule, firstDate) ?: return emptyList()
|
||||||
|
val windows = mutableListOf<ScheduleWindow>()
|
||||||
|
var iterations = 0
|
||||||
|
while (iterations++ < MAX_ITERATIONS) {
|
||||||
|
val start = startInstant(schedule, date, zone)
|
||||||
|
if (start.isAfter(to)) break
|
||||||
|
val window = windowFor(schedule, date, zone)
|
||||||
|
if (window != null) {
|
||||||
|
val end = window.end
|
||||||
|
val relevant = if (end == null) {
|
||||||
|
!start.isBefore(from)
|
||||||
|
} else {
|
||||||
|
end.isAfter(from)
|
||||||
|
}
|
||||||
|
if (relevant) windows += window
|
||||||
|
}
|
||||||
|
date = nextOccurrenceDate(schedule, date.plusDays(1)) ?: break
|
||||||
|
}
|
||||||
|
return windows
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startInstant(schedule: ProfileSchedule, date: LocalDate, zone: ZoneId): Instant =
|
||||||
|
date.atTime(schedule.startTime).atZone(zone).toInstant()
|
||||||
|
|
||||||
|
private fun endInstant(schedule: ProfileSchedule, date: LocalDate, zone: ZoneId): Instant? {
|
||||||
|
val endTime = schedule.endTime ?: return null
|
||||||
|
val endDate = if (endTime.isAfter(schedule.startTime)) date else date.plusDays(1)
|
||||||
|
val start = startInstant(schedule, date, zone)
|
||||||
|
var end = endDate.atTime(endTime).atZone(zone).toInstant()
|
||||||
|
if (!end.isAfter(start)) end = start.plusSeconds(60)
|
||||||
|
return end
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun nextOccurrenceAfter(
|
||||||
|
schedule: ProfileSchedule,
|
||||||
|
after: Instant,
|
||||||
|
zone: ZoneId,
|
||||||
|
): ScheduleOccurrence? {
|
||||||
|
var date = nextOccurrenceDate(schedule, after.atZone(zone).toLocalDate()) ?: return null
|
||||||
|
var iterations = 0
|
||||||
|
while (iterations++ < MAX_ITERATIONS) {
|
||||||
|
val start = startInstant(schedule, date, zone)
|
||||||
|
if (start.isAfter(after)) return ScheduleOccurrence(schedule, date, start)
|
||||||
|
date = nextOccurrenceDate(schedule, date.plusDays(1)) ?: return null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun previousOccurrenceAtOrBefore(
|
||||||
|
schedule: ProfileSchedule,
|
||||||
|
at: Instant,
|
||||||
|
zone: ZoneId,
|
||||||
|
): ScheduleOccurrence? {
|
||||||
|
var date = previousOccurrenceDate(schedule, at.atZone(zone).toLocalDate()) ?: return null
|
||||||
|
var iterations = 0
|
||||||
|
while (iterations++ < MAX_ITERATIONS) {
|
||||||
|
val start = startInstant(schedule, date, zone)
|
||||||
|
if (!start.isAfter(at)) return ScheduleOccurrence(schedule, date, start)
|
||||||
|
date = previousOccurrenceDate(schedule, date.minusDays(1)) ?: return null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun nextOccurrenceDate(
|
||||||
|
schedule: ProfileSchedule,
|
||||||
|
date: LocalDate,
|
||||||
|
): LocalDate? = nextRawOccurrenceDate(schedule, date)
|
||||||
|
|
||||||
|
private fun previousOccurrenceDate(
|
||||||
|
schedule: ProfileSchedule,
|
||||||
|
date: LocalDate,
|
||||||
|
): LocalDate? = previousRawOccurrenceDate(schedule, date)
|
||||||
|
|
||||||
|
private fun nextRawOccurrenceDate(
|
||||||
|
schedule: ProfileSchedule,
|
||||||
|
date: LocalDate,
|
||||||
|
): LocalDate? = when (val recurrence = schedule.recurrence) {
|
||||||
|
is ScheduleRecurrence.Once -> recurrence.date.takeIf { !it.isBefore(date) }
|
||||||
|
is ScheduleRecurrence.Daily -> nextDailyDate(recurrence, date)
|
||||||
|
is ScheduleRecurrence.Weekly -> nextWeeklyDate(recurrence, date)
|
||||||
|
is ScheduleRecurrence.Monthly -> nextMonthlyDate(recurrence, date)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun previousRawOccurrenceDate(
|
||||||
|
schedule: ProfileSchedule,
|
||||||
|
date: LocalDate,
|
||||||
|
): LocalDate? = when (val recurrence = schedule.recurrence) {
|
||||||
|
is ScheduleRecurrence.Once -> recurrence.date.takeIf { !it.isAfter(date) }
|
||||||
|
is ScheduleRecurrence.Daily -> previousDailyDate(recurrence, date)
|
||||||
|
is ScheduleRecurrence.Weekly -> previousWeeklyDate(recurrence, date)
|
||||||
|
is ScheduleRecurrence.Monthly -> previousMonthlyDate(recurrence, date)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun nextDailyDate(
|
||||||
|
recurrence: ScheduleRecurrence.Daily,
|
||||||
|
date: LocalDate,
|
||||||
|
): LocalDate? {
|
||||||
|
if (recurrence.intervalDays < 1) return null
|
||||||
|
val anchor = recurrence.anchorDate
|
||||||
|
if (date <= anchor) return anchor
|
||||||
|
val interval = recurrence.intervalDays.toLong()
|
||||||
|
val days = ChronoUnit.DAYS.between(anchor, date)
|
||||||
|
val steps = (days + interval - 1) / interval
|
||||||
|
return anchor.plusDays(steps * interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun previousDailyDate(
|
||||||
|
recurrence: ScheduleRecurrence.Daily,
|
||||||
|
date: LocalDate,
|
||||||
|
): LocalDate? {
|
||||||
|
if (recurrence.intervalDays < 1) return null
|
||||||
|
val anchor = recurrence.anchorDate
|
||||||
|
if (date < anchor) return null
|
||||||
|
val interval = recurrence.intervalDays.toLong()
|
||||||
|
val steps = ChronoUnit.DAYS.between(anchor, date) / interval
|
||||||
|
return anchor.plusDays(steps * interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun nextWeeklyDate(
|
||||||
|
recurrence: ScheduleRecurrence.Weekly,
|
||||||
|
date: LocalDate,
|
||||||
|
): LocalDate? {
|
||||||
|
if (recurrence.daysOfWeek.isEmpty() || recurrence.intervalWeeks < 1) return null
|
||||||
|
val anchor = recurrence.anchorDate
|
||||||
|
val interval = recurrence.intervalWeeks.toLong()
|
||||||
|
val anchorWeek = mondayOf(anchor)
|
||||||
|
val targetWeek = mondayOf(if (date.isBefore(anchor)) anchor else date)
|
||||||
|
val weeks = ChronoUnit.WEEKS.between(anchorWeek, targetWeek)
|
||||||
|
var step = if (weeks <= 0) 0 else (weeks + interval - 1) / interval
|
||||||
|
var iterations = 0
|
||||||
|
while (iterations++ < MAX_ITERATIONS) {
|
||||||
|
val week = anchorWeek.plusWeeks(step * interval)
|
||||||
|
weeklyDates(recurrence, week).forEach { candidate ->
|
||||||
|
if (!candidate.isBefore(date) && !candidate.isBefore(anchor)) return candidate
|
||||||
|
}
|
||||||
|
step += 1
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun previousWeeklyDate(
|
||||||
|
recurrence: ScheduleRecurrence.Weekly,
|
||||||
|
date: LocalDate,
|
||||||
|
): LocalDate? {
|
||||||
|
if (recurrence.daysOfWeek.isEmpty() || recurrence.intervalWeeks < 1) return null
|
||||||
|
val anchor = recurrence.anchorDate
|
||||||
|
if (date < anchor) return null
|
||||||
|
val interval = recurrence.intervalWeeks.toLong()
|
||||||
|
val anchorWeek = mondayOf(anchor)
|
||||||
|
val weeks = ChronoUnit.WEEKS.between(anchorWeek, mondayOf(date))
|
||||||
|
var step = (weeks / interval).coerceAtLeast(0)
|
||||||
|
var iterations = 0
|
||||||
|
while (step >= 0 && iterations++ < MAX_ITERATIONS) {
|
||||||
|
val week = anchorWeek.plusWeeks(step * interval)
|
||||||
|
weeklyDates(recurrence, week)
|
||||||
|
.asReversed()
|
||||||
|
.forEach { candidate ->
|
||||||
|
if (!candidate.isAfter(date) && !candidate.isBefore(anchor)) return candidate
|
||||||
|
}
|
||||||
|
step -= 1
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun weeklyDates(
|
||||||
|
recurrence: ScheduleRecurrence.Weekly,
|
||||||
|
weekStart: LocalDate,
|
||||||
|
): List<LocalDate> = recurrence.daysOfWeek
|
||||||
|
.sortedBy { it.value }
|
||||||
|
.map { day -> weekStart.plusDays((day.value - 1).toLong()) }
|
||||||
|
|
||||||
|
private fun nextMonthlyDate(
|
||||||
|
recurrence: ScheduleRecurrence.Monthly,
|
||||||
|
date: LocalDate,
|
||||||
|
): LocalDate? {
|
||||||
|
if (recurrence.dayOfMonth !in 1..31 || recurrence.intervalMonths < 1) return null
|
||||||
|
val anchor = recurrence.anchorDate
|
||||||
|
val effective = if (date.isBefore(anchor)) anchor else date
|
||||||
|
val interval = recurrence.intervalMonths.toLong()
|
||||||
|
val anchorMonth = YearMonth.from(anchor)
|
||||||
|
val months = ChronoUnit.MONTHS.between(anchorMonth, YearMonth.from(effective))
|
||||||
|
var step = if (months <= 0) 0 else (months + interval - 1) / interval
|
||||||
|
var iterations = 0
|
||||||
|
while (iterations++ < MAX_ITERATIONS) {
|
||||||
|
val month = anchorMonth.plusMonths(step * interval)
|
||||||
|
val candidate = month.atDay(recurrence.dayOfMonth.coerceAtMost(month.lengthOfMonth()))
|
||||||
|
if (!candidate.isBefore(effective) && !candidate.isBefore(anchor)) return candidate
|
||||||
|
step += 1
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun previousMonthlyDate(
|
||||||
|
recurrence: ScheduleRecurrence.Monthly,
|
||||||
|
date: LocalDate,
|
||||||
|
): LocalDate? {
|
||||||
|
if (recurrence.dayOfMonth !in 1..31 || recurrence.intervalMonths < 1) return null
|
||||||
|
val anchor = recurrence.anchorDate
|
||||||
|
if (date < anchor) return null
|
||||||
|
val interval = recurrence.intervalMonths.toLong()
|
||||||
|
val anchorMonth = YearMonth.from(anchor)
|
||||||
|
val months = ChronoUnit.MONTHS.between(anchorMonth, YearMonth.from(date))
|
||||||
|
var step = (months / interval).coerceAtLeast(0)
|
||||||
|
var iterations = 0
|
||||||
|
while (step >= 0 && iterations++ < MAX_ITERATIONS) {
|
||||||
|
val month = anchorMonth.plusMonths(step * interval)
|
||||||
|
val candidate = month.atDay(recurrence.dayOfMonth.coerceAtMost(month.lengthOfMonth()))
|
||||||
|
if (!candidate.isAfter(date) && !candidate.isBefore(anchor)) return candidate
|
||||||
|
step -= 1
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun overlaps(
|
||||||
|
candidateWindows: List<ScheduleWindow>,
|
||||||
|
otherWindows: List<ScheduleWindow>,
|
||||||
|
): Boolean {
|
||||||
|
candidateWindows.forEach { candidate ->
|
||||||
|
otherWindows.forEach { other ->
|
||||||
|
if (windowsConflict(candidate, other)) return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun windowsConflict(first: ScheduleWindow, second: ScheduleWindow): Boolean {
|
||||||
|
val firstEnd = first.end
|
||||||
|
val secondEnd = second.end
|
||||||
|
return when {
|
||||||
|
firstEnd == null && secondEnd == null -> first.start == second.start
|
||||||
|
firstEnd == null -> !first.start.isBefore(second.start) && first.start.isBefore(secondEnd)
|
||||||
|
secondEnd == null -> !second.start.isBefore(first.start) && second.start.isBefore(firstEnd)
|
||||||
|
else -> first.start.isBefore(secondEnd) && second.start.isBefore(firstEnd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isLater(candidate: ScheduleOccurrence, current: ScheduleOccurrence): Boolean {
|
||||||
|
if (candidate.start != current.start) return candidate.start.isAfter(current.start)
|
||||||
|
val createdAt = candidate.schedule.createdAtMillis.compareTo(current.schedule.createdAtMillis)
|
||||||
|
if (createdAt != 0) return createdAt > 0
|
||||||
|
return candidate.schedule.id > current.schedule.id
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun earliest(current: Instant?, candidate: Instant): Instant =
|
||||||
|
if (current == null || candidate.isBefore(current)) candidate else current
|
||||||
|
|
||||||
|
private fun mondayOf(date: LocalDate): LocalDate =
|
||||||
|
date.minusDays((date.dayOfWeek.value - DayOfWeek.MONDAY.value).toLong())
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package com.krrishg.kwall.scheduler
|
||||||
|
|
||||||
|
import java.time.DayOfWeek
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class ScheduleRecordList(
|
||||||
|
val version: Int = 1,
|
||||||
|
val schedules: List<ScheduleRecord> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class ScheduleRecord(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val profileId: String,
|
||||||
|
val fallbackProfileId: String,
|
||||||
|
val type: String,
|
||||||
|
val anchorDate: String,
|
||||||
|
val startTime: String,
|
||||||
|
val endTime: String? = null,
|
||||||
|
val interval: Int = 1,
|
||||||
|
val daysOfWeek: List<Int> = emptyList(),
|
||||||
|
val dayOfMonth: Int = 1,
|
||||||
|
val enabled: Boolean = true,
|
||||||
|
val createdAtMillis: Long = 0L,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal object ScheduleJsonCodec {
|
||||||
|
|
||||||
|
private val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
encodeDefaults = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun encode(schedules: List<ProfileSchedule>): String =
|
||||||
|
json.encodeToString(
|
||||||
|
ScheduleRecordList(schedules = schedules.map { it.toRecord() }),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun decode(raw: String?): List<ProfileSchedule> {
|
||||||
|
if (raw.isNullOrBlank()) return emptyList()
|
||||||
|
return runCatching {
|
||||||
|
json.decodeFromString<ScheduleRecordList>(raw)
|
||||||
|
.schedules
|
||||||
|
.mapNotNull { it.toProfileSchedule() }
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ProfileSchedule.toRecord(): ScheduleRecord {
|
||||||
|
val recurrence = this.recurrence
|
||||||
|
val type = when (recurrence) {
|
||||||
|
is ScheduleRecurrence.Once -> TYPE_ONCE
|
||||||
|
is ScheduleRecurrence.Daily -> TYPE_DAILY
|
||||||
|
is ScheduleRecurrence.Weekly -> TYPE_WEEKLY
|
||||||
|
is ScheduleRecurrence.Monthly -> TYPE_MONTHLY
|
||||||
|
}
|
||||||
|
val interval = when (recurrence) {
|
||||||
|
is ScheduleRecurrence.Once -> 1
|
||||||
|
is ScheduleRecurrence.Daily -> recurrence.intervalDays
|
||||||
|
is ScheduleRecurrence.Weekly -> recurrence.intervalWeeks
|
||||||
|
is ScheduleRecurrence.Monthly -> recurrence.intervalMonths
|
||||||
|
}
|
||||||
|
val daysOfWeek = (recurrence as? ScheduleRecurrence.Weekly)
|
||||||
|
?.daysOfWeek
|
||||||
|
?.sortedBy { it.value }
|
||||||
|
?.map { it.value }
|
||||||
|
.orEmpty()
|
||||||
|
val dayOfMonth = (recurrence as? ScheduleRecurrence.Monthly)?.dayOfMonth ?: 1
|
||||||
|
return ScheduleRecord(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
profileId = profileId,
|
||||||
|
fallbackProfileId = fallbackProfileId,
|
||||||
|
type = type,
|
||||||
|
anchorDate = recurrence.anchorDate.toString(),
|
||||||
|
startTime = startTime.toString(),
|
||||||
|
endTime = endTime?.toString(),
|
||||||
|
interval = interval,
|
||||||
|
daysOfWeek = daysOfWeek,
|
||||||
|
dayOfMonth = dayOfMonth,
|
||||||
|
enabled = enabled,
|
||||||
|
createdAtMillis = createdAtMillis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ScheduleRecord.toProfileSchedule(): ProfileSchedule? {
|
||||||
|
val anchor = runCatching { LocalDate.parse(anchorDate) }.getOrNull() ?: return null
|
||||||
|
val start = runCatching { LocalTime.parse(startTime) }.getOrNull() ?: return null
|
||||||
|
val end = if (endTime != null) {
|
||||||
|
runCatching { LocalTime.parse(endTime) }.getOrNull() ?: return null
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val recurrence = when (type) {
|
||||||
|
TYPE_ONCE -> ScheduleRecurrence.Once(anchor)
|
||||||
|
TYPE_DAILY -> ScheduleRecurrence.Daily(anchor, interval)
|
||||||
|
TYPE_WEEKLY -> {
|
||||||
|
val days = daysOfWeek.mapNotNull { value ->
|
||||||
|
runCatching { DayOfWeek.of(value) }.getOrNull()
|
||||||
|
}.toSet()
|
||||||
|
ScheduleRecurrence.Weekly(anchor, days, interval)
|
||||||
|
}
|
||||||
|
TYPE_MONTHLY -> ScheduleRecurrence.Monthly(anchor, dayOfMonth, interval)
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
return ProfileSchedule(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
profileId = profileId,
|
||||||
|
fallbackProfileId = fallbackProfileId,
|
||||||
|
recurrence = recurrence,
|
||||||
|
startTime = start,
|
||||||
|
endTime = end,
|
||||||
|
enabled = enabled,
|
||||||
|
createdAtMillis = createdAtMillis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val TYPE_ONCE = "once"
|
||||||
|
private const val TYPE_DAILY = "daily"
|
||||||
|
private const val TYPE_WEEKLY = "weekly"
|
||||||
|
private const val TYPE_MONTHLY = "monthly"
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.krrishg.kwall.scheduler
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
interface ScheduleStore {
|
||||||
|
val schedules: Flow<List<ProfileSchedule>>
|
||||||
|
|
||||||
|
suspend fun create(schedule: ProfileSchedule): Boolean
|
||||||
|
|
||||||
|
suspend fun update(schedule: ProfileSchedule): Boolean
|
||||||
|
|
||||||
|
suspend fun delete(id: String): Boolean
|
||||||
|
|
||||||
|
suspend fun setEnabled(id: String, enabled: Boolean): Boolean
|
||||||
|
|
||||||
|
suspend fun readRuntime(): SchedulerRuntime
|
||||||
|
|
||||||
|
suspend fun writeRuntime(runtime: SchedulerRuntime)
|
||||||
|
|
||||||
|
suspend fun clearRuntime()
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.krrishg.kwall.scheduler
|
||||||
|
|
||||||
|
data class SchedulerRuntime(
|
||||||
|
val activeScheduleId: String? = null,
|
||||||
|
val activeWindowStartMillis: Long? = null,
|
||||||
|
val activeProfileId: String? = null,
|
||||||
|
val fallbackProfileId: String? = null,
|
||||||
|
val firewallWasEnabledBefore: Boolean? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.krrishg.kwall.settings
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
interface AppSettingsStore {
|
||||||
|
val appLockEnabled: Flow<Boolean>
|
||||||
|
val autoBlockNewApps: Flow<Boolean>
|
||||||
|
|
||||||
|
suspend fun setAppLockEnabled(enabled: Boolean)
|
||||||
|
|
||||||
|
suspend fun setAutoBlockNewApps(enabled: Boolean)
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.krrishg.kwall.settings
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.emptyPreferences
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import java.io.IOException
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
private val Context.settingsDataStore by preferencesDataStore(name = "settings")
|
||||||
|
|
||||||
|
class DataStoreAppSettingsStore(context: Context) : AppSettingsStore {
|
||||||
|
|
||||||
|
private val dataStore = context.applicationContext.settingsDataStore
|
||||||
|
|
||||||
|
private val preferences = dataStore.data
|
||||||
|
.catch { error ->
|
||||||
|
if (error is IOException) {
|
||||||
|
emit(emptyPreferences())
|
||||||
|
} else {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override val appLockEnabled: Flow<Boolean> = preferences
|
||||||
|
.map { values -> values[APP_LOCK_ENABLED_KEY] ?: true }
|
||||||
|
|
||||||
|
override val autoBlockNewApps: Flow<Boolean> = preferences
|
||||||
|
.map { values -> values[AUTO_BLOCK_NEW_APPS_KEY] ?: false }
|
||||||
|
|
||||||
|
override suspend fun setAppLockEnabled(enabled: Boolean) {
|
||||||
|
dataStore.edit { values ->
|
||||||
|
values[APP_LOCK_ENABLED_KEY] = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setAutoBlockNewApps(enabled: Boolean) {
|
||||||
|
dataStore.edit { values ->
|
||||||
|
values[AUTO_BLOCK_NEW_APPS_KEY] = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val APP_LOCK_ENABLED_KEY = booleanPreferencesKey("app_lock_enabled")
|
||||||
|
val AUTO_BLOCK_NEW_APPS_KEY = booleanPreferencesKey("auto_block_new_apps")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package com.krrishg.kwall.shizuku
|
||||||
|
|
||||||
|
import com.krrishg.kwall.firewall.ShellExecutor
|
||||||
|
import com.krrishg.kwall.firewall.ShellResult
|
||||||
|
import java.lang.reflect.Method
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import rikka.shizuku.Shizuku
|
||||||
|
import rikka.shizuku.ShizukuRemoteProcess
|
||||||
|
|
||||||
|
class ShizukuShellExecutor : ShellExecutor {
|
||||||
|
|
||||||
|
override suspend fun exec(command: String): ShellResult = withContext(Dispatchers.IO) {
|
||||||
|
if (!runCatching { Shizuku.pingBinder() }.getOrDefault(false)) {
|
||||||
|
return@withContext ShellResult(-1, "", "Shizuku is not running")
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val process = newProcess(
|
||||||
|
arrayOf("/system/bin/sh", "-c", command),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
) ?: return@withContext ShellResult(-1, "", "Shizuku process is unavailable")
|
||||||
|
|
||||||
|
coroutineScope {
|
||||||
|
val stdout = async(Dispatchers.IO) {
|
||||||
|
process.inputStream.bufferedReader().use { it.readText() }
|
||||||
|
}
|
||||||
|
val stderr = async(Dispatchers.IO) {
|
||||||
|
process.errorStream.bufferedReader().use { it.readText() }
|
||||||
|
}
|
||||||
|
process.outputStream.close()
|
||||||
|
val exitCode = process.waitFor()
|
||||||
|
val result = ShellResult(exitCode, stdout.await(), stderr.await())
|
||||||
|
process.destroy()
|
||||||
|
result
|
||||||
|
}
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (error: Exception) {
|
||||||
|
ShellResult(-1, "", error.message ?: error.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun newProcess(
|
||||||
|
command: Array<String>,
|
||||||
|
environment: Array<String>?,
|
||||||
|
directory: String?,
|
||||||
|
): ShizukuRemoteProcess? {
|
||||||
|
val method: Method = NEW_PROCESS_METHOD ?: return null
|
||||||
|
return method.invoke(null, command, environment, directory) as? ShizukuRemoteProcess
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val NEW_PROCESS_METHOD: Method? by lazy {
|
||||||
|
runCatching {
|
||||||
|
Shizuku::class.java.getDeclaredMethod(
|
||||||
|
"newProcess",
|
||||||
|
Array<String>::class.java,
|
||||||
|
Array<String>::class.java,
|
||||||
|
String::class.java,
|
||||||
|
).apply { isAccessible = true }
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,775 @@
|
|||||||
|
package com.krrishg.kwall.ui.apps
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.selection.toggleable
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
|
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.DateRange
|
||||||
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel as composeViewModel
|
||||||
|
import com.krrishg.kwall.R
|
||||||
|
import com.krrishg.kwall.data.InstalledApp
|
||||||
|
import com.krrishg.kwall.firewall.AppBlockability
|
||||||
|
import com.krrishg.kwall.firewall.ChainTransport
|
||||||
|
import com.krrishg.kwall.firewall.FirewallAvailability
|
||||||
|
import com.krrishg.kwall.firewall.FirewallController
|
||||||
|
import com.krrishg.kwall.firewall.RulesTransport
|
||||||
|
import com.krrishg.kwall.firewall.SHIZUKU_PACKAGE
|
||||||
|
import com.krrishg.kwall.scheduler.ScheduleAlarms
|
||||||
|
import com.krrishg.kwall.scheduler.ScheduleStore
|
||||||
|
import com.krrishg.kwall.ui.components.AppIcon
|
||||||
|
import com.krrishg.kwall.ui.components.AppIconPreloader
|
||||||
|
import com.krrishg.kwall.ui.components.SystemBadge
|
||||||
|
import com.krrishg.kwall.ui.components.WorkBadge
|
||||||
|
import com.krrishg.kwall.ui.components.blockabilityReason
|
||||||
|
import com.krrishg.kwall.ui.detail.AppDetailRoute
|
||||||
|
import com.krrishg.kwall.ui.detail.AppDetailViewModel
|
||||||
|
import com.krrishg.kwall.ui.profiles.ProfilesDialog
|
||||||
|
import com.krrishg.kwall.ui.schedules.SchedulesRoute
|
||||||
|
import com.krrishg.kwall.ui.schedules.SchedulesViewModel
|
||||||
|
import com.krrishg.kwall.ui.settings.AppSettingsDialog
|
||||||
|
import com.krrishg.kwall.ui.theme.KwallTheme
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppListRoute(
|
||||||
|
viewModel: AppListViewModel,
|
||||||
|
detailViewModelFactory: ViewModelProvider.Factory,
|
||||||
|
appLockEnabled: Boolean,
|
||||||
|
onAppLockEnabledChange: (Boolean) -> Unit,
|
||||||
|
autoBlockNewApps: Boolean,
|
||||||
|
onAutoBlockNewAppsChange: (Boolean) -> Unit,
|
||||||
|
scheduleStore: ScheduleStore,
|
||||||
|
scheduleAlarms: ScheduleAlarms,
|
||||||
|
firewallController: FirewallController,
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val context = LocalContext.current
|
||||||
|
var showSchedules by rememberSaveable { mutableStateOf(false) }
|
||||||
|
if (showSchedules) {
|
||||||
|
val schedulesFactory = remember(scheduleStore, scheduleAlarms, firewallController) {
|
||||||
|
SchedulesViewModel.factory(
|
||||||
|
scheduleStore = scheduleStore,
|
||||||
|
firewallController = firewallController,
|
||||||
|
alarms = scheduleAlarms,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SchedulesRoute(
|
||||||
|
viewModel = composeViewModel<SchedulesViewModel>(factory = schedulesFactory),
|
||||||
|
onBack = { showSchedules = false },
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val selected = uiState.selectedApp
|
||||||
|
if (selected != null) {
|
||||||
|
BackHandler(onBack = viewModel::clearSelection)
|
||||||
|
AppDetailRoute(
|
||||||
|
app = selected.app,
|
||||||
|
blockability = selected.blockability,
|
||||||
|
warnAllProfiles = selected.warnAllProfiles,
|
||||||
|
viewModel = composeViewModel<AppDetailViewModel>(factory = detailViewModelFactory),
|
||||||
|
onBack = viewModel::clearSelection,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
AppListScreen(
|
||||||
|
uiState = uiState,
|
||||||
|
onShowSystemAppsChange = viewModel::setShowSystemApps,
|
||||||
|
onFilterChange = viewModel::setFilter,
|
||||||
|
onSearchQueryChange = viewModel::setSearchQuery,
|
||||||
|
onRefresh = viewModel::refresh,
|
||||||
|
onBlockedChange = viewModel::setBlocked,
|
||||||
|
onFirewallEnabledChange = viewModel::setFirewallEnabled,
|
||||||
|
onGrantPermission = viewModel::requestFirewallPermission,
|
||||||
|
onRetryAvailability = viewModel::refreshFirewallAvailability,
|
||||||
|
onInstallShizuku = { openShizukuStore(context) },
|
||||||
|
onOpenShizuku = { openShizuku(context) },
|
||||||
|
onAppClick = viewModel::selectApp,
|
||||||
|
onOpenSchedules = { showSchedules = true },
|
||||||
|
appLockEnabled = appLockEnabled,
|
||||||
|
onAppLockEnabledChange = onAppLockEnabledChange,
|
||||||
|
autoBlockNewApps = autoBlockNewApps,
|
||||||
|
onAutoBlockNewAppsChange = onAutoBlockNewAppsChange,
|
||||||
|
onSwitchProfile = viewModel::switchProfile,
|
||||||
|
onCreateProfile = viewModel::createProfile,
|
||||||
|
onRenameProfile = viewModel::renameProfile,
|
||||||
|
onDuplicateProfile = viewModel::duplicateProfile,
|
||||||
|
onDeleteProfile = viewModel::deleteProfile,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AppListScreen(
|
||||||
|
uiState: AppListUiState,
|
||||||
|
onShowSystemAppsChange: (Boolean) -> Unit,
|
||||||
|
onFilterChange: (AppListFilter) -> Unit,
|
||||||
|
onSearchQueryChange: (String) -> Unit,
|
||||||
|
onRefresh: () -> Unit,
|
||||||
|
onBlockedChange: (InstalledApp, Boolean) -> Unit,
|
||||||
|
onFirewallEnabledChange: (Boolean) -> Unit,
|
||||||
|
onGrantPermission: () -> Unit,
|
||||||
|
onRetryAvailability: () -> Unit,
|
||||||
|
onInstallShizuku: () -> Unit,
|
||||||
|
onOpenShizuku: () -> Unit,
|
||||||
|
onAppClick: (AppRowUiState) -> Unit,
|
||||||
|
onOpenSchedules: () -> Unit,
|
||||||
|
appLockEnabled: Boolean,
|
||||||
|
onAppLockEnabledChange: (Boolean) -> Unit,
|
||||||
|
autoBlockNewApps: Boolean,
|
||||||
|
onAutoBlockNewAppsChange: (Boolean) -> Unit,
|
||||||
|
onSwitchProfile: (String) -> Unit,
|
||||||
|
onCreateProfile: (String) -> Unit,
|
||||||
|
onRenameProfile: (String, String) -> Unit,
|
||||||
|
onDuplicateProfile: (String, String) -> Unit,
|
||||||
|
onDeleteProfile: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var showSettingsDialog by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var showFirewallInfoDialog by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var showProfilesDialog by rememberSaveable { mutableStateOf(false) }
|
||||||
|
val firewallReady = uiState.firewall.availability is FirewallAvailability.Ready
|
||||||
|
val firewallToggleDescription = stringResource(R.string.firewall_title)
|
||||||
|
val activeProfileName = uiState.firewall.activeProfile?.name
|
||||||
|
?: stringResource(R.string.profile_default_name)
|
||||||
|
val profileSelectorDescription = stringResource(
|
||||||
|
R.string.profile_switch_description,
|
||||||
|
activeProfileName,
|
||||||
|
)
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable { showProfilesDialog = true }
|
||||||
|
.semantics { contentDescription = profileSelectorDescription },
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.apps_title))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = activeProfileName,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.ArrowDropDown,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = onOpenSchedules) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.DateRange,
|
||||||
|
contentDescription = stringResource(R.string.schedules_open),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = { showFirewallInfoDialog = true }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Info,
|
||||||
|
contentDescription = stringResource(R.string.firewall_info_open),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = uiState.firewall.enabled,
|
||||||
|
onCheckedChange = onFirewallEnabledChange,
|
||||||
|
enabled = firewallReady && !uiState.firewall.isApplying,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 8.dp)
|
||||||
|
.semantics { contentDescription = firewallToggleDescription },
|
||||||
|
)
|
||||||
|
IconButton(onClick = { showSettingsDialog = true }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Settings,
|
||||||
|
contentDescription = stringResource(R.string.settings_open),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onRefresh, enabled = !uiState.isLoading) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Refresh,
|
||||||
|
contentDescription = stringResource(R.string.refresh),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { contentPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(contentPadding)
|
||||||
|
.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
AppSearchField(
|
||||||
|
query = uiState.searchQuery,
|
||||||
|
onQueryChange = onSearchQueryChange,
|
||||||
|
)
|
||||||
|
AppFilterRow(
|
||||||
|
selected = uiState.filter,
|
||||||
|
onFilterChange = onFilterChange,
|
||||||
|
)
|
||||||
|
SystemAppsToggle(
|
||||||
|
checked = uiState.showSystemApps,
|
||||||
|
onCheckedChange = onShowSystemAppsChange,
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
AppsContent(
|
||||||
|
uiState = uiState,
|
||||||
|
onRetry = onRefresh,
|
||||||
|
onBlockedChange = onBlockedChange,
|
||||||
|
onAppClick = onAppClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showSettingsDialog) {
|
||||||
|
AppSettingsDialog(
|
||||||
|
appLockEnabled = appLockEnabled,
|
||||||
|
autoBlockNewApps = autoBlockNewApps,
|
||||||
|
onAppLockEnabledChange = onAppLockEnabledChange,
|
||||||
|
onAutoBlockNewAppsChange = onAutoBlockNewAppsChange,
|
||||||
|
onDismiss = { showSettingsDialog = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showFirewallInfoDialog) {
|
||||||
|
FirewallInfoDialog(
|
||||||
|
firewall = uiState.firewall,
|
||||||
|
onGrantPermission = onGrantPermission,
|
||||||
|
onRetryAvailability = onRetryAvailability,
|
||||||
|
onInstallShizuku = onInstallShizuku,
|
||||||
|
onOpenShizuku = onOpenShizuku,
|
||||||
|
onDismiss = { showFirewallInfoDialog = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showProfilesDialog) {
|
||||||
|
ProfilesDialog(
|
||||||
|
profiles = uiState.firewall.profiles,
|
||||||
|
activeProfileId = uiState.firewall.activeProfileId,
|
||||||
|
isBusy = uiState.firewall.isApplying,
|
||||||
|
errorMessage = uiState.firewall.errorMessage,
|
||||||
|
onSwitch = onSwitchProfile,
|
||||||
|
onCreate = onCreateProfile,
|
||||||
|
onRename = onRenameProfile,
|
||||||
|
onDuplicate = onDuplicateProfile,
|
||||||
|
onDelete = onDeleteProfile,
|
||||||
|
onDismiss = { showProfilesDialog = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AppSearchField(
|
||||||
|
query: String,
|
||||||
|
onQueryChange: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = onQueryChange,
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 20.dp, vertical = 4.dp),
|
||||||
|
placeholder = { Text(stringResource(R.string.search_apps)) },
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Search,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingIcon = if (query.isNotEmpty()) {
|
||||||
|
{
|
||||||
|
IconButton(onClick = { onQueryChange("") }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Close,
|
||||||
|
contentDescription = stringResource(R.string.clear_search),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AppFilterRow(
|
||||||
|
selected: AppListFilter,
|
||||||
|
onFilterChange: (AppListFilter) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 20.dp, vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
AppListFilter.entries.forEach { filter ->
|
||||||
|
FilterChip(
|
||||||
|
selected = selected == filter,
|
||||||
|
onClick = { onFilterChange(filter) },
|
||||||
|
label = { Text(stringResource(filter.labelRes)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val AppListFilter.labelRes: Int
|
||||||
|
get() = when (this) {
|
||||||
|
AppListFilter.ALL -> R.string.filter_all
|
||||||
|
AppListFilter.BLOCKED -> R.string.filter_blocked
|
||||||
|
AppListFilter.UNBLOCKED -> R.string.filter_unblocked
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SystemAppsToggle(
|
||||||
|
checked: Boolean,
|
||||||
|
onCheckedChange: (Boolean) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.toggleable(
|
||||||
|
value = checked,
|
||||||
|
onValueChange = onCheckedChange,
|
||||||
|
role = Role.Switch,
|
||||||
|
)
|
||||||
|
.padding(horizontal = 20.dp, vertical = 2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.show_system_apps),
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Switch(
|
||||||
|
checked = checked,
|
||||||
|
onCheckedChange = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AppsContent(
|
||||||
|
uiState: AppListUiState,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
onBlockedChange: (InstalledApp, Boolean) -> Unit,
|
||||||
|
onAppClick: (AppRowUiState) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
uiState.isLoading -> LoadingState(modifier)
|
||||||
|
uiState.errorMessage != null -> ErrorState(
|
||||||
|
message = uiState.errorMessage,
|
||||||
|
onRetry = onRetry,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
uiState.apps.isEmpty() -> EmptyState(
|
||||||
|
showSystemApps = uiState.showSystemApps,
|
||||||
|
isFiltering = uiState.filter != AppListFilter.ALL || uiState.searchQuery.isNotBlank(),
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
else -> AppsList(
|
||||||
|
rows = uiState.apps,
|
||||||
|
togglesEnabled = uiState.firewall.availability is FirewallAvailability.Ready,
|
||||||
|
isApplying = uiState.firewall.isApplying,
|
||||||
|
onBlockedChange = onBlockedChange,
|
||||||
|
onAppClick = onAppClick,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AppsList(
|
||||||
|
rows: List<AppRowUiState>,
|
||||||
|
togglesEnabled: Boolean,
|
||||||
|
isApplying: Boolean,
|
||||||
|
onBlockedChange: (InstalledApp, Boolean) -> Unit,
|
||||||
|
onAppClick: (AppRowUiState) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val apps = remember(rows) { rows.map { it.app } }
|
||||||
|
AppIconPreloader(apps = apps)
|
||||||
|
Column(modifier = modifier.fillMaxSize()) {
|
||||||
|
Text(
|
||||||
|
text = pluralStringResource(R.plurals.apps_count, rows.size, rows.size),
|
||||||
|
modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
itemsIndexed(
|
||||||
|
items = rows,
|
||||||
|
key = { _, row -> row.listKey },
|
||||||
|
contentType = { _, _ -> "app" },
|
||||||
|
) { index, row ->
|
||||||
|
AppRow(
|
||||||
|
row = row,
|
||||||
|
togglesEnabled = togglesEnabled && !isApplying,
|
||||||
|
onBlockedChange = onBlockedChange,
|
||||||
|
onAppClick = onAppClick,
|
||||||
|
)
|
||||||
|
if (index < rows.lastIndex) {
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(start = 76.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AppRow(
|
||||||
|
row: AppRowUiState,
|
||||||
|
togglesEnabled: Boolean,
|
||||||
|
onBlockedChange: (InstalledApp, Boolean) -> Unit,
|
||||||
|
onAppClick: (AppRowUiState) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val app = row.app
|
||||||
|
val blockable = row.blockability == AppBlockability.BLOCKABLE
|
||||||
|
val reason = blockabilityReason(row.blockability)
|
||||||
|
?: if (row.warnAllProfiles) {
|
||||||
|
stringResource(R.string.firewall_blocked_warning_all_profiles)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onAppClick(row) }
|
||||||
|
.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
AppIcon(app = app)
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = app.label,
|
||||||
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (app.isWorkProfile) {
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
WorkBadge()
|
||||||
|
}
|
||||||
|
if (app.isSystem) {
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
SystemBadge()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = app.packageName,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (reason != null) {
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = reason,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (row.warnAllProfiles) {
|
||||||
|
MaterialTheme.colorScheme.error
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
key(app.key) {
|
||||||
|
Switch(
|
||||||
|
checked = row.isBlocked,
|
||||||
|
onCheckedChange = { checked -> onBlockedChange(app, checked) },
|
||||||
|
enabled = togglesEnabled && blockable,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(start = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LoadingState(modifier: Modifier = Modifier) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.loading_apps),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ErrorState(
|
||||||
|
message: String?,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(32.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.load_error),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
if (!message.isNullOrBlank()) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
Button(onClick = onRetry) {
|
||||||
|
Text(stringResource(R.string.retry))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmptyState(
|
||||||
|
showSystemApps: Boolean,
|
||||||
|
isFiltering: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val showAllAppsMessage = showSystemApps || isFiltering
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(32.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
if (showAllAppsMessage) {
|
||||||
|
R.string.all_apps_empty_title
|
||||||
|
} else {
|
||||||
|
R.string.user_apps_empty_title
|
||||||
|
},
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
if (showAllAppsMessage) {
|
||||||
|
R.string.all_apps_empty_description
|
||||||
|
} else {
|
||||||
|
R.string.user_apps_empty_description
|
||||||
|
},
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openShizukuStore(context: Context) {
|
||||||
|
val storeIntent = Intent(
|
||||||
|
Intent.ACTION_VIEW,
|
||||||
|
"market://details?id=$SHIZUKU_PACKAGE".toUri(),
|
||||||
|
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
if (runCatching { context.startActivity(storeIntent) }.isSuccess) return
|
||||||
|
|
||||||
|
val webIntent = Intent(
|
||||||
|
Intent.ACTION_VIEW,
|
||||||
|
"https://play.google.com/store/apps/details?id=$SHIZUKU_PACKAGE".toUri(),
|
||||||
|
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
runCatching { context.startActivity(webIntent) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openShizuku(context: Context) {
|
||||||
|
val launchIntent = context.packageManager.getLaunchIntentForPackage(SHIZUKU_PACKAGE)
|
||||||
|
if (launchIntent != null) {
|
||||||
|
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
runCatching { context.startActivity(launchIntent) }
|
||||||
|
.onFailure { openShizukuStore(context) }
|
||||||
|
} else {
|
||||||
|
openShizukuStore(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun AppListScreenPreview() {
|
||||||
|
KwallTheme {
|
||||||
|
AppListScreen(
|
||||||
|
uiState = AppListUiState(
|
||||||
|
apps = listOf(
|
||||||
|
AppRowUiState(
|
||||||
|
app = InstalledApp(
|
||||||
|
packageName = "com.example.notes",
|
||||||
|
label = "Notes",
|
||||||
|
isSystem = false,
|
||||||
|
icon = null,
|
||||||
|
uid = 10123,
|
||||||
|
userId = 0,
|
||||||
|
isWorkProfile = false,
|
||||||
|
hasInternetPermission = true,
|
||||||
|
),
|
||||||
|
isBlocked = true,
|
||||||
|
blockability = AppBlockability.BLOCKABLE,
|
||||||
|
warnAllProfiles = false,
|
||||||
|
),
|
||||||
|
AppRowUiState(
|
||||||
|
app = InstalledApp(
|
||||||
|
packageName = "com.example.chat",
|
||||||
|
label = "Chat",
|
||||||
|
isSystem = false,
|
||||||
|
icon = null,
|
||||||
|
uid = 1010123,
|
||||||
|
userId = 10,
|
||||||
|
isWorkProfile = true,
|
||||||
|
hasInternetPermission = true,
|
||||||
|
),
|
||||||
|
isBlocked = false,
|
||||||
|
blockability = AppBlockability.BLOCKABLE,
|
||||||
|
warnAllProfiles = false,
|
||||||
|
),
|
||||||
|
AppRowUiState(
|
||||||
|
app = InstalledApp(
|
||||||
|
packageName = "com.android.systemui",
|
||||||
|
label = "System UI",
|
||||||
|
isSystem = true,
|
||||||
|
icon = null,
|
||||||
|
uid = 1000,
|
||||||
|
userId = 0,
|
||||||
|
isWorkProfile = false,
|
||||||
|
hasInternetPermission = true,
|
||||||
|
),
|
||||||
|
isBlocked = false,
|
||||||
|
blockability = AppBlockability.SYSTEM_UID,
|
||||||
|
warnAllProfiles = false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
isLoading = false,
|
||||||
|
firewall = FirewallUiState(
|
||||||
|
enabled = true,
|
||||||
|
availability = FirewallAvailability.Ready(
|
||||||
|
rulesTransport = RulesTransport.BINDER,
|
||||||
|
chainTransport = ChainTransport.SHELL,
|
||||||
|
),
|
||||||
|
blockedCount = 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onShowSystemAppsChange = {},
|
||||||
|
onFilterChange = {},
|
||||||
|
onSearchQueryChange = {},
|
||||||
|
onRefresh = {},
|
||||||
|
onBlockedChange = { _, _ -> },
|
||||||
|
onFirewallEnabledChange = {},
|
||||||
|
onGrantPermission = {},
|
||||||
|
onRetryAvailability = {},
|
||||||
|
onInstallShizuku = {},
|
||||||
|
onOpenShizuku = {},
|
||||||
|
onAppClick = {},
|
||||||
|
onOpenSchedules = {},
|
||||||
|
appLockEnabled = true,
|
||||||
|
onAppLockEnabledChange = {},
|
||||||
|
autoBlockNewApps = false,
|
||||||
|
onAutoBlockNewAppsChange = {},
|
||||||
|
onSwitchProfile = {},
|
||||||
|
onCreateProfile = {},
|
||||||
|
onRenameProfile = { _, _ -> },
|
||||||
|
onDuplicateProfile = { _, _ -> },
|
||||||
|
onDeleteProfile = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
package com.krrishg.kwall.ui.apps
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.lifecycle.viewmodel.initializer
|
||||||
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
|
import com.krrishg.kwall.AppChangeNotifier
|
||||||
|
import com.krrishg.kwall.data.AppRepository
|
||||||
|
import com.krrishg.kwall.data.InstalledApp
|
||||||
|
import com.krrishg.kwall.firewall.AppBlockability
|
||||||
|
import com.krrishg.kwall.firewall.AppKey
|
||||||
|
import com.krrishg.kwall.firewall.FIREWALL_PACKAGES
|
||||||
|
import com.krrishg.kwall.firewall.FirewallAvailability
|
||||||
|
import com.krrishg.kwall.firewall.FirewallController
|
||||||
|
import com.krrishg.kwall.firewall.FirewallDriftState
|
||||||
|
import com.krrishg.kwall.firewall.RuleProfile
|
||||||
|
import com.krrishg.kwall.firewall.RulesTransport
|
||||||
|
import com.krrishg.kwall.firewall.blockabilityFor
|
||||||
|
import com.krrishg.kwall.scheduler.ScheduleStore
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
data class AppRowUiState(
|
||||||
|
val app: InstalledApp,
|
||||||
|
val isBlocked: Boolean,
|
||||||
|
val blockability: AppBlockability,
|
||||||
|
val warnAllProfiles: Boolean,
|
||||||
|
) {
|
||||||
|
val listKey: String = app.key.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class AppListFilter {
|
||||||
|
ALL,
|
||||||
|
BLOCKED,
|
||||||
|
UNBLOCKED,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class FirewallUiState(
|
||||||
|
val enabled: Boolean = false,
|
||||||
|
val availability: FirewallAvailability = FirewallAvailability.Checking,
|
||||||
|
val blockedCount: Int = 0,
|
||||||
|
val isApplying: Boolean = false,
|
||||||
|
val errorMessage: String? = null,
|
||||||
|
val drift: FirewallDriftState = FirewallDriftState(),
|
||||||
|
val profiles: List<RuleProfile> = emptyList(),
|
||||||
|
val activeProfileId: String = "",
|
||||||
|
) {
|
||||||
|
val activeProfile: RuleProfile?
|
||||||
|
get() = profiles.firstOrNull { it.id == activeProfileId } ?: profiles.firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class FirewallSnapshot(
|
||||||
|
val enabled: Boolean,
|
||||||
|
val blockedApps: Set<AppKey>,
|
||||||
|
val availability: FirewallAvailability,
|
||||||
|
val error: String?,
|
||||||
|
val drift: FirewallDriftState,
|
||||||
|
val profiles: List<RuleProfile>,
|
||||||
|
val activeProfileId: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AppListUiState(
|
||||||
|
val apps: List<AppRowUiState> = emptyList(),
|
||||||
|
val showSystemApps: Boolean = false,
|
||||||
|
val filter: AppListFilter = AppListFilter.ALL,
|
||||||
|
val searchQuery: String = "",
|
||||||
|
val isLoading: Boolean = true,
|
||||||
|
val errorMessage: String? = null,
|
||||||
|
val firewall: FirewallUiState = FirewallUiState(),
|
||||||
|
val selectedApp: AppRowUiState? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
class AppListViewModel(
|
||||||
|
private val repository: AppRepository,
|
||||||
|
private val firewallController: FirewallController,
|
||||||
|
private val appChangeNotifier: AppChangeNotifier,
|
||||||
|
private val scheduleStore: ScheduleStore,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private var allApps: List<InstalledApp> = emptyList()
|
||||||
|
private var uidCounts: Map<Int, Int> = emptyMap()
|
||||||
|
private var packageCounts: Map<String, Int> = emptyMap()
|
||||||
|
private var loadJob: Job? = null
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(AppListUiState())
|
||||||
|
val uiState: StateFlow<AppListUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
observeFirewallState()
|
||||||
|
observeAppChanges()
|
||||||
|
viewModelScope.launch { firewallController.refreshAvailability() }
|
||||||
|
loadApps()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setShowSystemApps(show: Boolean) {
|
||||||
|
if (_uiState.value.showSystemApps == show) return
|
||||||
|
_uiState.update { current ->
|
||||||
|
val updated = current.copy(showSystemApps = show)
|
||||||
|
updated.copy(apps = buildRows(updated))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setFilter(filter: AppListFilter) {
|
||||||
|
if (_uiState.value.filter == filter) return
|
||||||
|
_uiState.update { current ->
|
||||||
|
val updated = current.copy(filter = filter)
|
||||||
|
updated.copy(apps = buildRows(updated))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSearchQuery(query: String) {
|
||||||
|
if (_uiState.value.searchQuery == query) return
|
||||||
|
_uiState.update { current ->
|
||||||
|
val updated = current.copy(searchQuery = query)
|
||||||
|
updated.copy(apps = buildRows(updated))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectApp(row: AppRowUiState) {
|
||||||
|
_uiState.update { it.copy(selectedApp = row) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearSelection() {
|
||||||
|
_uiState.update { it.copy(selectedApp = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
loadApps()
|
||||||
|
viewModelScope.launch {
|
||||||
|
firewallController.refreshAvailability()
|
||||||
|
firewallController.restore()
|
||||||
|
firewallController.checkForDrift()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setBlocked(app: InstalledApp, blocked: Boolean) {
|
||||||
|
if (_uiState.value.firewall.isApplying) return
|
||||||
|
_uiState.update { current ->
|
||||||
|
current.copy(
|
||||||
|
firewall = current.firewall.copy(isApplying = true, errorMessage = null),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
firewallController.setBlocked(app, blocked)
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (error: Exception) {
|
||||||
|
_uiState.update { current ->
|
||||||
|
current.copy(
|
||||||
|
firewall = current.firewall.copy(
|
||||||
|
errorMessage = error.message ?: "Could not update the firewall",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_uiState.update { current ->
|
||||||
|
current.copy(firewall = current.firewall.copy(isApplying = false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setFirewallEnabled(enabled: Boolean) {
|
||||||
|
if (_uiState.value.firewall.isApplying) return
|
||||||
|
_uiState.update { current ->
|
||||||
|
current.copy(
|
||||||
|
firewall = current.firewall.copy(isApplying = true, errorMessage = null),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
firewallController.setEnabled(enabled)
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (error: Exception) {
|
||||||
|
_uiState.update { current ->
|
||||||
|
current.copy(
|
||||||
|
firewall = current.firewall.copy(
|
||||||
|
errorMessage = error.message ?: "Could not update the firewall",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_uiState.update { current ->
|
||||||
|
current.copy(firewall = current.firewall.copy(isApplying = false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requestFirewallPermission() {
|
||||||
|
firewallController.requestPermission()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshFirewallAvailability() {
|
||||||
|
viewModelScope.launch { firewallController.refreshAvailability() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun switchProfile(id: String) {
|
||||||
|
applyProfileOperation { firewallController.switchProfile(id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createProfile(name: String) {
|
||||||
|
applyProfileOperation { firewallController.createProfile(name) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun renameProfile(id: String, name: String) {
|
||||||
|
applyProfileOperation { firewallController.renameProfile(id, name) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun duplicateProfile(id: String, name: String) {
|
||||||
|
applyProfileOperation { firewallController.duplicateProfile(id, name) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteProfile(id: String) {
|
||||||
|
if (_uiState.value.firewall.isApplying) return
|
||||||
|
_uiState.update { current ->
|
||||||
|
current.copy(
|
||||||
|
firewall = current.firewall.copy(isApplying = true, errorMessage = null),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val usedBySchedule = scheduleStore.schedules.first().any { schedule ->
|
||||||
|
schedule.profileId == id || schedule.fallbackProfileId == id
|
||||||
|
}
|
||||||
|
if (usedBySchedule) {
|
||||||
|
_uiState.update { current ->
|
||||||
|
current.copy(
|
||||||
|
firewall = current.firewall.copy(
|
||||||
|
errorMessage = "This profile is used by a schedule",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
firewallController.deleteProfile(id)
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (error: Exception) {
|
||||||
|
_uiState.update { current ->
|
||||||
|
current.copy(
|
||||||
|
firewall = current.firewall.copy(
|
||||||
|
errorMessage = error.message ?: "Could not update profile",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_uiState.update { current ->
|
||||||
|
current.copy(firewall = current.firewall.copy(isApplying = false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyProfileOperation(operation: suspend () -> Unit) {
|
||||||
|
if (_uiState.value.firewall.isApplying) return
|
||||||
|
_uiState.update { current ->
|
||||||
|
current.copy(
|
||||||
|
firewall = current.firewall.copy(isApplying = true, errorMessage = null),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
operation()
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (error: Exception) {
|
||||||
|
_uiState.update { current ->
|
||||||
|
current.copy(
|
||||||
|
firewall = current.firewall.copy(
|
||||||
|
errorMessage = error.message ?: "Could not update profile",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_uiState.update { current ->
|
||||||
|
current.copy(firewall = current.firewall.copy(isApplying = false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeFirewallState() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
combine(
|
||||||
|
firewallController.state,
|
||||||
|
firewallController.availability,
|
||||||
|
firewallController.error,
|
||||||
|
firewallController.drift,
|
||||||
|
) { state, availability, error, drift ->
|
||||||
|
FirewallSnapshot(
|
||||||
|
enabled = state.enabled,
|
||||||
|
blockedApps = state.blockedApps,
|
||||||
|
availability = availability,
|
||||||
|
error = error,
|
||||||
|
drift = drift,
|
||||||
|
profiles = state.profiles,
|
||||||
|
activeProfileId = state.activeProfileId,
|
||||||
|
)
|
||||||
|
}.collect { snapshot ->
|
||||||
|
_uiState.update { current ->
|
||||||
|
val updated = current.copy(
|
||||||
|
firewall = current.firewall.copy(
|
||||||
|
enabled = snapshot.enabled,
|
||||||
|
availability = snapshot.availability,
|
||||||
|
blockedCount = snapshot.blockedApps.size,
|
||||||
|
errorMessage = snapshot.error,
|
||||||
|
drift = snapshot.drift,
|
||||||
|
profiles = snapshot.profiles,
|
||||||
|
activeProfileId = snapshot.activeProfileId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
updated.copy(apps = buildRows(updated))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeAppChanges() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
appChangeNotifier.events.collect {
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadApps() {
|
||||||
|
loadJob?.cancel()
|
||||||
|
loadJob = viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||||
|
try {
|
||||||
|
val apps = repository.loadInstalledApps()
|
||||||
|
allApps = apps
|
||||||
|
uidCounts = apps.groupingBy { it.uid }.eachCount()
|
||||||
|
packageCounts = apps.groupingBy { it.packageName }.eachCount()
|
||||||
|
_uiState.update { current ->
|
||||||
|
val updated = current.copy(isLoading = false)
|
||||||
|
updated.copy(apps = buildRows(updated))
|
||||||
|
}
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (error: Exception) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = error.message ?: "Could not load apps",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildRows(uiState: AppListUiState): List<AppRowUiState> {
|
||||||
|
val state = firewallController.state.value
|
||||||
|
val availability = firewallController.availability.value
|
||||||
|
val rulesTransport = (availability as? FirewallAvailability.Ready)?.rulesTransport
|
||||||
|
val query = uiState.searchQuery.trim()
|
||||||
|
return allApps
|
||||||
|
.filter { uiState.showSystemApps || !it.isSystem }
|
||||||
|
.map { app ->
|
||||||
|
val assessment = blockabilityFor(
|
||||||
|
app = app,
|
||||||
|
uidCounts = uidCounts,
|
||||||
|
packageCounts = packageCounts,
|
||||||
|
rulesTransport = rulesTransport,
|
||||||
|
firewallPackages = FIREWALL_PACKAGES,
|
||||||
|
)
|
||||||
|
AppRowUiState(
|
||||||
|
app = app,
|
||||||
|
isBlocked = state.blockedApps.contains(app.key),
|
||||||
|
blockability = assessment.blockability,
|
||||||
|
warnAllProfiles = assessment.warnAllProfiles,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.filter { row -> matchesFilter(row, uiState.filter) }
|
||||||
|
.filter { row -> matchesQuery(row, query) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun matchesFilter(row: AppRowUiState, filter: AppListFilter): Boolean = when (filter) {
|
||||||
|
AppListFilter.ALL -> true
|
||||||
|
AppListFilter.BLOCKED -> row.isBlocked
|
||||||
|
AppListFilter.UNBLOCKED -> !row.isBlocked
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun matchesQuery(row: AppRowUiState, query: String): Boolean =
|
||||||
|
query.isEmpty() || row.app.label.contains(query, ignoreCase = true)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun factory(
|
||||||
|
repository: AppRepository,
|
||||||
|
firewallController: FirewallController,
|
||||||
|
appChangeNotifier: AppChangeNotifier,
|
||||||
|
scheduleStore: ScheduleStore,
|
||||||
|
): ViewModelProvider.Factory = viewModelFactory {
|
||||||
|
initializer {
|
||||||
|
AppListViewModel(
|
||||||
|
repository = repository,
|
||||||
|
firewallController = firewallController,
|
||||||
|
appChangeNotifier = appChangeNotifier,
|
||||||
|
scheduleStore = scheduleStore,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package com.krrishg.kwall.ui.apps
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.krrishg.kwall.R
|
||||||
|
import com.krrishg.kwall.firewall.FirewallAvailability
|
||||||
|
import com.krrishg.kwall.firewall.FirewallDriftStatus
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FirewallInfoDialog(
|
||||||
|
firewall: FirewallUiState,
|
||||||
|
onGrantPermission: () -> Unit,
|
||||||
|
onRetryAvailability: () -> Unit,
|
||||||
|
onInstallShizuku: () -> Unit,
|
||||||
|
onOpenShizuku: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val availability = firewall.availability
|
||||||
|
val ready = availability is FirewallAvailability.Ready
|
||||||
|
val status = when (availability) {
|
||||||
|
FirewallAvailability.Checking -> stringResource(R.string.firewall_state_checking)
|
||||||
|
FirewallAvailability.UnsupportedAndroid -> stringResource(R.string.firewall_state_android)
|
||||||
|
FirewallAvailability.ShizukuNotInstalled -> stringResource(R.string.firewall_state_shizuku_missing)
|
||||||
|
FirewallAvailability.ShizukuNotRunning -> stringResource(R.string.firewall_state_shizuku_stopped)
|
||||||
|
FirewallAvailability.PermissionDenied -> stringResource(R.string.firewall_state_permission)
|
||||||
|
FirewallAvailability.CommandUnavailable -> stringResource(R.string.firewall_state_unsupported)
|
||||||
|
is FirewallAvailability.Ready -> when {
|
||||||
|
!firewall.enabled -> stringResource(R.string.firewall_state_off)
|
||||||
|
firewall.blockedCount == 0 -> stringResource(R.string.firewall_state_no_apps)
|
||||||
|
else -> pluralStringResource(
|
||||||
|
R.plurals.firewall_blocked_apps,
|
||||||
|
firewall.blockedCount,
|
||||||
|
firewall.blockedCount,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val action = when (availability) {
|
||||||
|
FirewallAvailability.ShizukuNotInstalled ->
|
||||||
|
R.string.firewall_action_install_shizuku to onInstallShizuku
|
||||||
|
FirewallAvailability.ShizukuNotRunning ->
|
||||||
|
R.string.firewall_action_open_shizuku to onOpenShizuku
|
||||||
|
FirewallAvailability.PermissionDenied ->
|
||||||
|
R.string.firewall_action_grant to onGrantPermission
|
||||||
|
FirewallAvailability.CommandUnavailable ->
|
||||||
|
R.string.retry to onRetryAvailability
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.firewall_title)) },
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(text = status, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
if (ready) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.firewall_description),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (ready && firewall.drift.status != FirewallDriftStatus.IDLE) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(firewall.drift.status.labelRes),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = when (firewall.drift.status) {
|
||||||
|
FirewallDriftStatus.DRIFT_DETECTED -> MaterialTheme.colorScheme.error
|
||||||
|
FirewallDriftStatus.REPAIRED -> MaterialTheme.colorScheme.primary
|
||||||
|
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (ready && (firewall.enabled || firewall.blockedCount > 0)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.firewall_drift_automatic),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (ready && firewall.enabled && firewall.blockedCount > 0) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.firewall_reset_notice),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.firewall_uninstall_notice),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (ready && !firewall.enabled && firewall.blockedCount > 0) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.firewall_selection_paused),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!ready && firewall.enabled && firewall.blockedCount > 0) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.firewall_rules_persist),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
firewall.errorMessage?.let { message ->
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.close))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = action?.let { (labelRes, onClick) ->
|
||||||
|
{
|
||||||
|
TextButton(onClick = onClick) {
|
||||||
|
Text(stringResource(labelRes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val FirewallDriftStatus.labelRes: Int
|
||||||
|
get() = when (this) {
|
||||||
|
FirewallDriftStatus.IDLE, FirewallDriftStatus.CHECKING ->
|
||||||
|
R.string.firewall_drift_checking
|
||||||
|
FirewallDriftStatus.IN_SYNC -> R.string.firewall_drift_in_sync
|
||||||
|
FirewallDriftStatus.REPAIRED -> R.string.firewall_drift_repaired
|
||||||
|
FirewallDriftStatus.DRIFT_DETECTED -> R.string.firewall_drift_detected
|
||||||
|
FirewallDriftStatus.UNAVAILABLE -> R.string.firewall_drift_unavailable
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package com.krrishg.kwall.ui.components
|
||||||
|
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.util.LruCache
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
|
import com.krrishg.kwall.data.InstalledApp
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppIcon(
|
||||||
|
app: InstalledApp,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
iconSize: Dp = 44.dp,
|
||||||
|
) {
|
||||||
|
val iconSizePx = with(LocalDensity.current) { iconSize.roundToPx() }
|
||||||
|
val cacheKey = remember(app.key, app.icon, iconSizePx) {
|
||||||
|
AppIconCache.key(app, iconSizePx)
|
||||||
|
}
|
||||||
|
val bitmap by produceState(
|
||||||
|
initialValue = AppIconCache.get(cacheKey),
|
||||||
|
key1 = cacheKey,
|
||||||
|
) {
|
||||||
|
if (value == null) {
|
||||||
|
value = AppIconCache.load(cacheKey, app.icon, iconSizePx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val iconModifier = modifier
|
||||||
|
.size(iconSize)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
|
||||||
|
val iconBitmap = bitmap
|
||||||
|
if (iconBitmap != null) {
|
||||||
|
Image(
|
||||||
|
bitmap = iconBitmap,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = iconModifier,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier = iconModifier.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = app.label.trim().take(1).uppercase().ifEmpty { "?" },
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppIconPreloader(
|
||||||
|
apps: List<InstalledApp>,
|
||||||
|
iconSize: Dp = 44.dp,
|
||||||
|
) {
|
||||||
|
val iconSizePx = with(LocalDensity.current) { iconSize.roundToPx() }
|
||||||
|
LaunchedEffect(apps, iconSizePx) {
|
||||||
|
apps.forEach { app ->
|
||||||
|
AppIconCache.load(AppIconCache.key(app, iconSizePx), app.icon, iconSizePx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private object AppIconCache {
|
||||||
|
private const val MAX_BYTES = 24 * 1024 * 1024
|
||||||
|
|
||||||
|
private val decodeDispatcher = Dispatchers.Default.limitedParallelism(2)
|
||||||
|
|
||||||
|
private val cache = object : LruCache<String, ImageBitmap>(MAX_BYTES) {
|
||||||
|
override fun sizeOf(key: String, value: ImageBitmap): Int = value.width * value.height * 4
|
||||||
|
}
|
||||||
|
|
||||||
|
fun key(app: InstalledApp, sizePx: Int): String =
|
||||||
|
"${app.key}:${System.identityHashCode(app.icon)}:$sizePx"
|
||||||
|
|
||||||
|
fun get(key: String): ImageBitmap? = cache.get(key)
|
||||||
|
|
||||||
|
suspend fun load(key: String, icon: Drawable?, sizePx: Int): ImageBitmap? {
|
||||||
|
if (icon == null) return null
|
||||||
|
cache.get(key)?.let { return it }
|
||||||
|
return withContext(decodeDispatcher) {
|
||||||
|
cache.get(key) ?: runCatching {
|
||||||
|
icon.toBitmap(sizePx, sizePx).asImageBitmap()
|
||||||
|
}.getOrNull()?.also { cache.put(key, it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.krrishg.kwall.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.krrishg.kwall.R
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SystemBadge(modifier: Modifier = Modifier) {
|
||||||
|
Badge(
|
||||||
|
text = stringResource(R.string.system_app_badge),
|
||||||
|
modifier = modifier,
|
||||||
|
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun WorkBadge(modifier: Modifier = Modifier) {
|
||||||
|
Badge(
|
||||||
|
text = stringResource(R.string.work_profile_badge),
|
||||||
|
modifier = modifier,
|
||||||
|
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Badge(
|
||||||
|
text: String,
|
||||||
|
containerColor: Color,
|
||||||
|
contentColor: Color,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = modifier,
|
||||||
|
shape = RoundedCornerShape(6.dp),
|
||||||
|
color = containerColor,
|
||||||
|
contentColor = contentColor,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.krrishg.kwall.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import com.krrishg.kwall.R
|
||||||
|
import com.krrishg.kwall.firewall.AppBlockability
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun blockabilityReason(blockability: AppBlockability): String? = when (blockability) {
|
||||||
|
AppBlockability.BLOCKABLE -> null
|
||||||
|
AppBlockability.SYSTEM_UID -> stringResource(R.string.firewall_blocked_reason_system)
|
||||||
|
AppBlockability.SHARED_UID -> stringResource(R.string.firewall_blocked_reason_shared)
|
||||||
|
AppBlockability.NO_INTERNET -> stringResource(R.string.firewall_blocked_reason_no_internet)
|
||||||
|
AppBlockability.FIREWALL_APP -> stringResource(R.string.firewall_blocked_reason_firewall)
|
||||||
|
AppBlockability.WORK_PROFILE_ONLY -> stringResource(R.string.firewall_blocked_reason_work_only)
|
||||||
|
AppBlockability.UNRESOLVED -> stringResource(R.string.firewall_blocked_reason_unresolved)
|
||||||
|
}
|
||||||
@@ -0,0 +1,652 @@
|
|||||||
|
package com.krrishg.kwall.ui.detail
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.text.format.Formatter
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
|
import com.krrishg.kwall.R
|
||||||
|
import com.krrishg.kwall.data.InstalledApp
|
||||||
|
import com.krrishg.kwall.firewall.AppBlockability
|
||||||
|
import com.krrishg.kwall.network.DataUsage
|
||||||
|
import com.krrishg.kwall.network.SocketEntry
|
||||||
|
import com.krrishg.kwall.network.SocketProtocol
|
||||||
|
import com.krrishg.kwall.network.TcpState
|
||||||
|
import com.krrishg.kwall.network.UsageRange
|
||||||
|
import com.krrishg.kwall.ui.components.AppIcon
|
||||||
|
import com.krrishg.kwall.ui.components.SystemBadge
|
||||||
|
import com.krrishg.kwall.ui.components.WorkBadge
|
||||||
|
import com.krrishg.kwall.ui.components.blockabilityReason
|
||||||
|
import com.krrishg.kwall.ui.theme.KwallTheme
|
||||||
|
import java.net.InetAddress
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppDetailRoute(
|
||||||
|
app: InstalledApp,
|
||||||
|
blockability: AppBlockability,
|
||||||
|
warnAllProfiles: Boolean,
|
||||||
|
viewModel: AppDetailViewModel,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val context = LocalContext.current
|
||||||
|
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||||
|
|
||||||
|
LaunchedEffect(app.key) { viewModel.load(app) }
|
||||||
|
LaunchedEffect(app.key, lifecycle) {
|
||||||
|
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||||
|
while (isActive) {
|
||||||
|
viewModel.refreshConnections()
|
||||||
|
delay(CONNECTION_POLL_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LifecycleResumeEffect(app.key) {
|
||||||
|
viewModel.refreshUsage()
|
||||||
|
onPauseOrDispose { }
|
||||||
|
}
|
||||||
|
|
||||||
|
AppDetailScreen(
|
||||||
|
app = app,
|
||||||
|
uiState = uiState,
|
||||||
|
blockability = blockability,
|
||||||
|
warnAllProfiles = warnAllProfiles,
|
||||||
|
onBack = onBack,
|
||||||
|
onBlockedChange = viewModel::setBlocked,
|
||||||
|
onUsageRangeChange = viewModel::selectUsageRange,
|
||||||
|
onGrantUsageAccess = { openUsageAccessSettings(context) },
|
||||||
|
onRefresh = {
|
||||||
|
viewModel.refreshUsage()
|
||||||
|
viewModel.refreshConnectionsNow()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AppDetailScreen(
|
||||||
|
app: InstalledApp,
|
||||||
|
uiState: AppDetailUiState,
|
||||||
|
blockability: AppBlockability,
|
||||||
|
warnAllProfiles: Boolean,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onBlockedChange: (Boolean) -> Unit,
|
||||||
|
onUsageRangeChange: (UsageRange) -> Unit,
|
||||||
|
onGrantUsageAccess: () -> Unit,
|
||||||
|
onRefresh: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(app.label) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.detail_back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = onRefresh) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Refresh,
|
||||||
|
contentDescription = stringResource(R.string.detail_refresh),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { contentPadding ->
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(contentPadding)
|
||||||
|
.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
item { AppHeader(app) }
|
||||||
|
item {
|
||||||
|
BlockCard(
|
||||||
|
isBlocked = uiState.isBlocked,
|
||||||
|
isBlocking = uiState.isBlocking,
|
||||||
|
firewallReady = uiState.firewallReady,
|
||||||
|
blockability = blockability,
|
||||||
|
warnAllProfiles = warnAllProfiles,
|
||||||
|
onBlockedChange = onBlockedChange,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
UsageCard(
|
||||||
|
usage = uiState.usage,
|
||||||
|
range = uiState.usageRange,
|
||||||
|
onRangeChange = onUsageRangeChange,
|
||||||
|
onGrantUsageAccess = onGrantUsageAccess,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
ConnectionsHeader(connections = uiState.connections)
|
||||||
|
}
|
||||||
|
item { ConnectionsCard(connections = uiState.connections) }
|
||||||
|
uiState.errorMessage?.let { message ->
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AppHeader(
|
||||||
|
app: InstalledApp,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
AppIcon(app = app, iconSize = 56.dp)
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = app.label,
|
||||||
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (app.isWorkProfile) {
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
WorkBadge()
|
||||||
|
}
|
||||||
|
if (app.isSystem) {
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
SystemBadge()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = app.packageName,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BlockCard(
|
||||||
|
isBlocked: Boolean,
|
||||||
|
isBlocking: Boolean,
|
||||||
|
firewallReady: Boolean,
|
||||||
|
blockability: AppBlockability,
|
||||||
|
warnAllProfiles: Boolean,
|
||||||
|
onBlockedChange: (Boolean) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val blockable = blockability == AppBlockability.BLOCKABLE
|
||||||
|
val reason = blockabilityReason(blockability)
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.detail_block_title),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.detail_block_description),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Switch(
|
||||||
|
checked = isBlocked,
|
||||||
|
onCheckedChange = onBlockedChange,
|
||||||
|
enabled = blockable && firewallReady && !isBlocking,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (reason != null) {
|
||||||
|
Text(
|
||||||
|
text = reason,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
} else if (!firewallReady) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.detail_firewall_not_ready),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (warnAllProfiles) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.firewall_blocked_warning_all_profiles),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun UsageCard(
|
||||||
|
usage: UsageUiState,
|
||||||
|
range: UsageRange,
|
||||||
|
onRangeChange: (UsageRange) -> Unit,
|
||||||
|
onGrantUsageAccess: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Card(modifier = modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.detail_usage_title),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
UsageRange.entries.forEach { option ->
|
||||||
|
FilterChip(
|
||||||
|
selected = option == range,
|
||||||
|
onClick = { onRangeChange(option) },
|
||||||
|
label = { Text(stringResource(option.labelRes)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
when (usage) {
|
||||||
|
UsageUiState.Loading -> LoadingRow(
|
||||||
|
text = stringResource(R.string.detail_usage_loading),
|
||||||
|
)
|
||||||
|
is UsageUiState.Loaded -> Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(32.dp),
|
||||||
|
) {
|
||||||
|
UsageStat(
|
||||||
|
label = stringResource(R.string.detail_usage_download),
|
||||||
|
bytes = usage.usage.receivedBytes,
|
||||||
|
)
|
||||||
|
UsageStat(
|
||||||
|
label = stringResource(R.string.detail_usage_upload),
|
||||||
|
bytes = usage.usage.sentBytes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
UsageUiState.PermissionRequired -> Column {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.detail_usage_permission),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
TextButton(onClick = onGrantUsageAccess) {
|
||||||
|
Text(stringResource(R.string.detail_usage_permission_action))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is UsageUiState.Unavailable -> Text(
|
||||||
|
text = usage.reason,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun UsageStat(
|
||||||
|
label: String,
|
||||||
|
bytes: Long,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = Formatter.formatFileSize(context, bytes),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ConnectionsHeader(
|
||||||
|
connections: ConnectionsUiState,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.detail_connections_title),
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
)
|
||||||
|
if (connections is ConnectionsUiState.Loaded && connections.active.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = pluralStringResource(
|
||||||
|
R.plurals.detail_connections_count,
|
||||||
|
connections.active.size,
|
||||||
|
connections.active.size,
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ConnectionsCard(
|
||||||
|
connections: ConnectionsUiState,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Card(modifier = modifier.fillMaxWidth()) {
|
||||||
|
Column {
|
||||||
|
when (connections) {
|
||||||
|
ConnectionsUiState.Loading -> LoadingRow(
|
||||||
|
text = stringResource(R.string.detail_connections_loading),
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
)
|
||||||
|
is ConnectionsUiState.Unavailable -> Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.detail_connections_unavailable),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = connections.reason,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is ConnectionsUiState.Loaded -> {
|
||||||
|
val hasActive = connections.active.isNotEmpty()
|
||||||
|
val hasListening = connections.listening.isNotEmpty()
|
||||||
|
if (!hasActive && !hasListening) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.detail_connections_empty),
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
connections.active.forEachIndexed { index, entry ->
|
||||||
|
ConnectionRow(entry)
|
||||||
|
if (index < connections.active.lastIndex) {
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(start = 16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasListening) {
|
||||||
|
if (hasActive) HorizontalDivider()
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.detail_connections_listening_title),
|
||||||
|
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 12.dp),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
connections.listening.forEachIndexed { index, entry ->
|
||||||
|
ConnectionRow(entry)
|
||||||
|
if (index < connections.listening.lastIndex) {
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(start = 16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (connections is ConnectionsUiState.Loaded) {
|
||||||
|
HorizontalDivider()
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.detail_connections_note),
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ConnectionRow(
|
||||||
|
entry: SocketEntry,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = connectionEndpoint(entry),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = connectionDetail(entry),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
ProtocolBadge(entry.protocol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun connectionEndpoint(entry: SocketEntry): String {
|
||||||
|
if (entry.isListening) {
|
||||||
|
return stringResource(R.string.detail_connections_listening, entry.localPort)
|
||||||
|
}
|
||||||
|
val host = entry.remoteAddress?.hostAddress
|
||||||
|
?: return stringResource(R.string.detail_connections_unknown)
|
||||||
|
val display = if (host.contains(':')) "[$host]" else host
|
||||||
|
return "$display:${entry.remotePort}"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun connectionDetail(entry: SocketEntry): String {
|
||||||
|
if (entry.isListening) {
|
||||||
|
val address = entry.localAddress?.hostAddress
|
||||||
|
return if (address != null) {
|
||||||
|
stringResource(R.string.detail_connections_local_address, address)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.detail_connections_all_interfaces)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val state = entry.state
|
||||||
|
if (state != null) {
|
||||||
|
return state.name
|
||||||
|
.replace('_', ' ')
|
||||||
|
.lowercase()
|
||||||
|
.replaceFirstChar { it.uppercase() }
|
||||||
|
}
|
||||||
|
return stringResource(R.string.detail_connections_active)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProtocolBadge(
|
||||||
|
protocol: SocketProtocol,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = modifier,
|
||||||
|
shape = RoundedCornerShape(6.dp),
|
||||||
|
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = protocol.label,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LoadingRow(
|
||||||
|
text: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val UsageRange.labelRes: Int
|
||||||
|
get() = when (this) {
|
||||||
|
UsageRange.TODAY -> R.string.detail_usage_today
|
||||||
|
UsageRange.WEEK -> R.string.detail_usage_week
|
||||||
|
UsageRange.MONTH -> R.string.detail_usage_month
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openUsageAccessSettings(context: Context) {
|
||||||
|
val intent = Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
if (runCatching { context.startActivity(intent) }.isSuccess) return
|
||||||
|
|
||||||
|
val fallback = Intent(Settings.ACTION_SETTINGS)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
runCatching { context.startActivity(fallback) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val CONNECTION_POLL_INTERVAL_MS = 3_000L
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun AppDetailScreenPreview() {
|
||||||
|
val app = InstalledApp(
|
||||||
|
packageName = "com.example.browser",
|
||||||
|
label = "Browser",
|
||||||
|
isSystem = false,
|
||||||
|
icon = null,
|
||||||
|
uid = 10124,
|
||||||
|
userId = 0,
|
||||||
|
isWorkProfile = false,
|
||||||
|
hasInternetPermission = true,
|
||||||
|
)
|
||||||
|
val remote = InetAddress.getByAddress(byteArrayOf(1, 1, 1, 1))
|
||||||
|
KwallTheme {
|
||||||
|
AppDetailScreen(
|
||||||
|
app = app,
|
||||||
|
uiState = AppDetailUiState(
|
||||||
|
app = app,
|
||||||
|
isBlocked = true,
|
||||||
|
firewallReady = true,
|
||||||
|
connections = ConnectionsUiState.Loaded(
|
||||||
|
active = listOf(
|
||||||
|
SocketEntry(
|
||||||
|
uid = app.uid,
|
||||||
|
protocol = SocketProtocol.TCP,
|
||||||
|
localAddress = null,
|
||||||
|
localPort = 41234,
|
||||||
|
remoteAddress = remote,
|
||||||
|
remotePort = 443,
|
||||||
|
state = TcpState.ESTABLISHED,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
listening = emptyList(),
|
||||||
|
),
|
||||||
|
usage = UsageUiState.Loaded(DataUsage(receivedBytes = 1_234_567, sentBytes = 234_567)),
|
||||||
|
),
|
||||||
|
blockability = AppBlockability.BLOCKABLE,
|
||||||
|
warnAllProfiles = false,
|
||||||
|
onBack = {},
|
||||||
|
onBlockedChange = {},
|
||||||
|
onUsageRangeChange = {},
|
||||||
|
onGrantUsageAccess = {},
|
||||||
|
onRefresh = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package com.krrishg.kwall.ui.detail
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.lifecycle.viewmodel.initializer
|
||||||
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
|
import com.krrishg.kwall.data.InstalledApp
|
||||||
|
import com.krrishg.kwall.firewall.FirewallAvailability
|
||||||
|
import com.krrishg.kwall.firewall.FirewallController
|
||||||
|
import com.krrishg.kwall.network.ConnectionInspector
|
||||||
|
import com.krrishg.kwall.network.ConnectionSnapshot
|
||||||
|
import com.krrishg.kwall.network.DataUsage
|
||||||
|
import com.krrishg.kwall.network.NetworkUsageSource
|
||||||
|
import com.krrishg.kwall.network.SocketEntry
|
||||||
|
import com.krrishg.kwall.network.UsageRange
|
||||||
|
import com.krrishg.kwall.network.UsageResult
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
|
||||||
|
sealed interface ConnectionsUiState {
|
||||||
|
data object Loading : ConnectionsUiState
|
||||||
|
|
||||||
|
data class Loaded(
|
||||||
|
val active: List<SocketEntry>,
|
||||||
|
val listening: List<SocketEntry>,
|
||||||
|
) : ConnectionsUiState
|
||||||
|
|
||||||
|
data class Unavailable(val reason: String) : ConnectionsUiState
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface UsageUiState {
|
||||||
|
data object Loading : UsageUiState
|
||||||
|
|
||||||
|
data class Loaded(val usage: DataUsage) : UsageUiState
|
||||||
|
|
||||||
|
data object PermissionRequired : UsageUiState
|
||||||
|
|
||||||
|
data class Unavailable(val reason: String) : UsageUiState
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AppDetailUiState(
|
||||||
|
val app: InstalledApp? = null,
|
||||||
|
val isBlocked: Boolean = false,
|
||||||
|
val isBlocking: Boolean = false,
|
||||||
|
val firewallReady: Boolean = false,
|
||||||
|
val connections: ConnectionsUiState = ConnectionsUiState.Loading,
|
||||||
|
val usage: UsageUiState = UsageUiState.Loading,
|
||||||
|
val usageRange: UsageRange = UsageRange.TODAY,
|
||||||
|
val errorMessage: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
class AppDetailViewModel(
|
||||||
|
private val connectionInspector: ConnectionInspector,
|
||||||
|
private val usageSource: NetworkUsageSource,
|
||||||
|
private val firewallController: FirewallController,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val connectionLock = Mutex()
|
||||||
|
private var usageJob: Job? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var app: InstalledApp? = null
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(AppDetailUiState())
|
||||||
|
val uiState: StateFlow<AppDetailUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
combine(
|
||||||
|
firewallController.state,
|
||||||
|
firewallController.availability,
|
||||||
|
) { state, availability ->
|
||||||
|
state.blockedApps to (availability is FirewallAvailability.Ready)
|
||||||
|
}.collect { (blockedApps, ready) ->
|
||||||
|
_uiState.update { current ->
|
||||||
|
val currentApp = current.app ?: return@update current
|
||||||
|
current.copy(
|
||||||
|
isBlocked = currentApp.key in blockedApps,
|
||||||
|
firewallReady = ready,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
firewallController.error.collect { error ->
|
||||||
|
if (error != null) {
|
||||||
|
_uiState.update { it.copy(errorMessage = error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load(app: InstalledApp) {
|
||||||
|
if (this.app?.key == app.key) return
|
||||||
|
this.app = app
|
||||||
|
_uiState.value = AppDetailUiState(
|
||||||
|
app = app,
|
||||||
|
isBlocked = firewallController.state.value.blockedApps.contains(app.key),
|
||||||
|
firewallReady = firewallController.availability.value is FirewallAvailability.Ready,
|
||||||
|
)
|
||||||
|
refreshUsage()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun refreshConnections() {
|
||||||
|
val currentApp = app ?: return
|
||||||
|
connectionLock.withLock {
|
||||||
|
if (app?.key != currentApp.key) return@withLock
|
||||||
|
val snapshot = connectionInspector.snapshot()
|
||||||
|
if (app?.key != currentApp.key) return@withLock
|
||||||
|
_uiState.update { current ->
|
||||||
|
if (current.app?.key != currentApp.key) return@update current
|
||||||
|
current.copy(connections = snapshot.toUiState(currentApp.uid))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshConnectionsNow() {
|
||||||
|
viewModelScope.launch { refreshConnections() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectUsageRange(range: UsageRange) {
|
||||||
|
if (_uiState.value.usageRange == range) return
|
||||||
|
_uiState.update { it.copy(usageRange = range) }
|
||||||
|
refreshUsage()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshUsage() {
|
||||||
|
val currentApp = app ?: return
|
||||||
|
val range = _uiState.value.usageRange
|
||||||
|
usageJob?.cancel()
|
||||||
|
usageJob = viewModelScope.launch {
|
||||||
|
_uiState.update { current ->
|
||||||
|
if (current.app?.key == currentApp.key) {
|
||||||
|
current.copy(usage = UsageUiState.Loading)
|
||||||
|
} else {
|
||||||
|
current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val result = usageSource.usageFor(currentApp.uid, range)
|
||||||
|
if (app?.key != currentApp.key) return@launch
|
||||||
|
_uiState.update { current ->
|
||||||
|
if (current.app?.key != currentApp.key) return@update current
|
||||||
|
current.copy(usage = result.toUiState())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setBlocked(blocked: Boolean) {
|
||||||
|
val currentApp = app ?: return
|
||||||
|
if (_uiState.value.isBlocking) return
|
||||||
|
_uiState.update { it.copy(isBlocking = true, errorMessage = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
firewallController.setBlocked(currentApp, blocked)
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (error: Exception) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(errorMessage = error.message ?: "Could not update the firewall")
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_uiState.update { it.copy(isBlocking = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ConnectionSnapshot.toUiState(uid: Int): ConnectionsUiState = when (this) {
|
||||||
|
is ConnectionSnapshot.Available -> {
|
||||||
|
val matching = this.sockets.filter { it.uid == uid }
|
||||||
|
ConnectionsUiState.Loaded(
|
||||||
|
active = matching
|
||||||
|
.filterNot { it.isListening }
|
||||||
|
.sortedWith(
|
||||||
|
compareBy<SocketEntry> { it.remoteAddress?.hostAddress.orEmpty() }
|
||||||
|
.thenBy { it.remotePort }
|
||||||
|
.thenBy { it.protocol.label },
|
||||||
|
),
|
||||||
|
listening = matching.filter { it.isListening }.sortedBy { it.localPort },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is ConnectionSnapshot.Unavailable -> ConnectionsUiState.Unavailable(reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun UsageResult.toUiState(): UsageUiState = when (this) {
|
||||||
|
is UsageResult.Available -> UsageUiState.Loaded(usage)
|
||||||
|
UsageResult.PermissionRequired -> UsageUiState.PermissionRequired
|
||||||
|
is UsageResult.Unavailable -> UsageUiState.Unavailable(reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun factory(
|
||||||
|
firewallController: FirewallController,
|
||||||
|
connectionInspector: ConnectionInspector,
|
||||||
|
usageSource: NetworkUsageSource,
|
||||||
|
): ViewModelProvider.Factory = viewModelFactory {
|
||||||
|
initializer {
|
||||||
|
AppDetailViewModel(
|
||||||
|
connectionInspector = connectionInspector,
|
||||||
|
usageSource = usageSource,
|
||||||
|
firewallController = firewallController,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
package com.krrishg.kwall.ui.profiles
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.krrishg.kwall.R
|
||||||
|
import com.krrishg.kwall.firewall.ProfileNameIssue
|
||||||
|
import com.krrishg.kwall.firewall.RuleProfile
|
||||||
|
import com.krrishg.kwall.firewall.validateProfileName
|
||||||
|
|
||||||
|
private sealed interface ProfileNameMode {
|
||||||
|
data object Create : ProfileNameMode
|
||||||
|
|
||||||
|
data class Rename(val profile: RuleProfile) : ProfileNameMode
|
||||||
|
|
||||||
|
data class Duplicate(val profile: RuleProfile) : ProfileNameMode
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ProfilesDialog(
|
||||||
|
profiles: List<RuleProfile>,
|
||||||
|
activeProfileId: String,
|
||||||
|
isBusy: Boolean,
|
||||||
|
errorMessage: String?,
|
||||||
|
onSwitch: (String) -> Unit,
|
||||||
|
onCreate: (String) -> Unit,
|
||||||
|
onRename: (String, String) -> Unit,
|
||||||
|
onDuplicate: (String, String) -> Unit,
|
||||||
|
onDelete: (String) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
var nameMode by remember { mutableStateOf<ProfileNameMode?>(null) }
|
||||||
|
var deleteTarget by remember { mutableStateOf<RuleProfile?>(null) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.profiles_title)) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
if (errorMessage != null) {
|
||||||
|
Text(
|
||||||
|
text = errorMessage,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.heightIn(max = 360.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
items(items = profiles, key = { it.id }) { profile ->
|
||||||
|
ProfileRow(
|
||||||
|
profile = profile,
|
||||||
|
active = profile.id == activeProfileId,
|
||||||
|
isBusy = isBusy,
|
||||||
|
canDelete = profiles.size > 1,
|
||||||
|
onSwitch = { onSwitch(profile.id) },
|
||||||
|
onRename = { nameMode = ProfileNameMode.Rename(profile) },
|
||||||
|
onDuplicate = { nameMode = ProfileNameMode.Duplicate(profile) },
|
||||||
|
onDelete = { deleteTarget = profile },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.close))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = { nameMode = ProfileNameMode.Create },
|
||||||
|
enabled = !isBusy,
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.profile_new))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
val mode = nameMode
|
||||||
|
if (mode != null) {
|
||||||
|
ProfileNameDialog(
|
||||||
|
mode = mode,
|
||||||
|
profiles = profiles,
|
||||||
|
isBusy = isBusy,
|
||||||
|
onConfirm = { name ->
|
||||||
|
when (mode) {
|
||||||
|
ProfileNameMode.Create -> onCreate(name)
|
||||||
|
is ProfileNameMode.Rename -> onRename(mode.profile.id, name)
|
||||||
|
is ProfileNameMode.Duplicate -> onDuplicate(mode.profile.id, name)
|
||||||
|
}
|
||||||
|
nameMode = null
|
||||||
|
},
|
||||||
|
onDismiss = { nameMode = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val target = deleteTarget
|
||||||
|
if (target != null) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { deleteTarget = null },
|
||||||
|
title = { Text(stringResource(R.string.profile_delete_title)) },
|
||||||
|
text = { Text(stringResource(R.string.profile_delete_message, target.name)) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
onDelete(target.id)
|
||||||
|
deleteTarget = null
|
||||||
|
},
|
||||||
|
enabled = !isBusy,
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.profile_delete))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { deleteTarget = null }) {
|
||||||
|
Text(stringResource(R.string.cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProfileRow(
|
||||||
|
profile: RuleProfile,
|
||||||
|
active: Boolean,
|
||||||
|
isBusy: Boolean,
|
||||||
|
canDelete: Boolean,
|
||||||
|
onSwitch: () -> Unit,
|
||||||
|
onRename: () -> Unit,
|
||||||
|
onDuplicate: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
) {
|
||||||
|
var menuExpanded by remember { mutableStateOf(false) }
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(enabled = !isBusy, onClick = onSwitch)
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = active,
|
||||||
|
onClick = onSwitch,
|
||||||
|
enabled = !isBusy,
|
||||||
|
)
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = profile.name,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = pluralStringResource(
|
||||||
|
R.plurals.profile_blocked_apps,
|
||||||
|
profile.blockedApps.size,
|
||||||
|
profile.blockedApps.size,
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box {
|
||||||
|
IconButton(
|
||||||
|
onClick = { menuExpanded = true },
|
||||||
|
enabled = !isBusy,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.MoreVert,
|
||||||
|
contentDescription = stringResource(R.string.profile_actions),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = menuExpanded,
|
||||||
|
onDismissRequest = { menuExpanded = false },
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.profile_rename)) },
|
||||||
|
onClick = {
|
||||||
|
menuExpanded = false
|
||||||
|
onRename()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.profile_duplicate)) },
|
||||||
|
onClick = {
|
||||||
|
menuExpanded = false
|
||||||
|
onDuplicate()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.profile_delete)) },
|
||||||
|
onClick = {
|
||||||
|
menuExpanded = false
|
||||||
|
onDelete()
|
||||||
|
},
|
||||||
|
enabled = canDelete,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProfileNameDialog(
|
||||||
|
mode: ProfileNameMode,
|
||||||
|
profiles: List<RuleProfile>,
|
||||||
|
isBusy: Boolean,
|
||||||
|
onConfirm: (String) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val initialName = when (mode) {
|
||||||
|
ProfileNameMode.Create -> ""
|
||||||
|
is ProfileNameMode.Rename -> mode.profile.name
|
||||||
|
is ProfileNameMode.Duplicate -> stringResource(
|
||||||
|
R.string.profile_duplicate_name,
|
||||||
|
mode.profile.name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val excludingId = (mode as? ProfileNameMode.Rename)?.profile?.id
|
||||||
|
val title = when (mode) {
|
||||||
|
ProfileNameMode.Create -> stringResource(R.string.profile_create_title)
|
||||||
|
is ProfileNameMode.Rename -> stringResource(R.string.profile_rename_title)
|
||||||
|
is ProfileNameMode.Duplicate -> stringResource(R.string.profile_duplicate_title)
|
||||||
|
}
|
||||||
|
var name by rememberSaveable(initialName) { mutableStateOf(initialName) }
|
||||||
|
val issue = validateProfileName(name, profiles, excludingId)
|
||||||
|
val issueMessage = when (issue) {
|
||||||
|
ProfileNameIssue.BLANK -> stringResource(R.string.profile_name_blank)
|
||||||
|
ProfileNameIssue.TOO_LONG -> stringResource(R.string.profile_name_too_long)
|
||||||
|
ProfileNameIssue.DUPLICATE -> stringResource(R.string.profile_name_duplicate)
|
||||||
|
null -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(title) },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = { name = it },
|
||||||
|
label = { Text(stringResource(R.string.profile_name_label)) },
|
||||||
|
singleLine = true,
|
||||||
|
isError = issueMessage != null,
|
||||||
|
supportingText = issueMessage?.let { message ->
|
||||||
|
{ Text(message) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = { onConfirm(name.trim()) },
|
||||||
|
enabled = issueMessage == null && !isBusy,
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.save))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,869 @@
|
|||||||
|
package com.krrishg.kwall.ui.schedules
|
||||||
|
|
||||||
|
import android.app.DatePickerDialog
|
||||||
|
import android.app.TimePickerDialog
|
||||||
|
import android.text.format.DateFormat
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.krrishg.kwall.R
|
||||||
|
import com.krrishg.kwall.firewall.RuleProfile
|
||||||
|
import com.krrishg.kwall.scheduler.MAX_SCHEDULE_NAME_LENGTH
|
||||||
|
import com.krrishg.kwall.scheduler.ProfileSchedule
|
||||||
|
import com.krrishg.kwall.scheduler.ScheduleEngine
|
||||||
|
import com.krrishg.kwall.scheduler.ScheduleIssue
|
||||||
|
import com.krrishg.kwall.scheduler.ScheduleRecurrence
|
||||||
|
import java.time.DayOfWeek
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
import java.time.format.TextStyle
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SchedulesRoute(
|
||||||
|
viewModel: SchedulesViewModel,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
var editorOpen by remember { mutableStateOf(false) }
|
||||||
|
var editorTarget by remember { mutableStateOf<ProfileSchedule?>(null) }
|
||||||
|
var deleteTarget by remember { mutableStateOf<ProfileSchedule?>(null) }
|
||||||
|
|
||||||
|
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
|
||||||
|
viewModel.refreshExactAlarmStatus()
|
||||||
|
}
|
||||||
|
BackHandler(onBack = onBack)
|
||||||
|
|
||||||
|
SchedulesScreen(
|
||||||
|
uiState = uiState,
|
||||||
|
onBack = onBack,
|
||||||
|
onAdd = {
|
||||||
|
viewModel.clearError()
|
||||||
|
editorTarget = null
|
||||||
|
editorOpen = true
|
||||||
|
},
|
||||||
|
onEdit = { schedule ->
|
||||||
|
viewModel.clearError()
|
||||||
|
editorTarget = schedule
|
||||||
|
editorOpen = true
|
||||||
|
},
|
||||||
|
onDelete = { schedule -> deleteTarget = schedule },
|
||||||
|
onToggle = viewModel::setEnabled,
|
||||||
|
onRequestExactAlarmPermission = viewModel::requestExactAlarmPermission,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (editorOpen) {
|
||||||
|
key(editorTarget?.id) {
|
||||||
|
ScheduleEditorDialog(
|
||||||
|
existing = editorTarget,
|
||||||
|
profiles = uiState.profiles,
|
||||||
|
activeProfileId = uiState.activeProfileId,
|
||||||
|
existingSchedules = uiState.schedules.map { it.schedule },
|
||||||
|
onSave = viewModel::save,
|
||||||
|
onDismiss = { editorOpen = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val target = deleteTarget
|
||||||
|
if (target != null) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { deleteTarget = null },
|
||||||
|
title = { Text(stringResource(R.string.schedule_delete_title)) },
|
||||||
|
text = { Text(stringResource(R.string.schedule_delete_message, target.name)) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
viewModel.delete(target.id)
|
||||||
|
deleteTarget = null
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.schedule_delete))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { deleteTarget = null }) {
|
||||||
|
Text(stringResource(R.string.cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun SchedulesScreen(
|
||||||
|
uiState: SchedulesUiState,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onAdd: () -> Unit,
|
||||||
|
onEdit: (ProfileSchedule) -> Unit,
|
||||||
|
onDelete: (ProfileSchedule) -> Unit,
|
||||||
|
onToggle: (String, Boolean) -> Unit,
|
||||||
|
onRequestExactAlarmPermission: () -> Unit,
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.schedules_title)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = onAdd, enabled = uiState.profiles.isNotEmpty()) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Add,
|
||||||
|
contentDescription = stringResource(R.string.schedule_add),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { contentPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(contentPadding),
|
||||||
|
) {
|
||||||
|
if (!uiState.exactAlarmsAllowed) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.schedule_exact_alarm_unavailable),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
TextButton(onClick = onRequestExactAlarmPermission) {
|
||||||
|
Text(stringResource(R.string.schedule_allow_exact_alarms))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val issue = uiState.errorIssue
|
||||||
|
val error = uiState.errorMessage
|
||||||
|
if (issue != null || error != null) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = issue?.let { scheduleIssueText(it) } ?: error.orEmpty(),
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.schedule_loading))
|
||||||
|
}
|
||||||
|
} else if (uiState.schedules.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(32.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.schedule_empty),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
|
items(items = uiState.schedules, key = { it.schedule.id }) { row ->
|
||||||
|
ScheduleRow(
|
||||||
|
row = row,
|
||||||
|
enabled = !uiState.isSaving,
|
||||||
|
onEdit = { onEdit(row.schedule) },
|
||||||
|
onDelete = { onDelete(row.schedule) },
|
||||||
|
onToggle = { onToggle(row.schedule.id, it) },
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ScheduleRow(
|
||||||
|
row: ScheduleRowUiState,
|
||||||
|
enabled: Boolean,
|
||||||
|
onEdit: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
onToggle: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
var menuExpanded by remember { mutableStateOf(false) }
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onEdit)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = row.schedule.name,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = row.profileName ?: stringResource(R.string.schedule_missing_profile),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (row.profileName == null) {
|
||||||
|
MaterialTheme.colorScheme.error
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = scheduleSummary(row.schedule),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
val nextRun = row.nextRun
|
||||||
|
if (nextRun != null) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
R.string.schedule_next_run,
|
||||||
|
formatInstant(nextRun),
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
row.issues.firstOrNull()?.let { issue ->
|
||||||
|
Text(
|
||||||
|
text = scheduleIssueText(issue),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = row.schedule.enabled,
|
||||||
|
onCheckedChange = onToggle,
|
||||||
|
enabled = enabled,
|
||||||
|
)
|
||||||
|
Box {
|
||||||
|
IconButton(onClick = { menuExpanded = true }, enabled = enabled) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.MoreVert,
|
||||||
|
contentDescription = stringResource(R.string.profile_actions),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = menuExpanded,
|
||||||
|
onDismissRequest = { menuExpanded = false },
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.schedule_edit)) },
|
||||||
|
onClick = {
|
||||||
|
menuExpanded = false
|
||||||
|
onEdit()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.schedule_delete)) },
|
||||||
|
onClick = {
|
||||||
|
menuExpanded = false
|
||||||
|
onDelete()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ScheduleEditorDialog(
|
||||||
|
existing: ProfileSchedule?,
|
||||||
|
profiles: List<RuleProfile>,
|
||||||
|
activeProfileId: String,
|
||||||
|
existingSchedules: List<ProfileSchedule>,
|
||||||
|
onSave: (ProfileSchedule) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val defaultProfileId = existing?.profileId
|
||||||
|
?: profiles.firstOrNull { it.id == activeProfileId }?.id
|
||||||
|
?: profiles.firstOrNull()?.id
|
||||||
|
.orEmpty()
|
||||||
|
val defaultFallbackId = existing?.fallbackProfileId
|
||||||
|
?: profiles.firstOrNull { it.id == activeProfileId }?.id
|
||||||
|
?: profiles.firstOrNull()?.id
|
||||||
|
.orEmpty()
|
||||||
|
val initialRecurrence = existing?.recurrence
|
||||||
|
val initialType = when (initialRecurrence) {
|
||||||
|
is ScheduleRecurrence.Once -> RecurrenceType.ONCE
|
||||||
|
is ScheduleRecurrence.Daily -> RecurrenceType.DAILY
|
||||||
|
is ScheduleRecurrence.Weekly -> RecurrenceType.WEEKLY
|
||||||
|
is ScheduleRecurrence.Monthly -> RecurrenceType.MONTHLY
|
||||||
|
null -> RecurrenceType.DAILY
|
||||||
|
}
|
||||||
|
val initialAnchor = initialRecurrence?.anchorDate ?: LocalDate.now()
|
||||||
|
val initialStart = existing?.startTime ?: LocalTime.of(9, 0)
|
||||||
|
val initialEnd = existing?.endTime ?: LocalTime.of(17, 0)
|
||||||
|
val initialInterval = when (initialRecurrence) {
|
||||||
|
is ScheduleRecurrence.Daily -> initialRecurrence.intervalDays
|
||||||
|
is ScheduleRecurrence.Weekly -> initialRecurrence.intervalWeeks
|
||||||
|
is ScheduleRecurrence.Monthly -> initialRecurrence.intervalMonths
|
||||||
|
else -> 1
|
||||||
|
}
|
||||||
|
val initialDays = (initialRecurrence as? ScheduleRecurrence.Weekly)
|
||||||
|
?.daysOfWeek
|
||||||
|
?: setOf(initialAnchor.dayOfWeek)
|
||||||
|
val initialDayOfMonth = (initialRecurrence as? ScheduleRecurrence.Monthly)
|
||||||
|
?.dayOfMonth
|
||||||
|
?: initialAnchor.dayOfMonth
|
||||||
|
|
||||||
|
var name by remember { mutableStateOf(existing?.name.orEmpty()) }
|
||||||
|
var profileId by remember { mutableStateOf(defaultProfileId) }
|
||||||
|
var fallbackProfileId by remember { mutableStateOf(defaultFallbackId) }
|
||||||
|
var type by remember { mutableStateOf(initialType) }
|
||||||
|
var anchorDate by remember { mutableStateOf(initialAnchor.toString()) }
|
||||||
|
var startTime by remember { mutableStateOf(formatTimeInput(initialStart)) }
|
||||||
|
var endTimeEnabled by remember { mutableStateOf(existing?.endTime != null) }
|
||||||
|
var endTime by remember { mutableStateOf(formatTimeInput(initialEnd)) }
|
||||||
|
var interval by remember { mutableStateOf(initialInterval.toString()) }
|
||||||
|
var daysOfWeek by remember { mutableStateOf(initialDays) }
|
||||||
|
var dayOfMonth by remember { mutableStateOf(initialDayOfMonth.toString()) }
|
||||||
|
var scheduleEnabled by remember { mutableStateOf(existing?.enabled ?: true) }
|
||||||
|
var errorIssue by remember { mutableStateOf<ScheduleIssue?>(null) }
|
||||||
|
|
||||||
|
fun showDatePicker(current: LocalDate, onSelected: (LocalDate) -> Unit) {
|
||||||
|
DatePickerDialog(
|
||||||
|
context,
|
||||||
|
{ _, year, month, day -> onSelected(LocalDate.of(year, month + 1, day)) },
|
||||||
|
current.year,
|
||||||
|
current.monthValue - 1,
|
||||||
|
current.dayOfMonth,
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showTimePicker(current: LocalTime, onSelected: (LocalTime) -> Unit) {
|
||||||
|
TimePickerDialog(
|
||||||
|
context,
|
||||||
|
{ _, hour, minute -> onSelected(LocalTime.of(hour, minute)) },
|
||||||
|
current.hour,
|
||||||
|
current.minute,
|
||||||
|
DateFormat.is24HourFormat(context),
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildSchedule(): ProfileSchedule? {
|
||||||
|
val date = runCatching { LocalDate.parse(anchorDate) }.getOrNull() ?: return null
|
||||||
|
val start = runCatching { LocalTime.parse(startTime) }.getOrNull() ?: return null
|
||||||
|
val end = if (endTimeEnabled) {
|
||||||
|
runCatching { LocalTime.parse(endTime) }.getOrNull() ?: return null
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val parsedInterval = interval.toIntOrNull()
|
||||||
|
val recurrence = when (type) {
|
||||||
|
RecurrenceType.ONCE -> ScheduleRecurrence.Once(date)
|
||||||
|
RecurrenceType.DAILY -> ScheduleRecurrence.Daily(
|
||||||
|
anchorDate = date,
|
||||||
|
intervalDays = parsedInterval ?: 0,
|
||||||
|
)
|
||||||
|
RecurrenceType.WEEKLY -> ScheduleRecurrence.Weekly(
|
||||||
|
anchorDate = date,
|
||||||
|
daysOfWeek = daysOfWeek,
|
||||||
|
intervalWeeks = parsedInterval ?: 0,
|
||||||
|
)
|
||||||
|
RecurrenceType.MONTHLY -> ScheduleRecurrence.Monthly(
|
||||||
|
anchorDate = date,
|
||||||
|
dayOfMonth = dayOfMonth.toIntOrNull() ?: 0,
|
||||||
|
intervalMonths = parsedInterval ?: 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val base = existing ?: ProfileSchedule(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
name = name.trim(),
|
||||||
|
profileId = profileId,
|
||||||
|
fallbackProfileId = fallbackProfileId,
|
||||||
|
recurrence = recurrence,
|
||||||
|
startTime = start,
|
||||||
|
createdAtMillis = System.currentTimeMillis(),
|
||||||
|
)
|
||||||
|
return base.copy(
|
||||||
|
name = name.trim(),
|
||||||
|
profileId = profileId,
|
||||||
|
fallbackProfileId = fallbackProfileId,
|
||||||
|
recurrence = recurrence,
|
||||||
|
startTime = start,
|
||||||
|
endTime = end,
|
||||||
|
enabled = scheduleEnabled,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
if (existing == null) R.string.schedule_create_title else R.string.schedule_edit_title,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
if (profiles.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.schedule_needs_profile),
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
OutlinedTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = {
|
||||||
|
if (it.length <= MAX_SCHEDULE_NAME_LENGTH) name = it
|
||||||
|
},
|
||||||
|
label = { Text(stringResource(R.string.schedule_name_label)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
ProfilePicker(
|
||||||
|
label = stringResource(R.string.schedule_profile_label),
|
||||||
|
profiles = profiles,
|
||||||
|
selectedId = profileId,
|
||||||
|
onSelected = { profileId = it },
|
||||||
|
)
|
||||||
|
ProfilePicker(
|
||||||
|
label = stringResource(R.string.schedule_fallback_label),
|
||||||
|
profiles = profiles,
|
||||||
|
selectedId = fallbackProfileId,
|
||||||
|
onSelected = { fallbackProfileId = it },
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.schedule_repeat_label),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
RecurrenceType.entries.forEach { option ->
|
||||||
|
FilterChip(
|
||||||
|
selected = type == option,
|
||||||
|
onClick = { type = option },
|
||||||
|
label = { Text(recurrenceTypeLabel(option)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
showDatePicker(
|
||||||
|
runCatching { LocalDate.parse(anchorDate) }.getOrDefault(LocalDate.now()),
|
||||||
|
) { anchorDate = it.toString() }
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
R.string.schedule_date_value,
|
||||||
|
formatDate(
|
||||||
|
runCatching { LocalDate.parse(anchorDate) }
|
||||||
|
.getOrDefault(LocalDate.now()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (type != RecurrenceType.ONCE) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = interval,
|
||||||
|
onValueChange = { interval = it.filter(Char::isDigit) },
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
when (type) {
|
||||||
|
RecurrenceType.DAILY -> R.string.schedule_interval_days
|
||||||
|
RecurrenceType.WEEKLY -> R.string.schedule_interval_weeks
|
||||||
|
else -> R.string.schedule_interval_months
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
when (type) {
|
||||||
|
RecurrenceType.WEEKLY -> {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.schedule_weekdays_label),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
DayOfWeek.entries.forEach { day ->
|
||||||
|
FilterChip(
|
||||||
|
selected = day in daysOfWeek,
|
||||||
|
onClick = {
|
||||||
|
daysOfWeek = if (day in daysOfWeek) {
|
||||||
|
daysOfWeek - day
|
||||||
|
} else {
|
||||||
|
daysOfWeek + day
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = {
|
||||||
|
Text(day.getDisplayName(TextStyle.SHORT, Locale.getDefault()))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RecurrenceType.MONTHLY -> {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = dayOfMonth,
|
||||||
|
onValueChange = { dayOfMonth = it.filter(Char::isDigit) },
|
||||||
|
label = { Text(stringResource(R.string.schedule_day_of_month)) },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
showTimePicker(
|
||||||
|
runCatching { LocalTime.parse(startTime) }.getOrDefault(LocalTime.of(9, 0)),
|
||||||
|
) { startTime = formatTimeInput(it) }
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
R.string.schedule_start_time_value,
|
||||||
|
formatTime(
|
||||||
|
runCatching { LocalTime.parse(startTime) }
|
||||||
|
.getOrDefault(LocalTime.of(9, 0)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Switch(
|
||||||
|
checked = endTimeEnabled,
|
||||||
|
onCheckedChange = { endTimeEnabled = it },
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.schedule_end_time_label),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(start = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!endTimeEnabled) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.schedule_no_end_hint),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (endTimeEnabled) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
showTimePicker(
|
||||||
|
runCatching { LocalTime.parse(endTime) }
|
||||||
|
.getOrDefault(LocalTime.of(17, 0)),
|
||||||
|
) { endTime = formatTimeInput(it) }
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
R.string.schedule_end_time_value,
|
||||||
|
formatTime(
|
||||||
|
runCatching { LocalTime.parse(endTime) }
|
||||||
|
.getOrDefault(LocalTime.of(17, 0)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Switch(
|
||||||
|
checked = scheduleEnabled,
|
||||||
|
onCheckedChange = { scheduleEnabled = it },
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.schedule_enabled_label),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
errorIssue?.let { issue ->
|
||||||
|
Text(
|
||||||
|
text = scheduleIssueText(issue),
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
val schedule = buildSchedule() ?: return@TextButton
|
||||||
|
val issues = ScheduleEngine.validateSchedule(
|
||||||
|
candidate = schedule,
|
||||||
|
existing = existingSchedules,
|
||||||
|
availableProfileIds = profiles.map { it.id }.toSet(),
|
||||||
|
now = Instant.now(),
|
||||||
|
zone = ZoneId.systemDefault(),
|
||||||
|
)
|
||||||
|
if (issues.isNotEmpty()) {
|
||||||
|
errorIssue = issues.first()
|
||||||
|
return@TextButton
|
||||||
|
}
|
||||||
|
onSave(schedule)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
enabled = profiles.isNotEmpty(),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.save))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProfilePicker(
|
||||||
|
label: String,
|
||||||
|
profiles: List<RuleProfile>,
|
||||||
|
selectedId: String,
|
||||||
|
onSelected: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
val selectedName = profiles.firstOrNull { it.id == selectedId }?.name
|
||||||
|
?: stringResource(R.string.schedule_select_profile)
|
||||||
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { expanded = true },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Text(text = selectedName, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = expanded,
|
||||||
|
onDismissRequest = { expanded = false },
|
||||||
|
modifier = Modifier.width(280.dp),
|
||||||
|
) {
|
||||||
|
profiles.forEach { profile ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(profile.name) },
|
||||||
|
onClick = {
|
||||||
|
onSelected(profile.id)
|
||||||
|
expanded = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class RecurrenceType {
|
||||||
|
ONCE,
|
||||||
|
DAILY,
|
||||||
|
WEEKLY,
|
||||||
|
MONTHLY,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun recurrenceTypeLabel(type: RecurrenceType): String = stringResource(
|
||||||
|
when (type) {
|
||||||
|
RecurrenceType.ONCE -> R.string.schedule_repeat_once
|
||||||
|
RecurrenceType.DAILY -> R.string.schedule_repeat_daily
|
||||||
|
RecurrenceType.WEEKLY -> R.string.schedule_repeat_weekly
|
||||||
|
RecurrenceType.MONTHLY -> R.string.schedule_repeat_monthly
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun scheduleSummary(schedule: ProfileSchedule): String {
|
||||||
|
val start = formatTime(schedule.startTime)
|
||||||
|
val base = when (val recurrence = schedule.recurrence) {
|
||||||
|
is ScheduleRecurrence.Once -> stringResource(
|
||||||
|
R.string.schedule_summary_once,
|
||||||
|
formatDate(recurrence.date),
|
||||||
|
start,
|
||||||
|
)
|
||||||
|
is ScheduleRecurrence.Daily -> if (recurrence.intervalDays == 1) {
|
||||||
|
stringResource(R.string.schedule_summary_daily, start)
|
||||||
|
} else {
|
||||||
|
pluralStringResource(
|
||||||
|
R.plurals.schedule_summary_every_days,
|
||||||
|
recurrence.intervalDays,
|
||||||
|
recurrence.intervalDays,
|
||||||
|
start,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is ScheduleRecurrence.Weekly -> {
|
||||||
|
val days = recurrence.daysOfWeek
|
||||||
|
.sortedBy { it.value }
|
||||||
|
.joinToString(", ") { it.getDisplayName(TextStyle.SHORT, Locale.getDefault()) }
|
||||||
|
if (recurrence.intervalWeeks == 1) {
|
||||||
|
stringResource(R.string.schedule_summary_weekly, days, start)
|
||||||
|
} else {
|
||||||
|
pluralStringResource(
|
||||||
|
R.plurals.schedule_summary_every_weeks,
|
||||||
|
recurrence.intervalWeeks,
|
||||||
|
recurrence.intervalWeeks,
|
||||||
|
days,
|
||||||
|
start,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ScheduleRecurrence.Monthly -> if (recurrence.intervalMonths == 1) {
|
||||||
|
stringResource(R.string.schedule_summary_monthly, recurrence.dayOfMonth, start)
|
||||||
|
} else {
|
||||||
|
pluralStringResource(
|
||||||
|
R.plurals.schedule_summary_every_months,
|
||||||
|
recurrence.intervalMonths,
|
||||||
|
recurrence.dayOfMonth,
|
||||||
|
recurrence.intervalMonths,
|
||||||
|
start,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val end = schedule.endTime
|
||||||
|
return if (end == null) {
|
||||||
|
base
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.schedule_summary_until, base, formatTime(end))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun scheduleIssueText(issue: ScheduleIssue): String = stringResource(
|
||||||
|
when (issue) {
|
||||||
|
ScheduleIssue.BLANK_NAME -> R.string.schedule_error_blank_name
|
||||||
|
ScheduleIssue.NAME_TOO_LONG -> R.string.schedule_error_name_too_long
|
||||||
|
ScheduleIssue.PROFILE_MISSING -> R.string.schedule_error_profile_missing
|
||||||
|
ScheduleIssue.FALLBACK_MISSING -> R.string.schedule_error_fallback_missing
|
||||||
|
ScheduleIssue.INVALID_INTERVAL -> R.string.schedule_error_invalid_interval
|
||||||
|
ScheduleIssue.NO_WEEKDAYS -> R.string.schedule_error_no_weekdays
|
||||||
|
ScheduleIssue.INVALID_DAY_OF_MONTH -> R.string.schedule_error_invalid_day
|
||||||
|
ScheduleIssue.PAST_ONE_TIME -> R.string.schedule_error_past_time
|
||||||
|
ScheduleIssue.OVERLAP -> R.string.schedule_error_overlap
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun formatDate(date: LocalDate): String =
|
||||||
|
date.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(Locale.getDefault()))
|
||||||
|
|
||||||
|
private fun formatTime(time: LocalTime): String =
|
||||||
|
time.format(DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(Locale.getDefault()))
|
||||||
|
|
||||||
|
private fun formatInstant(instant: Instant): String =
|
||||||
|
instant.atZone(ZoneId.systemDefault()).format(
|
||||||
|
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)
|
||||||
|
.withLocale(Locale.getDefault()),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun formatTimeInput(time: LocalTime): String =
|
||||||
|
String.format(Locale.ROOT, "%02d:%02d", time.hour, time.minute)
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
package com.krrishg.kwall.ui.schedules
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.lifecycle.viewmodel.initializer
|
||||||
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
|
import com.krrishg.kwall.firewall.FirewallController
|
||||||
|
import com.krrishg.kwall.firewall.RuleProfile
|
||||||
|
import com.krrishg.kwall.scheduler.ProfileSchedule
|
||||||
|
import com.krrishg.kwall.scheduler.ScheduleAlarms
|
||||||
|
import com.krrishg.kwall.scheduler.ScheduleEngine
|
||||||
|
import com.krrishg.kwall.scheduler.ScheduleIssue
|
||||||
|
import com.krrishg.kwall.scheduler.ScheduleStore
|
||||||
|
import com.krrishg.kwall.scheduler.validateScheduleBasics
|
||||||
|
import java.time.Clock
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
data class ScheduleRowUiState(
|
||||||
|
val schedule: ProfileSchedule,
|
||||||
|
val profileName: String?,
|
||||||
|
val fallbackProfileName: String?,
|
||||||
|
val nextRun: Instant?,
|
||||||
|
val issues: List<ScheduleIssue>,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SchedulesUiState(
|
||||||
|
val schedules: List<ScheduleRowUiState> = emptyList(),
|
||||||
|
val profiles: List<RuleProfile> = emptyList(),
|
||||||
|
val activeProfileId: String = "",
|
||||||
|
val exactAlarmsAllowed: Boolean = true,
|
||||||
|
val isLoading: Boolean = true,
|
||||||
|
val isSaving: Boolean = false,
|
||||||
|
val errorIssue: ScheduleIssue? = null,
|
||||||
|
val errorMessage: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
class SchedulesViewModel(
|
||||||
|
private val scheduleStore: ScheduleStore,
|
||||||
|
private val firewallController: FirewallController,
|
||||||
|
private val alarms: ScheduleAlarms,
|
||||||
|
private val clock: Clock = Clock.systemDefaultZone(),
|
||||||
|
private val zoneProvider: () -> ZoneId = { ZoneId.systemDefault() },
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(SchedulesUiState())
|
||||||
|
val uiState: StateFlow<SchedulesUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
combine(
|
||||||
|
scheduleStore.schedules,
|
||||||
|
firewallController.state,
|
||||||
|
) { schedules, firewall -> schedules to firewall }
|
||||||
|
.collect { (schedules, firewall) ->
|
||||||
|
val profileIds = firewall.profiles.map { it.id }.toSet()
|
||||||
|
val now = clock.instant()
|
||||||
|
val zone = zoneProvider()
|
||||||
|
val rows = schedules
|
||||||
|
.sortedWith(
|
||||||
|
compareBy<ProfileSchedule> { it.createdAtMillis }.thenBy { it.id },
|
||||||
|
)
|
||||||
|
.map { schedule ->
|
||||||
|
val issues = validateScheduleBasics(schedule, profileIds)
|
||||||
|
ScheduleRowUiState(
|
||||||
|
schedule = schedule,
|
||||||
|
profileName = firewall.profiles
|
||||||
|
.firstOrNull { it.id == schedule.profileId }
|
||||||
|
?.name,
|
||||||
|
fallbackProfileName = firewall.profiles
|
||||||
|
.firstOrNull { it.id == schedule.fallbackProfileId }
|
||||||
|
?.name,
|
||||||
|
nextRun = if (schedule.enabled && issues.isEmpty()) {
|
||||||
|
ScheduleEngine.nextOccurrence(schedule, now, zone)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
issues = issues,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_uiState.update { current ->
|
||||||
|
current.copy(
|
||||||
|
schedules = rows,
|
||||||
|
profiles = firewall.profiles,
|
||||||
|
activeProfileId = firewall.activeProfileId,
|
||||||
|
isLoading = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refreshExactAlarmStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshExactAlarmStatus() {
|
||||||
|
_uiState.update { it.copy(exactAlarmsAllowed = alarms.canScheduleExactAlarms()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requestExactAlarmPermission() {
|
||||||
|
alarms.requestExactAlarmPermission()
|
||||||
|
refreshExactAlarmStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun save(schedule: ProfileSchedule) {
|
||||||
|
if (_uiState.value.isSaving) return
|
||||||
|
val current = _uiState.value
|
||||||
|
val existing = current.schedules.map { it.schedule }
|
||||||
|
val profileIds = current.profiles.map { it.id }.toSet()
|
||||||
|
val issues = ScheduleEngine.validateSchedule(
|
||||||
|
candidate = schedule,
|
||||||
|
existing = existing,
|
||||||
|
availableProfileIds = profileIds,
|
||||||
|
now = clock.instant(),
|
||||||
|
zone = zoneProvider(),
|
||||||
|
)
|
||||||
|
if (issues.isNotEmpty()) {
|
||||||
|
_uiState.update { it.copy(errorIssue = issues.first(), errorMessage = null) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val isNew = existing.none { it.id == schedule.id }
|
||||||
|
_uiState.update { it.copy(isSaving = true, errorIssue = null, errorMessage = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val saved = if (isNew) {
|
||||||
|
scheduleStore.create(schedule)
|
||||||
|
} else {
|
||||||
|
scheduleStore.update(schedule)
|
||||||
|
}
|
||||||
|
if (!saved) {
|
||||||
|
_uiState.update { it.copy(errorMessage = "Could not save the schedule") }
|
||||||
|
}
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (error: Exception) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(errorMessage = error.message ?: "Could not save the schedule")
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_uiState.update { it.copy(isSaving = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun delete(id: String) {
|
||||||
|
if (_uiState.value.isSaving) return
|
||||||
|
_uiState.update { it.copy(isSaving = true, errorIssue = null, errorMessage = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val deleted = scheduleStore.delete(id)
|
||||||
|
if (!deleted) {
|
||||||
|
_uiState.update { it.copy(errorMessage = "Could not delete the schedule") }
|
||||||
|
}
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (error: Exception) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(errorMessage = error.message ?: "Could not delete the schedule")
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_uiState.update { it.copy(isSaving = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setEnabled(id: String, enabled: Boolean) {
|
||||||
|
if (_uiState.value.isSaving) return
|
||||||
|
val current = _uiState.value
|
||||||
|
val target = current.schedules.firstOrNull { it.schedule.id == id }?.schedule ?: return
|
||||||
|
if (enabled) {
|
||||||
|
val issues = ScheduleEngine.validateSchedule(
|
||||||
|
candidate = target.copy(enabled = true),
|
||||||
|
existing = current.schedules
|
||||||
|
.map { it.schedule }
|
||||||
|
.filterNot { it.id == id },
|
||||||
|
availableProfileIds = current.profiles.map { it.id }.toSet(),
|
||||||
|
now = clock.instant(),
|
||||||
|
zone = zoneProvider(),
|
||||||
|
)
|
||||||
|
if (issues.isNotEmpty()) {
|
||||||
|
_uiState.update { it.copy(errorIssue = issues.first(), errorMessage = null) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_uiState.update { it.copy(isSaving = true, errorIssue = null, errorMessage = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val updated = scheduleStore.setEnabled(id, enabled)
|
||||||
|
if (!updated) {
|
||||||
|
_uiState.update { it.copy(errorMessage = "Could not update the schedule") }
|
||||||
|
}
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (error: Exception) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(errorMessage = error.message ?: "Could not update the schedule")
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_uiState.update { it.copy(isSaving = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearError() {
|
||||||
|
_uiState.update { it.copy(errorIssue = null, errorMessage = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun factory(
|
||||||
|
scheduleStore: ScheduleStore,
|
||||||
|
firewallController: FirewallController,
|
||||||
|
alarms: ScheduleAlarms,
|
||||||
|
): ViewModelProvider.Factory = viewModelFactory {
|
||||||
|
initializer {
|
||||||
|
SchedulesViewModel(
|
||||||
|
scheduleStore = scheduleStore,
|
||||||
|
firewallController = firewallController,
|
||||||
|
alarms = alarms,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package com.krrishg.kwall.ui.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.selection.toggleable
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.krrishg.kwall.R
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppSettingsDialog(
|
||||||
|
appLockEnabled: Boolean,
|
||||||
|
autoBlockNewApps: Boolean,
|
||||||
|
onAppLockEnabledChange: (Boolean) -> Unit,
|
||||||
|
onAutoBlockNewAppsChange: (Boolean) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.settings_title)) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
SettingSwitchRow(
|
||||||
|
title = stringResource(R.string.settings_app_lock),
|
||||||
|
description = stringResource(R.string.settings_app_lock_description),
|
||||||
|
checked = appLockEnabled,
|
||||||
|
onCheckedChange = onAppLockEnabledChange,
|
||||||
|
)
|
||||||
|
SettingSwitchRow(
|
||||||
|
title = stringResource(R.string.settings_auto_block_new_apps),
|
||||||
|
description = stringResource(R.string.settings_auto_block_new_apps_description),
|
||||||
|
checked = autoBlockNewApps,
|
||||||
|
onCheckedChange = onAutoBlockNewAppsChange,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.close))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingSwitchRow(
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
checked: Boolean,
|
||||||
|
onCheckedChange: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.toggleable(
|
||||||
|
value = checked,
|
||||||
|
onValueChange = onCheckedChange,
|
||||||
|
role = Role.Switch,
|
||||||
|
)
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = description,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = checked,
|
||||||
|
onCheckedChange = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.krrishg.kwall.ui.theme
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
|
||||||
|
private val LightColors = lightColorScheme(
|
||||||
|
primary = Color(0xFF1B5E4A),
|
||||||
|
secondary = Color(0xFF4B6358),
|
||||||
|
tertiary = Color(0xFF3E6374),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val DarkColors = darkColorScheme(
|
||||||
|
primary = Color(0xFF86D6B5),
|
||||||
|
secondary = Color(0xFFB2CCBE),
|
||||||
|
tertiary = Color(0xFFA6CDE0),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun KwallTheme(
|
||||||
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val colorScheme = if (darkTheme) DarkColors else LightColors
|
||||||
|
val view = LocalView.current
|
||||||
|
if (!view.isInEditMode) {
|
||||||
|
SideEffect {
|
||||||
|
val window = (view.context as Activity).window
|
||||||
|
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MaterialTheme(colorScheme = colorScheme, content = content)
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M19,19H5V5h7V3H5c-1.11,0 -1.99,0.9 -1.99,2L3,19c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2v-7h-2v7zM14,3v2h3.59l-9.83,9.83 1.41,1.41L19,6.41V10h2V3h-7z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M6,19h4V5H6v14zM14,5v14h4V5h-4z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M8,5v14l11,-7z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#FF101013</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Kwall</string>
|
||||||
|
<string name="lock_title">Unlock Kwall</string>
|
||||||
|
<string name="lock_subtitle">Use your device PIN, pattern, password, or biometrics</string>
|
||||||
|
<string name="lock_prompt_title">Kwall is locked</string>
|
||||||
|
<string name="lock_prompt_description">Authenticate with your device screen lock to continue.</string>
|
||||||
|
<string name="lock_unlock">Unlock</string>
|
||||||
|
<string name="lock_no_device_lock_title">Set up a screen lock</string>
|
||||||
|
<string name="lock_no_device_lock_description">Kwall is protected by your device screen lock. Set a PIN, pattern, password, or biometrics to open the app.</string>
|
||||||
|
<string name="lock_open_settings">Open security settings</string>
|
||||||
|
<string name="lock_disable_app_lock">Turn off app lock</string>
|
||||||
|
<string name="apps_title">Apps</string>
|
||||||
|
<string name="settings_title">Settings</string>
|
||||||
|
<string name="settings_app_lock">Require device unlock</string>
|
||||||
|
<string name="settings_app_lock_description">Ask for your device PIN, pattern, password, or biometrics when opening Kwall.</string>
|
||||||
|
<string name="settings_auto_block_new_apps">Auto-block new apps</string>
|
||||||
|
<string name="settings_auto_block_new_apps_description">Select newly installed user apps for blocking in the active profile. The firewall switch controls when those rules take effect.</string>
|
||||||
|
<string name="settings_open">Settings</string>
|
||||||
|
<string name="profiles_title">Profiles</string>
|
||||||
|
<string name="profile_default_name">Default</string>
|
||||||
|
<string name="profile_new">New profile</string>
|
||||||
|
<string name="profile_actions">Profile actions</string>
|
||||||
|
<string name="profile_rename">Rename</string>
|
||||||
|
<string name="profile_duplicate">Duplicate</string>
|
||||||
|
<string name="profile_delete">Delete</string>
|
||||||
|
<string name="profile_switch_description">Switch profile. Active profile: %1$s</string>
|
||||||
|
<string name="profile_create_title">New profile</string>
|
||||||
|
<string name="profile_rename_title">Rename profile</string>
|
||||||
|
<string name="profile_duplicate_title">Duplicate profile</string>
|
||||||
|
<string name="profile_name_label">Profile name</string>
|
||||||
|
<string name="profile_name_blank">Enter a profile name</string>
|
||||||
|
<string name="profile_name_too_long">Profile name must be 40 characters or fewer</string>
|
||||||
|
<string name="profile_name_duplicate">A profile with this name already exists</string>
|
||||||
|
<string name="profile_duplicate_name">%1$s copy</string>
|
||||||
|
<string name="profile_delete_title">Delete profile?</string>
|
||||||
|
<string name="profile_delete_message">Delete \"%1$s\"? Its blocked app selections will be removed. If this is the active profile, another profile becomes active.</string>
|
||||||
|
<string name="save">Save</string>
|
||||||
|
<string name="cancel">Cancel</string>
|
||||||
|
<string name="close">Close</string>
|
||||||
|
<string name="system_app_badge">System</string>
|
||||||
|
<string name="work_profile_badge">Work</string>
|
||||||
|
<string name="refresh">Refresh</string>
|
||||||
|
<string name="search_apps">Search apps</string>
|
||||||
|
<string name="clear_search">Clear search</string>
|
||||||
|
<string name="filter_all">All</string>
|
||||||
|
<string name="filter_blocked">Blocked</string>
|
||||||
|
<string name="filter_unblocked">Unblocked</string>
|
||||||
|
<string name="show_system_apps">Show system apps</string>
|
||||||
|
<string name="expand_details">Show details</string>
|
||||||
|
<string name="collapse_details">Hide details</string>
|
||||||
|
<string name="loading_apps">Loading apps</string>
|
||||||
|
<string name="load_error">Could not load apps</string>
|
||||||
|
<string name="retry">Retry</string>
|
||||||
|
<string name="user_apps_empty_title">No user apps found</string>
|
||||||
|
<string name="user_apps_empty_description">Turn on system apps to see everything installed on this device.</string>
|
||||||
|
<string name="all_apps_empty_title">No installed apps found</string>
|
||||||
|
<string name="all_apps_empty_description">No apps matched this filter.</string>
|
||||||
|
<plurals name="apps_count">
|
||||||
|
<item quantity="one">%1$d app</item>
|
||||||
|
<item quantity="other">%1$d apps</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="profile_blocked_apps">
|
||||||
|
<item quantity="one">%1$d blocked app</item>
|
||||||
|
<item quantity="other">%1$d blocked apps</item>
|
||||||
|
</plurals>
|
||||||
|
|
||||||
|
<string name="firewall_title">Firewall</string>
|
||||||
|
<string name="firewall_info_open">Firewall status and messages</string>
|
||||||
|
<string name="firewall_description">Block internet for selected apps</string>
|
||||||
|
<string name="firewall_state_checking">Checking Shizuku…</string>
|
||||||
|
<string name="firewall_state_off">Firewall off</string>
|
||||||
|
<string name="firewall_state_no_apps">No apps blocked</string>
|
||||||
|
<string name="firewall_state_android">Requires Android 13 or newer</string>
|
||||||
|
<string name="firewall_state_shizuku_missing">Shizuku is not installed</string>
|
||||||
|
<string name="firewall_state_shizuku_stopped">Shizuku is not running</string>
|
||||||
|
<string name="firewall_state_permission">Shizuku permission is required</string>
|
||||||
|
<string name="firewall_state_unsupported">This ROM does not support per-app blocking</string>
|
||||||
|
<string name="firewall_action_install_shizuku">Install Shizuku</string>
|
||||||
|
<string name="firewall_action_open_shizuku">Open Shizuku</string>
|
||||||
|
<string name="firewall_action_grant">Grant permission</string>
|
||||||
|
<string name="firewall_reset_notice">Blocking stops after a reboot. Once Shizuku is running, Kwall reapplies your selections automatically.</string>
|
||||||
|
<string name="firewall_uninstall_notice">Turn the firewall off before uninstalling Kwall. If Shizuku is not running during uninstall, existing rules stay active until the next reboot.</string>
|
||||||
|
<string name="firewall_selection_paused">Selections are paused while the firewall is off.</string>
|
||||||
|
<string name="firewall_rules_persist">Existing rules stay active until reboot or until the firewall is turned off.</string>
|
||||||
|
<string name="firewall_drift_checking">Consistency check running…</string>
|
||||||
|
<string name="firewall_drift_in_sync">Consistency check passed</string>
|
||||||
|
<string name="firewall_drift_repaired">Consistency check fixed changed rules</string>
|
||||||
|
<string name="firewall_drift_detected">Consistency check found changes that could not be fixed</string>
|
||||||
|
<string name="firewall_drift_unavailable">Consistency check is unavailable on this device</string>
|
||||||
|
<string name="firewall_drift_automatic">Kwall checks its rules automatically and repairs changes.</string>
|
||||||
|
<string name="firewall_channel_name">Firewall status</string>
|
||||||
|
<string name="firewall_auto_block_channel_name">Auto-blocked apps</string>
|
||||||
|
<string name="firewall_auto_block_title">App blocked automatically</string>
|
||||||
|
<string name="firewall_auto_block_text">%1$s was selected for blocking because it was just installed.</string>
|
||||||
|
<string name="firewall_notification_on_title">Firewall on</string>
|
||||||
|
<string name="firewall_notification_attention_title">Firewall needs attention</string>
|
||||||
|
<string name="firewall_notification_no_selected_apps">No apps selected</string>
|
||||||
|
<string name="firewall_notification_error">Could not update firewall: %1$s</string>
|
||||||
|
<string name="firewall_notification_pause">Pause</string>
|
||||||
|
<string name="firewall_notification_resume">Resume</string>
|
||||||
|
<string name="firewall_notification_open">Open</string>
|
||||||
|
<string name="firewall_paused_title">Firewall paused</string>
|
||||||
|
<string name="firewall_blocked_reason_system">System UID apps cannot be blocked</string>
|
||||||
|
<string name="firewall_blocked_reason_shared">Shares a UID with other apps</string>
|
||||||
|
<string name="firewall_blocked_reason_no_internet">No internet permission</string>
|
||||||
|
<string name="firewall_blocked_reason_firewall">Required by the firewall</string>
|
||||||
|
<string name="firewall_blocked_reason_work_only">This app is only in the work profile; per-profile blocking needs the binder path</string>
|
||||||
|
<string name="firewall_blocked_reason_unresolved">Could not resolve this app</string>
|
||||||
|
<string name="firewall_blocked_warning_all_profiles">This device only supports package-wide blocking; other profiles will also be blocked</string>
|
||||||
|
<plurals name="firewall_blocked_apps">
|
||||||
|
<item quantity="one">%1$d app blocked</item>
|
||||||
|
<item quantity="other">%1$d apps blocked</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="firewall_notification_selected_apps">
|
||||||
|
<item quantity="one">%1$d app selected</item>
|
||||||
|
<item quantity="other">%1$d apps selected</item>
|
||||||
|
</plurals>
|
||||||
|
|
||||||
|
<string name="detail_back">Back</string>
|
||||||
|
<string name="detail_refresh">Refresh network information</string>
|
||||||
|
<string name="detail_block_title">Internet access</string>
|
||||||
|
<string name="detail_block_description">Block all internet traffic for this app</string>
|
||||||
|
<string name="detail_firewall_not_ready">Start Shizuku and grant permission to change this setting.</string>
|
||||||
|
<string name="detail_usage_title">Data usage</string>
|
||||||
|
<string name="detail_usage_today">Today</string>
|
||||||
|
<string name="detail_usage_week">7 days</string>
|
||||||
|
<string name="detail_usage_month">30 days</string>
|
||||||
|
<string name="detail_usage_download">Downloaded</string>
|
||||||
|
<string name="detail_usage_upload">Uploaded</string>
|
||||||
|
<string name="detail_usage_loading">Loading usage</string>
|
||||||
|
<string name="detail_usage_permission">Allow usage access to see data used by this app.</string>
|
||||||
|
<string name="detail_usage_permission_action">Grant usage access</string>
|
||||||
|
<string name="detail_connections_title">Connections</string>
|
||||||
|
<string name="detail_connections_loading">Loading connections</string>
|
||||||
|
<string name="detail_connections_empty">No active connections.</string>
|
||||||
|
<string name="detail_connections_unavailable">Live connections are unavailable on this device.</string>
|
||||||
|
<string name="detail_connections_listening">Listening on port %1$d</string>
|
||||||
|
<string name="detail_connections_listening_title">Listening ports</string>
|
||||||
|
<string name="detail_connections_local_address">Local address %1$s</string>
|
||||||
|
<string name="detail_connections_all_interfaces">All interfaces</string>
|
||||||
|
<string name="detail_connections_unknown">Unknown endpoint</string>
|
||||||
|
<string name="detail_connections_active">Active</string>
|
||||||
|
<string name="detail_connections_note">Existing connections may stay open after blocking this app.</string>
|
||||||
|
<plurals name="detail_connections_count">
|
||||||
|
<item quantity="one">%1$d active</item>
|
||||||
|
<item quantity="other">%1$d active</item>
|
||||||
|
</plurals>
|
||||||
|
|
||||||
|
<string name="back">Back</string>
|
||||||
|
<string name="schedules_open">Schedules</string>
|
||||||
|
<string name="schedules_title">Schedules</string>
|
||||||
|
<string name="schedule_add">Add schedule</string>
|
||||||
|
<string name="schedule_edit">Edit</string>
|
||||||
|
<string name="schedule_delete">Delete</string>
|
||||||
|
<string name="schedule_delete_title">Delete schedule?</string>
|
||||||
|
<string name="schedule_delete_message">Delete \"%1$s\"?</string>
|
||||||
|
<string name="schedule_create_title">New schedule</string>
|
||||||
|
<string name="schedule_edit_title">Edit schedule</string>
|
||||||
|
<string name="schedule_name_label">Schedule name</string>
|
||||||
|
<string name="schedule_repeat_label">Repeat</string>
|
||||||
|
<string name="schedule_repeat_once">Once</string>
|
||||||
|
<string name="schedule_repeat_daily">Daily</string>
|
||||||
|
<string name="schedule_repeat_weekly">Weekly</string>
|
||||||
|
<string name="schedule_repeat_monthly">Monthly</string>
|
||||||
|
<string name="schedule_date_value">Start date: %1$s</string>
|
||||||
|
<string name="schedule_no_end_hint">Without an end time, this profile stays active until another schedule starts.</string>
|
||||||
|
<string name="schedule_interval_days">Every N days</string>
|
||||||
|
<string name="schedule_interval_weeks">Every N weeks</string>
|
||||||
|
<string name="schedule_interval_months">Every N months</string>
|
||||||
|
<string name="schedule_weekdays_label">Days of week</string>
|
||||||
|
<string name="schedule_day_of_month">Day of month</string>
|
||||||
|
<string name="schedule_start_time_value">Starts at %1$s</string>
|
||||||
|
<string name="schedule_end_time_label">End time</string>
|
||||||
|
<string name="schedule_end_time_value">Ends at %1$s</string>
|
||||||
|
<string name="schedule_enabled_label">Schedule enabled</string>
|
||||||
|
<string name="schedule_profile_label">Profile</string>
|
||||||
|
<string name="schedule_fallback_label">Revert to</string>
|
||||||
|
<string name="schedule_select_profile">Select a profile</string>
|
||||||
|
<string name="schedule_missing_profile">Profile missing</string>
|
||||||
|
<string name="schedule_next_run">Next: %1$s</string>
|
||||||
|
<string name="schedule_loading">Loading schedules</string>
|
||||||
|
<string name="schedule_empty">No schedules yet. Add one to activate a profile automatically.</string>
|
||||||
|
<string name="schedule_needs_profile">Create a profile before adding a schedule.</string>
|
||||||
|
<string name="schedule_exact_alarm_unavailable">Exact alarms are disabled. Scheduled profiles may start a few minutes late.</string>
|
||||||
|
<string name="schedule_allow_exact_alarms">Allow</string>
|
||||||
|
<string name="schedule_error_blank_name">Enter a schedule name</string>
|
||||||
|
<string name="schedule_error_name_too_long">Schedule name is too long</string>
|
||||||
|
<string name="schedule_error_profile_missing">Select a profile that still exists</string>
|
||||||
|
<string name="schedule_error_fallback_missing">Select a fallback profile that still exists</string>
|
||||||
|
<string name="schedule_error_invalid_interval">The interval must be at least 1</string>
|
||||||
|
<string name="schedule_error_no_weekdays">Select at least one weekday</string>
|
||||||
|
<string name="schedule_error_invalid_day">Choose a day between 1 and 31</string>
|
||||||
|
<string name="schedule_error_past_time">Choose a date and time in the future</string>
|
||||||
|
<string name="schedule_error_overlap">This schedule overlaps another schedule</string>
|
||||||
|
<string name="schedule_summary_once">Once on %1$s at %2$s</string>
|
||||||
|
<string name="schedule_summary_daily">Every day at %1$s</string>
|
||||||
|
<plurals name="schedule_summary_every_days">
|
||||||
|
<item quantity="one">Every %1$d day at %2$s</item>
|
||||||
|
<item quantity="other">Every %1$d days at %2$s</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="schedule_summary_weekly">Every week on %1$s at %2$s</string>
|
||||||
|
<plurals name="schedule_summary_every_weeks">
|
||||||
|
<item quantity="one">Every %1$d week on %2$s at %3$s</item>
|
||||||
|
<item quantity="other">Every %1$d weeks on %2$s at %3$s</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="schedule_summary_monthly">Day %1$d of every month at %2$s</string>
|
||||||
|
<plurals name="schedule_summary_every_months">
|
||||||
|
<item quantity="one">Day %1$d of every %2$d month at %3$s</item>
|
||||||
|
<item quantity="other">Day %1$d of every %2$d months at %3$s</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="schedule_summary_until">%1$s until %2$s</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.Kwall" parent="android:Theme.Material.Light.NoActionBar" />
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<data-extraction-rules>
|
||||||
|
<cloud-backup>
|
||||||
|
<exclude domain="root" path="." />
|
||||||
|
<exclude domain="file" path="." />
|
||||||
|
<exclude domain="database" path="." />
|
||||||
|
<exclude domain="sharedpref" path="." />
|
||||||
|
<exclude domain="external" path="." />
|
||||||
|
</cloud-backup>
|
||||||
|
<device-transfer>
|
||||||
|
<exclude domain="root" path="." />
|
||||||
|
<exclude domain="file" path="." />
|
||||||
|
<exclude domain="database" path="." />
|
||||||
|
<exclude domain="sharedpref" path="." />
|
||||||
|
<exclude domain="external" path="." />
|
||||||
|
</device-transfer>
|
||||||
|
</data-extraction-rules>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.krrishg.kwall
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.TestDispatcher
|
||||||
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import org.junit.rules.TestWatcher
|
||||||
|
import org.junit.runner.Description
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class MainDispatcherRule(
|
||||||
|
private val dispatcher: TestDispatcher = UnconfinedTestDispatcher(),
|
||||||
|
) : TestWatcher() {
|
||||||
|
|
||||||
|
override fun starting(description: Description) {
|
||||||
|
Dispatchers.setMain(dispatcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun finished(description: Description) {
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.krrishg.kwall.firewall
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class AppKeyTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `serializes and parses a personal key`() {
|
||||||
|
val key = AppKey(0, "com.example.app")
|
||||||
|
|
||||||
|
assertEquals("0:com.example.app", key.toString())
|
||||||
|
assertEquals(key, AppKey.parse("0:com.example.app"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `serializes and parses a work profile key`() {
|
||||||
|
val key = AppKey(10, "com.example.app")
|
||||||
|
|
||||||
|
assertEquals("10:com.example.app", key.toString())
|
||||||
|
assertEquals(key, AppKey.parse("10:com.example.app"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rejects malformed keys`() {
|
||||||
|
assertNull(AppKey.parse(""))
|
||||||
|
assertNull(AppKey.parse("com.example.app"))
|
||||||
|
assertNull(AppKey.parse("x:com.example.app"))
|
||||||
|
assertNull(AppKey.parse("0:"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
package com.krrishg.kwall.firewall
|
||||||
|
|
||||||
|
import com.krrishg.kwall.data.AppRepository
|
||||||
|
import com.krrishg.kwall.data.InstalledApp
|
||||||
|
import com.krrishg.kwall.settings.AppSettingsStore
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class AutoBlockNewAppsTest {
|
||||||
|
|
||||||
|
private val notes = app("com.example.notes", uid = 10123)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `does nothing when the setting is disabled`() = runTest {
|
||||||
|
val repository = FakeAppRepository(listOf(notes))
|
||||||
|
val controller = FakeFirewallController()
|
||||||
|
val useCase = useCase(repository, controller, enabled = false)
|
||||||
|
|
||||||
|
val blocked = useCase.onPackageAdded(notes.packageName, notes.userId)
|
||||||
|
|
||||||
|
assertNull(blocked)
|
||||||
|
assertEquals(0, repository.loadCount)
|
||||||
|
assertTrue(controller.added.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `blocks a newly installed blockable app`() = runTest {
|
||||||
|
val repository = FakeAppRepository(listOf(notes))
|
||||||
|
val controller = FakeFirewallController()
|
||||||
|
val useCase = useCase(repository, controller)
|
||||||
|
|
||||||
|
val blocked = useCase.onPackageAdded(notes.packageName, notes.userId)
|
||||||
|
|
||||||
|
assertEquals(notes, blocked)
|
||||||
|
assertEquals(listOf(notes), controller.added)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `does not block an app that is already selected`() = runTest {
|
||||||
|
val repository = FakeAppRepository(listOf(notes))
|
||||||
|
val controller = FakeFirewallController(
|
||||||
|
initialState = state(blocked = setOf(notes.key)),
|
||||||
|
)
|
||||||
|
val useCase = useCase(repository, controller)
|
||||||
|
|
||||||
|
val blocked = useCase.onPackageAdded(notes.packageName, notes.userId)
|
||||||
|
|
||||||
|
assertNull(blocked)
|
||||||
|
assertTrue(controller.added.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `skips system apps`() = runTest {
|
||||||
|
val system = app("com.example.system", uid = 10124, isSystem = true)
|
||||||
|
val repository = FakeAppRepository(listOf(system))
|
||||||
|
val controller = FakeFirewallController()
|
||||||
|
val useCase = useCase(repository, controller)
|
||||||
|
|
||||||
|
val blocked = useCase.onPackageAdded(system.packageName, system.userId)
|
||||||
|
|
||||||
|
assertNull(blocked)
|
||||||
|
assertTrue(controller.added.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `skips apps without internet permission`() = runTest {
|
||||||
|
val offline = app("com.example.offline", uid = 10124, hasInternetPermission = false)
|
||||||
|
val repository = FakeAppRepository(listOf(offline))
|
||||||
|
val controller = FakeFirewallController()
|
||||||
|
val useCase = useCase(repository, controller)
|
||||||
|
|
||||||
|
val blocked = useCase.onPackageAdded(offline.packageName, offline.userId)
|
||||||
|
|
||||||
|
assertNull(blocked)
|
||||||
|
assertTrue(controller.added.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `skips apps that share a uid`() = runTest {
|
||||||
|
val shared = app("com.example.shared", uid = 10124)
|
||||||
|
val companion = app("com.example.companion", uid = 10124)
|
||||||
|
val repository = FakeAppRepository(listOf(shared, companion))
|
||||||
|
val controller = FakeFirewallController()
|
||||||
|
val useCase = useCase(repository, controller)
|
||||||
|
|
||||||
|
val blocked = useCase.onPackageAdded(shared.packageName, shared.userId)
|
||||||
|
|
||||||
|
assertNull(blocked)
|
||||||
|
assertTrue(controller.added.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `skips firewall packages`() = runTest {
|
||||||
|
val shizuku = app(SHIZUKU_PACKAGE, uid = 10124)
|
||||||
|
val repository = FakeAppRepository(listOf(shizuku))
|
||||||
|
val controller = FakeFirewallController()
|
||||||
|
val useCase = useCase(repository, controller)
|
||||||
|
|
||||||
|
val blocked = useCase.onPackageAdded(shizuku.packageName, shizuku.userId)
|
||||||
|
|
||||||
|
assertNull(blocked)
|
||||||
|
assertTrue(controller.added.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `skips work-only apps when shell is the only transport`() = runTest {
|
||||||
|
val work = app(
|
||||||
|
packageName = "com.example.work",
|
||||||
|
uid = 10 * PER_USER_RANGE + 10123,
|
||||||
|
userId = 10,
|
||||||
|
isWorkProfile = true,
|
||||||
|
)
|
||||||
|
val repository = FakeAppRepository(listOf(work))
|
||||||
|
val controller = FakeFirewallController(
|
||||||
|
initialAvailability = FirewallAvailability.Ready(
|
||||||
|
rulesTransport = RulesTransport.SHELL,
|
||||||
|
chainTransport = ChainTransport.SHELL,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val useCase = useCase(repository, controller)
|
||||||
|
|
||||||
|
val blocked = useCase.onPackageAdded(work.packageName, work.userId)
|
||||||
|
|
||||||
|
assertNull(blocked)
|
||||||
|
assertTrue(controller.added.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `skips work-only apps while the transport is unknown`() = runTest {
|
||||||
|
val work = app(
|
||||||
|
packageName = "com.example.work",
|
||||||
|
uid = 10 * PER_USER_RANGE + 10123,
|
||||||
|
userId = 10,
|
||||||
|
isWorkProfile = true,
|
||||||
|
)
|
||||||
|
val repository = FakeAppRepository(listOf(work))
|
||||||
|
val controller = FakeFirewallController(
|
||||||
|
initialAvailability = FirewallAvailability.Checking,
|
||||||
|
)
|
||||||
|
val useCase = useCase(repository, controller)
|
||||||
|
|
||||||
|
val blocked = useCase.onPackageAdded(work.packageName, work.userId)
|
||||||
|
|
||||||
|
assertNull(blocked)
|
||||||
|
assertTrue(controller.added.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `blocks work profile apps when binder is available`() = runTest {
|
||||||
|
val work = app(
|
||||||
|
packageName = "com.example.work",
|
||||||
|
uid = 10 * PER_USER_RANGE + 10123,
|
||||||
|
userId = 10,
|
||||||
|
isWorkProfile = true,
|
||||||
|
)
|
||||||
|
val repository = FakeAppRepository(listOf(work))
|
||||||
|
val controller = FakeFirewallController()
|
||||||
|
val useCase = useCase(repository, controller)
|
||||||
|
|
||||||
|
val blocked = useCase.onPackageAdded(work.packageName, work.userId)
|
||||||
|
|
||||||
|
assertEquals(work, blocked)
|
||||||
|
assertEquals(listOf(work), controller.added)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `matches the installed app to the requested user`() = runTest {
|
||||||
|
val personal = app("com.example.chat", uid = 10123)
|
||||||
|
val work = app(
|
||||||
|
packageName = "com.example.chat",
|
||||||
|
uid = 10 * PER_USER_RANGE + 10123,
|
||||||
|
userId = 10,
|
||||||
|
isWorkProfile = true,
|
||||||
|
)
|
||||||
|
val repository = FakeAppRepository(listOf(personal, work))
|
||||||
|
val controller = FakeFirewallController()
|
||||||
|
val useCase = useCase(repository, controller)
|
||||||
|
|
||||||
|
val blocked = useCase.onPackageAdded(work.packageName, work.userId)
|
||||||
|
|
||||||
|
assertEquals(work, blocked)
|
||||||
|
assertEquals(listOf(work), controller.added)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun useCase(
|
||||||
|
repository: AppRepository,
|
||||||
|
controller: FirewallController,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
) = AutoBlockNewApps(
|
||||||
|
settingsStore = FakeSettingsStore(enabled),
|
||||||
|
repository = repository,
|
||||||
|
firewallController = controller,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun app(
|
||||||
|
packageName: String,
|
||||||
|
uid: Int,
|
||||||
|
userId: Int = 0,
|
||||||
|
isSystem: Boolean = false,
|
||||||
|
isWorkProfile: Boolean = false,
|
||||||
|
hasInternetPermission: Boolean = true,
|
||||||
|
) = InstalledApp(
|
||||||
|
packageName = packageName,
|
||||||
|
label = packageName,
|
||||||
|
isSystem = isSystem,
|
||||||
|
icon = null,
|
||||||
|
uid = uid,
|
||||||
|
userId = userId,
|
||||||
|
isWorkProfile = isWorkProfile,
|
||||||
|
hasInternetPermission = hasInternetPermission,
|
||||||
|
)
|
||||||
|
|
||||||
|
private class FakeSettingsStore(
|
||||||
|
autoBlockEnabled: Boolean,
|
||||||
|
) : AppSettingsStore {
|
||||||
|
|
||||||
|
private val autoBlock = MutableStateFlow(autoBlockEnabled)
|
||||||
|
override val appLockEnabled = MutableStateFlow(true)
|
||||||
|
override val autoBlockNewApps = autoBlock
|
||||||
|
|
||||||
|
override suspend fun setAppLockEnabled(enabled: Boolean) {
|
||||||
|
appLockEnabled.value = enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setAutoBlockNewApps(enabled: Boolean) {
|
||||||
|
autoBlock.value = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun state(
|
||||||
|
enabled: Boolean = false,
|
||||||
|
blocked: Set<AppKey> = emptySet(),
|
||||||
|
): FirewallState = FirewallState(
|
||||||
|
enabled = enabled,
|
||||||
|
profiles = listOf(defaultRuleProfile(blockedApps = blocked, createdAtMillis = 1L)),
|
||||||
|
activeProfileId = DEFAULT_PROFILE_ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
private class FakeAppRepository(
|
||||||
|
private val apps: List<InstalledApp>,
|
||||||
|
) : AppRepository {
|
||||||
|
|
||||||
|
var loadCount = 0
|
||||||
|
|
||||||
|
override suspend fun loadInstalledApps(): List<InstalledApp> {
|
||||||
|
loadCount++
|
||||||
|
return apps
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeFirewallController(
|
||||||
|
initialState: FirewallState = FirewallState(),
|
||||||
|
initialAvailability: FirewallAvailability = FirewallAvailability.Ready(
|
||||||
|
rulesTransport = RulesTransport.BINDER,
|
||||||
|
chainTransport = ChainTransport.SHELL,
|
||||||
|
),
|
||||||
|
) : FirewallController {
|
||||||
|
|
||||||
|
private val stateFlow = MutableStateFlow(initialState)
|
||||||
|
override val state: StateFlow<FirewallState> = stateFlow
|
||||||
|
override val availability = MutableStateFlow(initialAvailability)
|
||||||
|
override val error = MutableStateFlow<String?>(null)
|
||||||
|
override val drift = MutableStateFlow(FirewallDriftState())
|
||||||
|
|
||||||
|
val added = mutableListOf<InstalledApp>()
|
||||||
|
|
||||||
|
override suspend fun setBlocked(app: InstalledApp, blocked: Boolean) = Unit
|
||||||
|
|
||||||
|
override suspend fun addBlockedSelection(app: InstalledApp): Boolean {
|
||||||
|
if (app.key in stateFlow.value.blockedApps) return false
|
||||||
|
added += app
|
||||||
|
stateFlow.update { current ->
|
||||||
|
current.updateActiveApps { apps -> apps + app.key }
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setEnabled(enabled: Boolean) = Unit
|
||||||
|
|
||||||
|
override suspend fun applyScheduledProfile(
|
||||||
|
profileId: String,
|
||||||
|
enableFirewall: Boolean?,
|
||||||
|
): Boolean = true
|
||||||
|
|
||||||
|
override suspend fun restore() = Unit
|
||||||
|
|
||||||
|
override suspend fun checkForDrift() = Unit
|
||||||
|
|
||||||
|
override suspend fun refreshAvailability() = Unit
|
||||||
|
|
||||||
|
override fun requestPermission() = Unit
|
||||||
|
|
||||||
|
override suspend fun removeBlocked(key: AppKey) = Unit
|
||||||
|
|
||||||
|
override suspend fun removeBlockedForUser(userId: Int) = Unit
|
||||||
|
|
||||||
|
override suspend fun switchProfile(id: String) = Unit
|
||||||
|
|
||||||
|
override suspend fun createProfile(name: String) = false
|
||||||
|
|
||||||
|
override suspend fun duplicateProfile(sourceId: String, name: String) = false
|
||||||
|
|
||||||
|
override suspend fun renameProfile(id: String, name: String) = false
|
||||||
|
|
||||||
|
override suspend fun deleteProfile(id: String) = false
|
||||||
|
|
||||||
|
override suspend fun removeBlockedEverywhere(key: AppKey) = Unit
|
||||||
|
|
||||||
|
override suspend fun removeBlockedForUserEverywhere(userId: Int) = Unit
|
||||||
|
|
||||||
|
private fun FirewallState.updateActiveApps(
|
||||||
|
transform: (Set<AppKey>) -> Set<AppKey>,
|
||||||
|
): FirewallState {
|
||||||
|
val activeId = activeProfile.id
|
||||||
|
return copy(
|
||||||
|
profiles = profiles.map { profile ->
|
||||||
|
if (profile.id == activeId) {
|
||||||
|
profile.copy(blockedApps = transform(profile.blockedApps))
|
||||||
|
} else {
|
||||||
|
profile
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val PER_USER_RANGE = 100_000
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package com.krrishg.kwall.firewall
|
||||||
|
|
||||||
|
import com.krrishg.kwall.data.InstalledApp
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class BlockabilityTest {
|
||||||
|
|
||||||
|
private val firewallPackages = setOf("moe.shizuku.privileged.api")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `normal app is blockable`() {
|
||||||
|
val app = app(uid = 10123)
|
||||||
|
|
||||||
|
assertEquals(AppBlockability.BLOCKABLE, blockabilityOf(app, mapOf(10123 to 1), firewallPackages))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `system uid is not blockable`() {
|
||||||
|
val app = app(uid = 1000)
|
||||||
|
|
||||||
|
assertEquals(AppBlockability.SYSTEM_UID, blockabilityOf(app, mapOf(1000 to 1), firewallPackages))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `work profile app is blockable by its own uid`() {
|
||||||
|
val app = app(uid = 10 * PER_USER_RANGE + 10123, userId = 10, isWorkProfile = true)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
AppBlockability.BLOCKABLE,
|
||||||
|
blockabilityOf(app, mapOf(10 * PER_USER_RANGE + 10123 to 1), firewallPackages),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `work profile system uid is not blockable`() {
|
||||||
|
val app = app(uid = 10 * PER_USER_RANGE + 1000, userId = 10, isWorkProfile = true)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
AppBlockability.SYSTEM_UID,
|
||||||
|
blockabilityOf(app, mapOf(app.uid to 1), firewallPackages),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `shared uid is not blockable per app`() {
|
||||||
|
val app = app(uid = 10124)
|
||||||
|
|
||||||
|
assertEquals(AppBlockability.SHARED_UID, blockabilityOf(app, mapOf(10124 to 2), firewallPackages))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `app without internet permission is not blockable`() {
|
||||||
|
val app = app(uid = 10125, hasInternetPermission = false)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
AppBlockability.NO_INTERNET,
|
||||||
|
blockabilityOf(app, mapOf(10125 to 1), firewallPackages),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `shizuku package is reserved`() {
|
||||||
|
val app = app(packageName = "moe.shizuku.privileged.api", uid = 10126)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
AppBlockability.FIREWALL_APP,
|
||||||
|
blockabilityOf(app, mapOf(10126 to 1), firewallPackages),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unresolved uid is not blockable`() {
|
||||||
|
val app = app(uid = 0)
|
||||||
|
|
||||||
|
assertEquals(AppBlockability.UNRESOLVED, blockabilityOf(app, emptyMap(), firewallPackages))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `work profile app is only blockable with binder`() {
|
||||||
|
val app = app(uid = 10 * PER_USER_RANGE + 10123, userId = 10, isWorkProfile = true)
|
||||||
|
|
||||||
|
val shell = blockabilityFor(
|
||||||
|
app = app,
|
||||||
|
uidCounts = mapOf(app.uid to 1),
|
||||||
|
packageCounts = mapOf(app.packageName to 1),
|
||||||
|
rulesTransport = RulesTransport.SHELL,
|
||||||
|
firewallPackages = firewallPackages,
|
||||||
|
)
|
||||||
|
val binder = blockabilityFor(
|
||||||
|
app = app,
|
||||||
|
uidCounts = mapOf(app.uid to 1),
|
||||||
|
packageCounts = mapOf(app.packageName to 1),
|
||||||
|
rulesTransport = RulesTransport.BINDER,
|
||||||
|
firewallPackages = firewallPackages,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(AppBlockability.WORK_PROFILE_ONLY, shell.blockability)
|
||||||
|
assertEquals(AppBlockability.BLOCKABLE, binder.blockability)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `shell transport warns when the package exists in multiple profiles`() {
|
||||||
|
val personal = app(packageName = "com.example.chat", uid = 10123)
|
||||||
|
val work = app(
|
||||||
|
packageName = "com.example.chat",
|
||||||
|
uid = 10 * PER_USER_RANGE + 10123,
|
||||||
|
userId = 10,
|
||||||
|
isWorkProfile = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
val assessment = blockabilityFor(
|
||||||
|
app = personal,
|
||||||
|
uidCounts = mapOf(personal.uid to 1, work.uid to 1),
|
||||||
|
packageCounts = mapOf(personal.packageName to 2),
|
||||||
|
rulesTransport = RulesTransport.SHELL,
|
||||||
|
firewallPackages = firewallPackages,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(AppBlockability.BLOCKABLE, assessment.blockability)
|
||||||
|
assertEquals(true, assessment.warnAllProfiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `binder transport does not warn about other profiles`() {
|
||||||
|
val app = app(uid = 10123)
|
||||||
|
|
||||||
|
val assessment = blockabilityFor(
|
||||||
|
app = app,
|
||||||
|
uidCounts = mapOf(app.uid to 1),
|
||||||
|
packageCounts = mapOf(app.packageName to 2),
|
||||||
|
rulesTransport = RulesTransport.BINDER,
|
||||||
|
firewallPackages = firewallPackages,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(false, assessment.warnAllProfiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun app(
|
||||||
|
packageName: String = "com.example.app",
|
||||||
|
uid: Int,
|
||||||
|
userId: Int = 0,
|
||||||
|
isWorkProfile: Boolean = false,
|
||||||
|
hasInternetPermission: Boolean = true,
|
||||||
|
) = InstalledApp(
|
||||||
|
packageName = packageName,
|
||||||
|
label = packageName,
|
||||||
|
isSystem = false,
|
||||||
|
icon = null,
|
||||||
|
uid = uid,
|
||||||
|
userId = userId,
|
||||||
|
isWorkProfile = isWorkProfile,
|
||||||
|
hasInternetPermission = hasInternetPermission,
|
||||||
|
)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val PER_USER_RANGE = 100_000
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.krrishg.kwall.firewall
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class FirewallCommandsTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `chain commands target chain 3`() {
|
||||||
|
assertEquals(
|
||||||
|
"cmd connectivity set-chain3-enabled true",
|
||||||
|
FirewallCommands.chainEnabled(true),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"cmd connectivity set-chain3-enabled false",
|
||||||
|
FirewallCommands.chainEnabled(false),
|
||||||
|
)
|
||||||
|
assertEquals("cmd connectivity get-chain3-enabled", FirewallCommands.chainState())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `package commands use the expected syntax`() {
|
||||||
|
assertEquals(
|
||||||
|
"cmd connectivity set-package-networking-enabled false com.example.app",
|
||||||
|
FirewallCommands.packageNetworking("com.example.app", false),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"cmd connectivity set-package-networking-enabled true com.example.app",
|
||||||
|
FirewallCommands.packageNetworking("com.example.app", true),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"cmd connectivity get-package-networking-enabled com.example.app",
|
||||||
|
FirewallCommands.packageState("com.example.app"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(expected = IllegalArgumentException::class)
|
||||||
|
fun `package commands reject invalid names`() {
|
||||||
|
FirewallCommands.packageNetworking("com.example.app;rm -rf /", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `chain and rule constants match the platform`() {
|
||||||
|
assertEquals(9, FirewallCommands.CHAIN)
|
||||||
|
assertEquals(0, FirewallCommands.RULE_DEFAULT)
|
||||||
|
assertEquals(2, FirewallCommands.RULE_DENY)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `shell result recognises unsupported commands`() {
|
||||||
|
val result = ShellResult(0, "Unknown command: set-chain3-enabled", "")
|
||||||
|
|
||||||
|
assertTrue(result.isUnknownCommand)
|
||||||
|
assertTrue(result.isUnsupportedPlatform)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `shell result recognises system app failures`() {
|
||||||
|
val result = ShellResult(1, "", "Can't set package firewall rule for system app android")
|
||||||
|
|
||||||
|
assertTrue(result.isSystemAppError)
|
||||||
|
assertFalse(result.isUnknownCommand)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `shell result treats a missing uid owner entry as success`() {
|
||||||
|
val result = ShellResult(1, "", "suidownermap does not have entry for uid")
|
||||||
|
|
||||||
|
assertTrue(result.isUidOwnerMapMissing)
|
||||||
|
assertTrue(result.isEffectivelySuccessful)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package com.krrishg.kwall.firewall
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class FirewallDriftDetectorTest {
|
||||||
|
|
||||||
|
private val notes = BlockedApp(AppKey(0, "com.example.notes"), 10123)
|
||||||
|
private val browser = BlockedApp(AppKey(0, "com.example.browser"), 10124)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `matching runtime has no drift`() {
|
||||||
|
val drift = detect(
|
||||||
|
expectedEnabled = true,
|
||||||
|
expectedBlocked = setOf(notes),
|
||||||
|
candidates = setOf(notes),
|
||||||
|
observation = FirewallObservation(true, mapOf(notes.key to true)),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertFalse(drift.detected)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `chain mismatch is drift`() {
|
||||||
|
val drift = detect(
|
||||||
|
expectedEnabled = true,
|
||||||
|
expectedBlocked = setOf(notes),
|
||||||
|
candidates = setOf(notes),
|
||||||
|
observation = FirewallObservation(false, mapOf(notes.key to true)),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(drift.chainMismatch)
|
||||||
|
assertTrue(drift.detected)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing deny rules are drift`() {
|
||||||
|
val drift = detect(
|
||||||
|
expectedEnabled = true,
|
||||||
|
expectedBlocked = setOf(notes, browser),
|
||||||
|
candidates = setOf(notes, browser),
|
||||||
|
observation = FirewallObservation(
|
||||||
|
chainEnabled = true,
|
||||||
|
appStates = mapOf(notes.key to true, browser.key to false),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(setOf(browser.key), drift.missingRules)
|
||||||
|
assertTrue(drift.detected)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `stale deny rules are drift when the firewall is disabled`() {
|
||||||
|
val drift = detect(
|
||||||
|
expectedEnabled = false,
|
||||||
|
expectedBlocked = emptySet(),
|
||||||
|
candidates = setOf(notes),
|
||||||
|
observation = FirewallObservation(false, mapOf(notes.key to true)),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(setOf(notes.key), drift.staleRules)
|
||||||
|
assertTrue(drift.detected)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unknown app state is not treated as a missing rule`() {
|
||||||
|
val drift = detect(
|
||||||
|
expectedEnabled = true,
|
||||||
|
expectedBlocked = setOf(notes, browser),
|
||||||
|
candidates = setOf(notes, browser),
|
||||||
|
observation = FirewallObservation(
|
||||||
|
chainEnabled = true,
|
||||||
|
appStates = mapOf(notes.key to true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertFalse(drift.detected)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun detect(
|
||||||
|
expectedEnabled: Boolean,
|
||||||
|
expectedBlocked: Set<BlockedApp>,
|
||||||
|
candidates: Set<BlockedApp>,
|
||||||
|
observation: FirewallObservation,
|
||||||
|
): FirewallDrift = FirewallDriftDetector.detect(
|
||||||
|
expectedEnabled = expectedEnabled,
|
||||||
|
expectedBlocked = expectedBlocked,
|
||||||
|
candidates = candidates,
|
||||||
|
observation = observation,
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
|||||||
|
package com.krrishg.kwall.firewall
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class FirewallObservationParserTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses the chain enabled state`() {
|
||||||
|
assertEquals(null, FirewallObservationParser.chainEnabled(ShellResult(0, "", "")))
|
||||||
|
assertEquals(true, FirewallObservationParser.chainEnabled(ShellResult(0, "chain:enabled\n", "")))
|
||||||
|
assertEquals(false, FirewallObservationParser.chainEnabled(ShellResult(0, "chain:disabled\n", "")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses the package deny state`() {
|
||||||
|
val denied = FirewallObservationParser.packageDenied(
|
||||||
|
ShellResult(0, "com.example.app:deny\n", ""),
|
||||||
|
"com.example.app",
|
||||||
|
)
|
||||||
|
val allowed = FirewallObservationParser.packageDenied(
|
||||||
|
ShellResult(0, "com.example.app:allow\n", ""),
|
||||||
|
"com.example.app",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(true, denied)
|
||||||
|
assertEquals(false, allowed)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ignores unknown or failed observations`() {
|
||||||
|
assertNull(
|
||||||
|
FirewallObservationParser.chainEnabled(
|
||||||
|
ShellResult(1, "chain:enabled", "Permission denied"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertNull(FirewallObservationParser.chainEnabled(ShellResult(0, "wat", "")))
|
||||||
|
assertNull(
|
||||||
|
FirewallObservationParser.packageDenied(
|
||||||
|
ShellResult(0, "other.app:deny", ""),
|
||||||
|
"com.example.app",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertNull(
|
||||||
|
FirewallObservationParser.packageDenied(
|
||||||
|
ShellResult(0, "com.example.app:maybe", ""),
|
||||||
|
"com.example.app",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.krrishg.kwall.firewall
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class RuleProfileTest {
|
||||||
|
|
||||||
|
private val default = defaultRuleProfile(createdAtMillis = 1L)
|
||||||
|
private val work = RuleProfile(
|
||||||
|
id = "work",
|
||||||
|
name = "Work",
|
||||||
|
createdAtMillis = 2L,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `blank names are rejected`() {
|
||||||
|
assertEquals(
|
||||||
|
ProfileNameIssue.BLANK,
|
||||||
|
validateProfileName(" ", listOf(default, work)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `names longer than the limit are rejected`() {
|
||||||
|
assertEquals(
|
||||||
|
ProfileNameIssue.TOO_LONG,
|
||||||
|
validateProfileName("a".repeat(MAX_PROFILE_NAME_LENGTH + 1), listOf(default)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `duplicate names are rejected case-insensitively`() {
|
||||||
|
assertEquals(
|
||||||
|
ProfileNameIssue.DUPLICATE,
|
||||||
|
validateProfileName("work", listOf(default, work)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a profile may keep its own name when renamed`() {
|
||||||
|
assertNull(
|
||||||
|
validateProfileName("Work", listOf(default, work), excludingId = work.id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a valid unique name is accepted`() {
|
||||||
|
assertNull(validateProfileName(" Travel ", listOf(default, work)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package com.krrishg.kwall.network
|
||||||
|
|
||||||
|
import com.krrishg.kwall.firewall.ShellExecutor
|
||||||
|
import com.krrishg.kwall.firewall.ShellResult
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ProcNetConnectionInspectorTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses every protocol section`() = runTest {
|
||||||
|
val inspector = ProcNetConnectionInspector(
|
||||||
|
FakeShell(
|
||||||
|
ShellResult(
|
||||||
|
exitCode = 0,
|
||||||
|
stdout = """
|
||||||
|
=== tcp ===
|
||||||
|
sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
|
||||||
|
0: 0100007F:1F90 5DB8D822:01BB 01 00000000:00000000 00:00000000 00000000 10123 0 12345 1 0000000000000000 100 0 0 10 0
|
||||||
|
=== tcp6 ===
|
||||||
|
sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
|
||||||
|
1: 00000000000000000000000001000000:0050 00000000000000000000000001000000:01BB 01 00000000:00000000 00:00000000 00000000 10124 0 23456 1 0000000000000000 100 0 0 10 0
|
||||||
|
=== udp ===
|
||||||
|
sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
|
||||||
|
0: 0100007F:14E9 00000000:0000 07 00000000:00000000 00:00000000 00000000 10126 0 45678 1 0000000000000000 100 0 0 10 0
|
||||||
|
=== udp6 ===
|
||||||
|
""".trimIndent(),
|
||||||
|
stderr = "",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val snapshot = inspector.snapshot()
|
||||||
|
|
||||||
|
assertTrue(snapshot is ConnectionSnapshot.Available)
|
||||||
|
val sockets = (snapshot as ConnectionSnapshot.Available).sockets
|
||||||
|
assertEquals(3, sockets.size)
|
||||||
|
assertEquals(
|
||||||
|
listOf(SocketProtocol.TCP, SocketProtocol.TCP6, SocketProtocol.UDP),
|
||||||
|
sockets.map { it.protocol },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `skips unreadable sections but keeps the rest`() = runTest {
|
||||||
|
val inspector = ProcNetConnectionInspector(
|
||||||
|
FakeShell(
|
||||||
|
ShellResult(
|
||||||
|
exitCode = 1,
|
||||||
|
stdout = """
|
||||||
|
=== tcp ===
|
||||||
|
cat: /proc/net/tcp: Permission denied
|
||||||
|
=== tcp6 ===
|
||||||
|
sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
|
||||||
|
0: 00000000:0050 00000000:0000 0A 00000000:00000000 00:00000000 00000000 10125 0 34567 1 0000000000000000 100 0 0 10 0
|
||||||
|
=== udp ===
|
||||||
|
cat: /proc/net/udp: Permission denied
|
||||||
|
=== udp6 ===
|
||||||
|
cat: /proc/net/udp6: Permission denied
|
||||||
|
""".trimIndent(),
|
||||||
|
stderr = "",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val snapshot = inspector.snapshot()
|
||||||
|
|
||||||
|
assertTrue(snapshot is ConnectionSnapshot.Available)
|
||||||
|
val sockets = (snapshot as ConnectionSnapshot.Available).sockets
|
||||||
|
assertEquals(1, sockets.size)
|
||||||
|
assertEquals(SocketProtocol.TCP6, sockets.single().protocol)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reports unavailable when every section is unreadable`() = runTest {
|
||||||
|
val inspector = ProcNetConnectionInspector(
|
||||||
|
FakeShell(
|
||||||
|
ShellResult(
|
||||||
|
exitCode = 1,
|
||||||
|
stdout = """
|
||||||
|
=== tcp ===
|
||||||
|
cat: /proc/net/tcp: Permission denied
|
||||||
|
=== tcp6 ===
|
||||||
|
cat: /proc/net/tcp6: Permission denied
|
||||||
|
=== udp ===
|
||||||
|
cat: /proc/net/udp: Permission denied
|
||||||
|
=== udp6 ===
|
||||||
|
cat: /proc/net/udp6: Permission denied
|
||||||
|
""".trimIndent(),
|
||||||
|
stderr = "",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val snapshot = inspector.snapshot()
|
||||||
|
|
||||||
|
assertTrue(snapshot is ConnectionSnapshot.Unavailable)
|
||||||
|
assertTrue(
|
||||||
|
(snapshot as ConnectionSnapshot.Unavailable).reason.contains("does not allow"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reports unavailable when shizuku is not running`() = runTest {
|
||||||
|
val inspector = ProcNetConnectionInspector(
|
||||||
|
FakeShell(ShellResult(-1, "", "Shizuku is not running")),
|
||||||
|
)
|
||||||
|
|
||||||
|
val snapshot = inspector.snapshot()
|
||||||
|
|
||||||
|
assertTrue(snapshot is ConnectionSnapshot.Unavailable)
|
||||||
|
assertEquals("Shizuku is not running", (snapshot as ConnectionSnapshot.Unavailable).reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeShell(private val result: ShellResult) : ShellExecutor {
|
||||||
|
override suspend fun exec(command: String): ShellResult = result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package com.krrishg.kwall.network
|
||||||
|
|
||||||
|
import java.net.InetAddress
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ProcNetParserTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses established ipv4 tcp connections`() {
|
||||||
|
val output = """
|
||||||
|
sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
|
||||||
|
0: 0100007F:1F90 5DB8D822:01BB 01 00000000:00000000 00:00000000 00000000 10123 0 12345 1 0000000000000000 100 0 0 10 0
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val sockets = ProcNetParser.parse(SocketProtocol.TCP, output)
|
||||||
|
|
||||||
|
assertEquals(1, sockets.size)
|
||||||
|
val socket = sockets.single()
|
||||||
|
assertEquals(10123, socket.uid)
|
||||||
|
assertEquals(SocketProtocol.TCP, socket.protocol)
|
||||||
|
assertEquals(InetAddress.getByName("127.0.0.1"), socket.localAddress)
|
||||||
|
assertEquals(8080, socket.localPort)
|
||||||
|
assertEquals(InetAddress.getByName("34.216.184.93"), socket.remoteAddress)
|
||||||
|
assertEquals(443, socket.remotePort)
|
||||||
|
assertEquals(TcpState.ESTABLISHED, socket.state)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses ipv6 addresses`() {
|
||||||
|
val output = """
|
||||||
|
sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
|
||||||
|
1: 00000000000000000000000001000000:0050 00000000000000000000000001000000:01BB 01 00000000:00000000 00:00000000 00000000 10124 0 23456 1 0000000000000000 100 0 0 10 0
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val sockets = ProcNetParser.parse(SocketProtocol.TCP6, output)
|
||||||
|
|
||||||
|
assertEquals(1, sockets.size)
|
||||||
|
val socket = sockets.single()
|
||||||
|
assertEquals(InetAddress.getByName("::1"), socket.localAddress)
|
||||||
|
assertEquals(80, socket.localPort)
|
||||||
|
assertEquals(InetAddress.getByName("::1"), socket.remoteAddress)
|
||||||
|
assertEquals(443, socket.remotePort)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `keeps listening sockets with no remote endpoint`() {
|
||||||
|
val output = """
|
||||||
|
sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
|
||||||
|
0: 00000000:0050 00000000:0000 0A 00000000:00000000 00:00000000 00000000 10125 0 34567 1 0000000000000000 100 0 0 10 0
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val socket = ProcNetParser.parse(SocketProtocol.TCP, output).single()
|
||||||
|
|
||||||
|
assertNull(socket.localAddress)
|
||||||
|
assertEquals(80, socket.localPort)
|
||||||
|
assertNull(socket.remoteAddress)
|
||||||
|
assertEquals(0, socket.remotePort)
|
||||||
|
assertEquals(TcpState.LISTEN, socket.state)
|
||||||
|
assertTrue(socket.isListening)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `udp sockets have no tcp state`() {
|
||||||
|
val output = """
|
||||||
|
sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
|
||||||
|
0: 0100007F:14E9 00000000:0000 07 00000000:00000000 00:00000000 00000000 10126 0 45678 1 0000000000000000 100 0 0 10 0
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val socket = ProcNetParser.parse(SocketProtocol.UDP, output).single()
|
||||||
|
|
||||||
|
assertEquals(5353, socket.localPort)
|
||||||
|
assertNull(socket.state)
|
||||||
|
assertNull(socket.remoteAddress)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ignores malformed lines`() {
|
||||||
|
val output = """
|
||||||
|
sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
|
||||||
|
broken line
|
||||||
|
0: 0100007F:1F90 5DB8D822:01BB 01 00000000:00000000 00:00000000 00000000 0 0 12345 1 0000000000000000 100 0 0 10 0
|
||||||
|
1: not-an-address:0050 00000000:0000 0A 00000000:00000000 00:00000000 00000000 10123 0 34567 1 0000000000000000 100 0 0 10 0
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val sockets = ProcNetParser.parse(SocketProtocol.TCP, output)
|
||||||
|
|
||||||
|
assertEquals(1, sockets.size)
|
||||||
|
assertEquals(0, sockets.single().uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `tcp state is decoded from its hex code`() {
|
||||||
|
assertEquals(TcpState.ESTABLISHED, TcpState.fromHex("01"))
|
||||||
|
assertEquals(TcpState.LISTEN, TcpState.fromHex("0a"))
|
||||||
|
assertEquals(TcpState.TIME_WAIT, TcpState.fromHex("06"))
|
||||||
|
assertEquals(TcpState.UNKNOWN, TcpState.fromHex("FF"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.krrishg.kwall.network
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class UsageWindowTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ranges start at midnight in utc`() {
|
||||||
|
val now = Instant.parse("2026-10-25T15:30:00Z").toEpochMilli()
|
||||||
|
val zone = ZoneId.of("UTC")
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
Instant.parse("2026-10-25T00:00:00Z").toEpochMilli(),
|
||||||
|
UsageWindow.start(UsageRange.TODAY, now, zone),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
Instant.parse("2026-10-19T00:00:00Z").toEpochMilli(),
|
||||||
|
UsageWindow.start(UsageRange.WEEK, now, zone),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
Instant.parse("2026-09-26T00:00:00Z").toEpochMilli(),
|
||||||
|
UsageWindow.start(UsageRange.MONTH, now, zone),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `today follows the local time zone`() {
|
||||||
|
val now = Instant.parse("2026-10-24T20:00:00Z").toEpochMilli()
|
||||||
|
val zone = ZoneId.of("Asia/Kolkata")
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
Instant.parse("2026-10-24T18:30:00Z").toEpochMilli(),
|
||||||
|
UsageWindow.start(UsageRange.TODAY, now, zone),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package com.krrishg.kwall.notifications
|
||||||
|
|
||||||
|
import com.krrishg.kwall.firewall.ChainTransport
|
||||||
|
import com.krrishg.kwall.firewall.FirewallAvailability
|
||||||
|
import com.krrishg.kwall.firewall.RulesTransport
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class FirewallNotificationContentTest {
|
||||||
|
|
||||||
|
private val ready = FirewallAvailability.Ready(
|
||||||
|
rulesTransport = RulesTransport.BINDER,
|
||||||
|
chainTransport = ChainTransport.SHELL,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun enabledFirewallShowsBlockedCountAndPauseAction() {
|
||||||
|
val content = firewallNotificationContent(
|
||||||
|
enabled = true,
|
||||||
|
appCount = 3,
|
||||||
|
availability = ready,
|
||||||
|
error = null,
|
||||||
|
appLockEnabled = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(FirewallNotificationTitle.ON, content.title)
|
||||||
|
assertEquals(FirewallNotificationText.BlockedApps(3), content.text)
|
||||||
|
assertEquals(FirewallNotificationAction.PAUSE, content.action)
|
||||||
|
assertTrue(content.directToggle)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun enabledFirewallWithoutAppsShowsNoBlockedApps() {
|
||||||
|
val content = firewallNotificationContent(
|
||||||
|
enabled = true,
|
||||||
|
appCount = 0,
|
||||||
|
availability = ready,
|
||||||
|
error = null,
|
||||||
|
appLockEnabled = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(FirewallNotificationText.NoBlockedApps, content.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun disabledFirewallShowsSelectedCountAndResumeAction() {
|
||||||
|
val content = firewallNotificationContent(
|
||||||
|
enabled = false,
|
||||||
|
appCount = 2,
|
||||||
|
availability = ready,
|
||||||
|
error = null,
|
||||||
|
appLockEnabled = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(FirewallNotificationTitle.OFF, content.title)
|
||||||
|
assertEquals(FirewallNotificationText.SelectedApps(2), content.text)
|
||||||
|
assertEquals(FirewallNotificationAction.RESUME, content.action)
|
||||||
|
assertTrue(content.directToggle)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun disabledFirewallWithoutAppsShowsNoSelectedApps() {
|
||||||
|
val content = firewallNotificationContent(
|
||||||
|
enabled = false,
|
||||||
|
appCount = 0,
|
||||||
|
availability = ready,
|
||||||
|
error = null,
|
||||||
|
appLockEnabled = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(FirewallNotificationText.NoSelectedApps, content.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun appLockDisablesDirectToggle() {
|
||||||
|
val content = firewallNotificationContent(
|
||||||
|
enabled = true,
|
||||||
|
appCount = 1,
|
||||||
|
availability = ready,
|
||||||
|
error = null,
|
||||||
|
appLockEnabled = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(FirewallNotificationAction.PAUSE, content.action)
|
||||||
|
assertFalse(content.directToggle)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun unavailableShizukuShowsOpenAction() {
|
||||||
|
val content = firewallNotificationContent(
|
||||||
|
enabled = true,
|
||||||
|
appCount = 1,
|
||||||
|
availability = FirewallAvailability.ShizukuNotRunning,
|
||||||
|
error = null,
|
||||||
|
appLockEnabled = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(FirewallNotificationTitle.PAUSED, content.title)
|
||||||
|
assertEquals(
|
||||||
|
FirewallNotificationText.Availability(FirewallAvailability.ShizukuNotRunning),
|
||||||
|
content.text,
|
||||||
|
)
|
||||||
|
assertEquals(FirewallNotificationAction.OPEN, content.action)
|
||||||
|
assertFalse(content.directToggle)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkingShizukuShowsOpenAction() {
|
||||||
|
val content = firewallNotificationContent(
|
||||||
|
enabled = false,
|
||||||
|
appCount = 0,
|
||||||
|
availability = FirewallAvailability.Checking,
|
||||||
|
error = null,
|
||||||
|
appLockEnabled = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(FirewallNotificationTitle.FIREWALL, content.title)
|
||||||
|
assertEquals(
|
||||||
|
FirewallNotificationText.Availability(FirewallAvailability.Checking),
|
||||||
|
content.text,
|
||||||
|
)
|
||||||
|
assertEquals(FirewallNotificationAction.OPEN, content.action)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun errorShowsAttentionAndOpenAction() {
|
||||||
|
val content = firewallNotificationContent(
|
||||||
|
enabled = true,
|
||||||
|
appCount = 1,
|
||||||
|
availability = ready,
|
||||||
|
error = "Could not change firewall chain",
|
||||||
|
appLockEnabled = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(FirewallNotificationTitle.ATTENTION, content.title)
|
||||||
|
assertEquals(
|
||||||
|
FirewallNotificationText.Error("Could not change firewall chain"),
|
||||||
|
content.text,
|
||||||
|
)
|
||||||
|
assertEquals(FirewallNotificationAction.OPEN, content.action)
|
||||||
|
assertFalse(content.directToggle)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
package com.krrishg.kwall.scheduler
|
||||||
|
|
||||||
|
import com.krrishg.kwall.data.InstalledApp
|
||||||
|
import com.krrishg.kwall.firewall.AppKey
|
||||||
|
import com.krrishg.kwall.firewall.DEFAULT_PROFILE_ID
|
||||||
|
import com.krrishg.kwall.firewall.FirewallAvailability
|
||||||
|
import com.krrishg.kwall.firewall.FirewallController
|
||||||
|
import com.krrishg.kwall.firewall.FirewallDriftState
|
||||||
|
import com.krrishg.kwall.firewall.FirewallState
|
||||||
|
import com.krrishg.kwall.firewall.RuleProfile
|
||||||
|
import com.krrishg.kwall.firewall.defaultRuleProfile
|
||||||
|
import java.time.Clock
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ProfileSchedulerTest {
|
||||||
|
|
||||||
|
private val work = RuleProfile(
|
||||||
|
id = "work",
|
||||||
|
name = "Work",
|
||||||
|
createdAtMillis = 2L,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `entering a window activates the profile and enables the firewall`() = runTest {
|
||||||
|
val now = Instant.parse("2026-01-05T12:00:00Z")
|
||||||
|
val schedule = schedule(end = LocalTime.of(17, 0))
|
||||||
|
val store = FakeScheduleStore(listOf(schedule))
|
||||||
|
val controller = FakeController()
|
||||||
|
val alarms = FakeAlarms()
|
||||||
|
val scheduler = ProfileScheduler(
|
||||||
|
store,
|
||||||
|
controller,
|
||||||
|
alarms,
|
||||||
|
MutableClock(now),
|
||||||
|
{ ZoneOffset.UTC },
|
||||||
|
)
|
||||||
|
|
||||||
|
scheduler.onSystemEvent()
|
||||||
|
|
||||||
|
assertEquals(listOf(Activation(work.id, true)), controller.activations)
|
||||||
|
assertTrue(controller.state.value.enabled)
|
||||||
|
assertEquals(work.id, controller.state.value.activeProfileId)
|
||||||
|
assertEquals(Instant.parse("2026-01-05T17:00:00Z"), alarms.next)
|
||||||
|
assertEquals(schedule.id, store.runtime.activeScheduleId)
|
||||||
|
assertEquals(false, store.runtime.firewallWasEnabledBefore)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `leaving a window restores the fallback profile and firewall state`() = runTest {
|
||||||
|
val clock = MutableClock(Instant.parse("2026-01-05T12:00:00Z"))
|
||||||
|
val schedule = schedule(end = LocalTime.of(17, 0))
|
||||||
|
val store = FakeScheduleStore(listOf(schedule))
|
||||||
|
val controller = FakeController()
|
||||||
|
val alarms = FakeAlarms()
|
||||||
|
val scheduler = ProfileScheduler(
|
||||||
|
store,
|
||||||
|
controller,
|
||||||
|
alarms,
|
||||||
|
clock,
|
||||||
|
{ ZoneOffset.UTC },
|
||||||
|
)
|
||||||
|
|
||||||
|
scheduler.onSystemEvent()
|
||||||
|
clock.current = Instant.parse("2026-01-05T18:00:00Z")
|
||||||
|
scheduler.onSystemEvent()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf(
|
||||||
|
Activation(work.id, true),
|
||||||
|
Activation(DEFAULT_PROFILE_ID, false),
|
||||||
|
),
|
||||||
|
controller.activations,
|
||||||
|
)
|
||||||
|
assertFalse(controller.state.value.enabled)
|
||||||
|
assertEquals(DEFAULT_PROFILE_ID, controller.state.value.activeProfileId)
|
||||||
|
assertNull(store.runtime.activeScheduleId)
|
||||||
|
assertNull(alarms.next)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an enabled firewall remains enabled after a window ends`() = runTest {
|
||||||
|
val clock = MutableClock(Instant.parse("2026-01-05T12:00:00Z"))
|
||||||
|
val schedule = schedule(end = LocalTime.of(17, 0))
|
||||||
|
val store = FakeScheduleStore(listOf(schedule))
|
||||||
|
val controller = FakeController(enabled = true)
|
||||||
|
val scheduler = ProfileScheduler(
|
||||||
|
store,
|
||||||
|
controller,
|
||||||
|
FakeAlarms(),
|
||||||
|
clock,
|
||||||
|
{ ZoneOffset.UTC },
|
||||||
|
)
|
||||||
|
|
||||||
|
scheduler.onSystemEvent()
|
||||||
|
clock.current = Instant.parse("2026-01-05T18:00:00Z")
|
||||||
|
scheduler.onSystemEvent()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf(
|
||||||
|
Activation(work.id, true),
|
||||||
|
Activation(DEFAULT_PROFILE_ID, null),
|
||||||
|
),
|
||||||
|
controller.activations,
|
||||||
|
)
|
||||||
|
assertTrue(controller.state.value.enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the next activation is scheduled when no window is active`() = runTest {
|
||||||
|
val schedule = schedule(
|
||||||
|
date = LocalDate.of(2026, 1, 6),
|
||||||
|
end = null,
|
||||||
|
)
|
||||||
|
val store = FakeScheduleStore(listOf(schedule))
|
||||||
|
val alarms = FakeAlarms()
|
||||||
|
val scheduler = ProfileScheduler(
|
||||||
|
store,
|
||||||
|
FakeController(),
|
||||||
|
alarms,
|
||||||
|
MutableClock(Instant.parse("2026-01-05T12:00:00Z")),
|
||||||
|
{ ZoneOffset.UTC },
|
||||||
|
)
|
||||||
|
|
||||||
|
scheduler.onSystemEvent()
|
||||||
|
|
||||||
|
assertEquals(Instant.parse("2026-01-06T09:00:00Z"), alarms.next)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an open ended schedule is not deactivated automatically`() = runTest {
|
||||||
|
val clock = MutableClock(Instant.parse("2026-01-05T12:00:00Z"))
|
||||||
|
val schedule = schedule(end = null)
|
||||||
|
val store = FakeScheduleStore(listOf(schedule))
|
||||||
|
val controller = FakeController()
|
||||||
|
val scheduler = ProfileScheduler(
|
||||||
|
store,
|
||||||
|
controller,
|
||||||
|
FakeAlarms(),
|
||||||
|
clock,
|
||||||
|
{ ZoneOffset.UTC },
|
||||||
|
)
|
||||||
|
|
||||||
|
scheduler.onSystemEvent()
|
||||||
|
clock.current = Instant.parse("2026-01-10T12:00:00Z")
|
||||||
|
scheduler.onSystemEvent()
|
||||||
|
|
||||||
|
assertEquals(listOf(Activation(work.id, true)), controller.activations)
|
||||||
|
assertEquals(work.id, controller.state.value.activeProfileId)
|
||||||
|
assertEquals(schedule.id, store.runtime.activeScheduleId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `schedules with missing profiles are ignored`() = runTest {
|
||||||
|
val store = FakeScheduleStore(
|
||||||
|
listOf(schedule(profileId = "missing", end = LocalTime.of(17, 0))),
|
||||||
|
)
|
||||||
|
val controller = FakeController()
|
||||||
|
val scheduler = ProfileScheduler(
|
||||||
|
store,
|
||||||
|
controller,
|
||||||
|
FakeAlarms(),
|
||||||
|
MutableClock(Instant.parse("2026-01-05T12:00:00Z")),
|
||||||
|
{ ZoneOffset.UTC },
|
||||||
|
)
|
||||||
|
|
||||||
|
scheduler.onSystemEvent()
|
||||||
|
|
||||||
|
assertTrue(controller.activations.isEmpty())
|
||||||
|
assertNull(store.runtime.activeScheduleId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun schedule(
|
||||||
|
date: LocalDate = LocalDate.of(2026, 1, 5),
|
||||||
|
profileId: String = work.id,
|
||||||
|
end: LocalTime?,
|
||||||
|
): ProfileSchedule = ProfileSchedule(
|
||||||
|
id = "schedule",
|
||||||
|
name = "Work hours",
|
||||||
|
profileId = profileId,
|
||||||
|
fallbackProfileId = DEFAULT_PROFILE_ID,
|
||||||
|
recurrence = ScheduleRecurrence.Once(date),
|
||||||
|
startTime = LocalTime.of(9, 0),
|
||||||
|
endTime = end,
|
||||||
|
createdAtMillis = 3L,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class Activation(val profileId: String, val enableFirewall: Boolean?)
|
||||||
|
|
||||||
|
private class MutableClock(var current: Instant) : Clock() {
|
||||||
|
override fun getZone(): ZoneId = ZoneOffset.UTC
|
||||||
|
|
||||||
|
override fun withZone(zone: ZoneId): Clock = this
|
||||||
|
|
||||||
|
override fun instant(): Instant = current
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeAlarms : ScheduleAlarms {
|
||||||
|
var next: Instant? = null
|
||||||
|
var cancelled = false
|
||||||
|
|
||||||
|
override fun canScheduleExactAlarms(): Boolean = true
|
||||||
|
|
||||||
|
override fun scheduleNext(at: Instant) {
|
||||||
|
next = at
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun cancel() {
|
||||||
|
cancelled = true
|
||||||
|
next = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun requestExactAlarmPermission() = Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeScheduleStore(
|
||||||
|
initial: List<ProfileSchedule>,
|
||||||
|
) : ScheduleStore {
|
||||||
|
private val scheduleFlow = MutableStateFlow(initial)
|
||||||
|
override val schedules: StateFlow<List<ProfileSchedule>> = scheduleFlow
|
||||||
|
var runtime = SchedulerRuntime()
|
||||||
|
|
||||||
|
override suspend fun create(schedule: ProfileSchedule): Boolean {
|
||||||
|
scheduleFlow.update { it + schedule }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun update(schedule: ProfileSchedule): Boolean {
|
||||||
|
scheduleFlow.update { schedules ->
|
||||||
|
schedules.map { if (it.id == schedule.id) schedule else it }
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun delete(id: String): Boolean {
|
||||||
|
scheduleFlow.update { schedules -> schedules.filterNot { it.id == id } }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setEnabled(id: String, enabled: Boolean): Boolean {
|
||||||
|
scheduleFlow.update { schedules ->
|
||||||
|
schedules.map { if (it.id == id) it.copy(enabled = enabled) else it }
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun readRuntime(): SchedulerRuntime = runtime
|
||||||
|
|
||||||
|
override suspend fun writeRuntime(runtime: SchedulerRuntime) {
|
||||||
|
this.runtime = runtime
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun clearRuntime() {
|
||||||
|
runtime = SchedulerRuntime()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeController(
|
||||||
|
enabled: Boolean = false,
|
||||||
|
) : FirewallController {
|
||||||
|
private val stateFlow = MutableStateFlow(
|
||||||
|
FirewallState(
|
||||||
|
enabled = enabled,
|
||||||
|
profiles = listOf(
|
||||||
|
defaultRuleProfile(createdAtMillis = 1L),
|
||||||
|
RuleProfile(id = "work", name = "Work", createdAtMillis = 2L),
|
||||||
|
),
|
||||||
|
activeProfileId = DEFAULT_PROFILE_ID,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
override val state: StateFlow<FirewallState> = stateFlow
|
||||||
|
override val availability: StateFlow<FirewallAvailability> =
|
||||||
|
MutableStateFlow(FirewallAvailability.Checking)
|
||||||
|
override val error = MutableStateFlow<String?>(null)
|
||||||
|
override val drift = MutableStateFlow(FirewallDriftState())
|
||||||
|
val activations = mutableListOf<Activation>()
|
||||||
|
|
||||||
|
override suspend fun applyScheduledProfile(
|
||||||
|
profileId: String,
|
||||||
|
enableFirewall: Boolean?,
|
||||||
|
): Boolean {
|
||||||
|
activations += Activation(profileId, enableFirewall)
|
||||||
|
stateFlow.update { current ->
|
||||||
|
current.copy(
|
||||||
|
enabled = enableFirewall ?: current.enabled,
|
||||||
|
activeProfileId = profileId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setBlocked(app: InstalledApp, blocked: Boolean) = Unit
|
||||||
|
override suspend fun addBlockedSelection(app: InstalledApp): Boolean = false
|
||||||
|
override suspend fun setEnabled(enabled: Boolean) = Unit
|
||||||
|
override suspend fun restore() = Unit
|
||||||
|
override suspend fun checkForDrift() = Unit
|
||||||
|
override suspend fun refreshAvailability() = Unit
|
||||||
|
override fun requestPermission() = Unit
|
||||||
|
override suspend fun removeBlocked(key: AppKey) = Unit
|
||||||
|
override suspend fun removeBlockedForUser(userId: Int) = Unit
|
||||||
|
override suspend fun switchProfile(id: String) = Unit
|
||||||
|
override suspend fun createProfile(name: String): Boolean = false
|
||||||
|
override suspend fun duplicateProfile(sourceId: String, name: String): Boolean = false
|
||||||
|
override suspend fun renameProfile(id: String, name: String): Boolean = false
|
||||||
|
override suspend fun deleteProfile(id: String): Boolean = false
|
||||||
|
override suspend fun removeBlockedEverywhere(key: AppKey) = Unit
|
||||||
|
override suspend fun removeBlockedForUserEverywhere(userId: Int) = Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
package com.krrishg.kwall.scheduler
|
||||||
|
|
||||||
|
import java.time.DayOfWeek
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ScheduleEngineTest {
|
||||||
|
|
||||||
|
private val zone = ZoneOffset.UTC
|
||||||
|
private val profiles = setOf("p", "f")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `daily schedule advances by its interval`() {
|
||||||
|
val schedule = schedule(
|
||||||
|
recurrence = ScheduleRecurrence.Daily(LocalDate.of(2026, 1, 1), intervalDays = 2),
|
||||||
|
start = LocalTime.of(9, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
val next = ScheduleEngine.nextOccurrence(
|
||||||
|
schedule,
|
||||||
|
Instant.parse("2026-01-02T10:00:00Z"),
|
||||||
|
zone,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(Instant.parse("2026-01-03T09:00:00Z"), next)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `weekly schedule selects configured weekdays`() {
|
||||||
|
val schedule = schedule(
|
||||||
|
recurrence = ScheduleRecurrence.Weekly(
|
||||||
|
anchorDate = LocalDate.of(2026, 1, 5),
|
||||||
|
daysOfWeek = setOf(DayOfWeek.WEDNESDAY, DayOfWeek.FRIDAY),
|
||||||
|
intervalWeeks = 1,
|
||||||
|
),
|
||||||
|
start = LocalTime.of(8, 30),
|
||||||
|
)
|
||||||
|
|
||||||
|
val next = ScheduleEngine.nextOccurrence(
|
||||||
|
schedule,
|
||||||
|
Instant.parse("2026-01-07T09:00:00Z"),
|
||||||
|
zone,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(Instant.parse("2026-01-09T08:30:00Z"), next)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `weekly schedule respects multi-week intervals`() {
|
||||||
|
val schedule = schedule(
|
||||||
|
recurrence = ScheduleRecurrence.Weekly(
|
||||||
|
anchorDate = LocalDate.of(2026, 1, 5),
|
||||||
|
daysOfWeek = setOf(DayOfWeek.MONDAY),
|
||||||
|
intervalWeeks = 2,
|
||||||
|
),
|
||||||
|
start = LocalTime.of(8, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
val next = ScheduleEngine.nextOccurrence(
|
||||||
|
schedule,
|
||||||
|
Instant.parse("2026-01-06T00:00:00Z"),
|
||||||
|
zone,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(Instant.parse("2026-01-19T08:00:00Z"), next)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `weekly schedule before the anchor uses the anchor week`() {
|
||||||
|
val schedule = schedule(
|
||||||
|
recurrence = ScheduleRecurrence.Weekly(
|
||||||
|
anchorDate = LocalDate.of(2026, 1, 5),
|
||||||
|
daysOfWeek = setOf(DayOfWeek.WEDNESDAY),
|
||||||
|
intervalWeeks = 2,
|
||||||
|
),
|
||||||
|
start = LocalTime.of(8, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
val next = ScheduleEngine.nextOccurrence(
|
||||||
|
schedule,
|
||||||
|
Instant.parse("2026-01-01T00:00:00Z"),
|
||||||
|
zone,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(Instant.parse("2026-01-07T08:00:00Z"), next)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `monthly schedule clamps to the end of shorter months`() {
|
||||||
|
val schedule = schedule(
|
||||||
|
recurrence = ScheduleRecurrence.Monthly(
|
||||||
|
anchorDate = LocalDate.of(2026, 1, 31),
|
||||||
|
dayOfMonth = 31,
|
||||||
|
intervalMonths = 1,
|
||||||
|
),
|
||||||
|
start = LocalTime.of(12, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
val next = ScheduleEngine.nextOccurrence(
|
||||||
|
schedule,
|
||||||
|
Instant.parse("2026-01-31T13:00:00Z"),
|
||||||
|
zone,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(Instant.parse("2026-02-28T12:00:00Z"), next)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an overnight window ends on the following day`() {
|
||||||
|
val schedule = schedule(
|
||||||
|
recurrence = ScheduleRecurrence.Once(LocalDate.of(2026, 1, 5)),
|
||||||
|
start = LocalTime.of(23, 0),
|
||||||
|
end = LocalTime.of(2, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
val window = ScheduleEngine.windowFor(schedule, LocalDate.of(2026, 1, 5), zone)
|
||||||
|
|
||||||
|
assertEquals(Instant.parse("2026-01-05T23:00:00Z"), window?.start)
|
||||||
|
assertEquals(Instant.parse("2026-01-06T02:00:00Z"), window?.end)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `active schedule is reported inside a window and cleared after the end`() {
|
||||||
|
val schedule = schedule(
|
||||||
|
recurrence = ScheduleRecurrence.Once(LocalDate.of(2026, 1, 5)),
|
||||||
|
start = LocalTime.of(9, 0),
|
||||||
|
end = LocalTime.of(17, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
val active = ScheduleEngine.activeSchedule(
|
||||||
|
listOf(schedule),
|
||||||
|
Instant.parse("2026-01-05T12:00:00Z"),
|
||||||
|
zone,
|
||||||
|
)
|
||||||
|
val ended = ScheduleEngine.activeSchedule(
|
||||||
|
listOf(schedule),
|
||||||
|
Instant.parse("2026-01-05T18:00:00Z"),
|
||||||
|
zone,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(schedule.id, active?.schedule?.id)
|
||||||
|
assertNull(ended)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an open ended schedule stays active until another schedule starts`() {
|
||||||
|
val first = schedule(
|
||||||
|
id = "first",
|
||||||
|
recurrence = ScheduleRecurrence.Once(LocalDate.of(2026, 1, 5)),
|
||||||
|
start = LocalTime.of(9, 0),
|
||||||
|
)
|
||||||
|
val second = schedule(
|
||||||
|
id = "second",
|
||||||
|
recurrence = ScheduleRecurrence.Once(LocalDate.of(2026, 1, 6)),
|
||||||
|
start = LocalTime.of(9, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
val active = ScheduleEngine.activeSchedule(
|
||||||
|
listOf(first, second),
|
||||||
|
Instant.parse("2026-01-06T12:00:00Z"),
|
||||||
|
zone,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(second.id, active?.schedule?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `next transition includes the end of an active window`() {
|
||||||
|
val schedule = schedule(
|
||||||
|
recurrence = ScheduleRecurrence.Once(LocalDate.of(2026, 1, 5)),
|
||||||
|
start = LocalTime.of(9, 0),
|
||||||
|
end = LocalTime.of(17, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
val next = ScheduleEngine.nextTransition(
|
||||||
|
listOf(schedule),
|
||||||
|
Instant.parse("2026-01-05T12:00:00Z"),
|
||||||
|
zone,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(Instant.parse("2026-01-05T17:00:00Z"), next)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `overlapping windows are rejected`() {
|
||||||
|
val existing = schedule(
|
||||||
|
recurrence = ScheduleRecurrence.Once(LocalDate.of(2026, 1, 5)),
|
||||||
|
start = LocalTime.of(9, 0),
|
||||||
|
end = LocalTime.of(12, 0),
|
||||||
|
)
|
||||||
|
val candidate = schedule(
|
||||||
|
id = "candidate",
|
||||||
|
recurrence = ScheduleRecurrence.Once(LocalDate.of(2026, 1, 5)),
|
||||||
|
start = LocalTime.of(11, 0),
|
||||||
|
end = LocalTime.of(13, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
val issues = ScheduleEngine.validateSchedule(
|
||||||
|
candidate,
|
||||||
|
listOf(existing),
|
||||||
|
profiles,
|
||||||
|
Instant.parse("2026-01-01T00:00:00Z"),
|
||||||
|
zone,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(ScheduleIssue.OVERLAP in issues)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `open ended activations cannot start inside another window`() {
|
||||||
|
val existing = schedule(
|
||||||
|
recurrence = ScheduleRecurrence.Once(LocalDate.of(2026, 1, 5)),
|
||||||
|
start = LocalTime.of(9, 0),
|
||||||
|
end = LocalTime.of(12, 0),
|
||||||
|
)
|
||||||
|
val candidate = schedule(
|
||||||
|
id = "candidate",
|
||||||
|
recurrence = ScheduleRecurrence.Once(LocalDate.of(2026, 1, 5)),
|
||||||
|
start = LocalTime.of(10, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
val issues = ScheduleEngine.validateSchedule(
|
||||||
|
candidate,
|
||||||
|
listOf(existing),
|
||||||
|
profiles,
|
||||||
|
Instant.parse("2026-01-01T00:00:00Z"),
|
||||||
|
zone,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(ScheduleIssue.OVERLAP in issues)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `one time schedules in the past are rejected`() {
|
||||||
|
val candidate = schedule(
|
||||||
|
recurrence = ScheduleRecurrence.Once(LocalDate.of(2025, 1, 5)),
|
||||||
|
start = LocalTime.of(9, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
val issues = ScheduleEngine.validateSchedule(
|
||||||
|
candidate,
|
||||||
|
emptyList(),
|
||||||
|
profiles,
|
||||||
|
Instant.parse("2026-01-01T00:00:00Z"),
|
||||||
|
zone,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(ScheduleIssue.PAST_ONE_TIME in issues)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun schedule(
|
||||||
|
id: String = "schedule",
|
||||||
|
recurrence: ScheduleRecurrence,
|
||||||
|
start: LocalTime,
|
||||||
|
end: LocalTime? = null,
|
||||||
|
): ProfileSchedule = ProfileSchedule(
|
||||||
|
id = id,
|
||||||
|
name = "Schedule",
|
||||||
|
profileId = "p",
|
||||||
|
fallbackProfileId = "f",
|
||||||
|
recurrence = recurrence,
|
||||||
|
startTime = start,
|
||||||
|
endTime = end,
|
||||||
|
enabled = true,
|
||||||
|
createdAtMillis = 1L,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.krrishg.kwall.scheduler
|
||||||
|
|
||||||
|
import java.time.DayOfWeek
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ScheduleJsonCodecTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `schedules survive a json round trip`() {
|
||||||
|
val schedules = listOf(
|
||||||
|
ProfileSchedule(
|
||||||
|
id = "weekly",
|
||||||
|
name = "Work week",
|
||||||
|
profileId = "work",
|
||||||
|
fallbackProfileId = "default",
|
||||||
|
recurrence = ScheduleRecurrence.Weekly(
|
||||||
|
anchorDate = LocalDate.of(2026, 1, 5),
|
||||||
|
daysOfWeek = setOf(DayOfWeek.MONDAY, DayOfWeek.FRIDAY),
|
||||||
|
intervalWeeks = 2,
|
||||||
|
),
|
||||||
|
startTime = LocalTime.of(9, 0),
|
||||||
|
endTime = LocalTime.of(17, 30),
|
||||||
|
enabled = false,
|
||||||
|
createdAtMillis = 42L,
|
||||||
|
),
|
||||||
|
ProfileSchedule(
|
||||||
|
id = "monthly",
|
||||||
|
name = "Month end",
|
||||||
|
profileId = "strict",
|
||||||
|
fallbackProfileId = "default",
|
||||||
|
recurrence = ScheduleRecurrence.Monthly(
|
||||||
|
anchorDate = LocalDate.of(2026, 1, 31),
|
||||||
|
dayOfMonth = 31,
|
||||||
|
intervalMonths = 1,
|
||||||
|
),
|
||||||
|
startTime = LocalTime.of(20, 0),
|
||||||
|
createdAtMillis = 43L,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val decoded = ScheduleJsonCodec.decode(ScheduleJsonCodec.encode(schedules))
|
||||||
|
|
||||||
|
assertEquals(schedules, decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `corrupt json decodes to an empty list`() {
|
||||||
|
assertTrue(ScheduleJsonCodec.decode("{not json").isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unknown recurrence records are ignored`() {
|
||||||
|
val raw = """
|
||||||
|
{"version":1,"schedules":[
|
||||||
|
{"id":"x","name":"x","profileId":"p","fallbackProfileId":"f",
|
||||||
|
"type":"unknown","anchorDate":"2026-01-01","startTime":"09:00"}
|
||||||
|
]}
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
assertTrue(ScheduleJsonCodec.decode(raw).isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,705 @@
|
|||||||
|
package com.krrishg.kwall.ui.apps
|
||||||
|
|
||||||
|
import com.krrishg.kwall.AppChangeNotifier
|
||||||
|
import com.krrishg.kwall.MainDispatcherRule
|
||||||
|
import com.krrishg.kwall.data.AppRepository
|
||||||
|
import com.krrishg.kwall.data.InstalledApp
|
||||||
|
import com.krrishg.kwall.firewall.AppBlockability
|
||||||
|
import com.krrishg.kwall.firewall.AppKey
|
||||||
|
import com.krrishg.kwall.firewall.ChainTransport
|
||||||
|
import com.krrishg.kwall.firewall.DEFAULT_PROFILE_ID
|
||||||
|
import com.krrishg.kwall.firewall.FirewallAvailability
|
||||||
|
import com.krrishg.kwall.firewall.FirewallController
|
||||||
|
import com.krrishg.kwall.firewall.FirewallDriftState
|
||||||
|
import com.krrishg.kwall.firewall.FirewallState
|
||||||
|
import com.krrishg.kwall.firewall.RuleProfile
|
||||||
|
import com.krrishg.kwall.firewall.RulesTransport
|
||||||
|
import com.krrishg.kwall.firewall.defaultRuleProfile
|
||||||
|
import com.krrishg.kwall.scheduler.ProfileSchedule
|
||||||
|
import com.krrishg.kwall.scheduler.ScheduleRecurrence
|
||||||
|
import com.krrishg.kwall.scheduler.ScheduleStore
|
||||||
|
import com.krrishg.kwall.scheduler.SchedulerRuntime
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class AppListViewModelTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val mainDispatcherRule = MainDispatcherRule()
|
||||||
|
|
||||||
|
private val notes = installedApp("com.example.notes", "Notes", uid = 10123)
|
||||||
|
private val browser = installedApp("com.example.browser", "Browser", uid = 10124)
|
||||||
|
private val workChat = installedApp(
|
||||||
|
packageName = "com.example.chat",
|
||||||
|
label = "Chat",
|
||||||
|
uid = 10 * PER_USER_RANGE + 10125,
|
||||||
|
userId = 10,
|
||||||
|
isWorkProfile = true,
|
||||||
|
)
|
||||||
|
private val androidSystem = installedApp(
|
||||||
|
packageName = "android",
|
||||||
|
label = "Android System",
|
||||||
|
uid = 1000,
|
||||||
|
isSystem = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `system apps are hidden by default`() = runTest {
|
||||||
|
val viewModel = viewModel(FakeAppRepository(listOf(notes, browser, androidSystem)))
|
||||||
|
|
||||||
|
assertFalse(viewModel.uiState.value.showSystemApps)
|
||||||
|
assertEquals(listOf(notes, browser), viewModel.uiState.value.apps.map { it.app })
|
||||||
|
assertFalse(viewModel.uiState.value.isLoading)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `enabling system apps shows them`() = runTest {
|
||||||
|
val viewModel = viewModel(FakeAppRepository(listOf(notes, androidSystem)))
|
||||||
|
|
||||||
|
viewModel.setShowSystemApps(true)
|
||||||
|
|
||||||
|
assertTrue(viewModel.uiState.value.showSystemApps)
|
||||||
|
assertEquals(
|
||||||
|
listOf(notes, androidSystem),
|
||||||
|
viewModel.uiState.value.apps.map { it.app },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `disabling system apps hides them again`() = runTest {
|
||||||
|
val viewModel = viewModel(FakeAppRepository(listOf(notes, androidSystem)))
|
||||||
|
viewModel.setShowSystemApps(true)
|
||||||
|
|
||||||
|
viewModel.setShowSystemApps(false)
|
||||||
|
|
||||||
|
assertFalse(viewModel.uiState.value.showSystemApps)
|
||||||
|
assertEquals(listOf(notes), viewModel.uiState.value.apps.map { it.app })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `blocked filter shows only blocked apps`() = runTest {
|
||||||
|
val controller = FakeFirewallController(
|
||||||
|
initialState = state(blocked = setOf(notes.key)),
|
||||||
|
)
|
||||||
|
val viewModel = viewModel(FakeAppRepository(listOf(notes, browser)), controller)
|
||||||
|
|
||||||
|
viewModel.setFilter(AppListFilter.BLOCKED)
|
||||||
|
|
||||||
|
assertEquals(AppListFilter.BLOCKED, viewModel.uiState.value.filter)
|
||||||
|
assertEquals(listOf(notes), viewModel.uiState.value.apps.map { it.app })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unblocked filter shows only unblocked apps`() = runTest {
|
||||||
|
val controller = FakeFirewallController(
|
||||||
|
initialState = state(blocked = setOf(notes.key)),
|
||||||
|
)
|
||||||
|
val viewModel = viewModel(FakeAppRepository(listOf(notes, browser)), controller)
|
||||||
|
|
||||||
|
viewModel.setFilter(AppListFilter.UNBLOCKED)
|
||||||
|
|
||||||
|
assertEquals(listOf(browser), viewModel.uiState.value.apps.map { it.app })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all filter restores every app`() = runTest {
|
||||||
|
val controller = FakeFirewallController(
|
||||||
|
initialState = state(blocked = setOf(notes.key)),
|
||||||
|
)
|
||||||
|
val viewModel = viewModel(FakeAppRepository(listOf(notes, browser)), controller)
|
||||||
|
viewModel.setFilter(AppListFilter.BLOCKED)
|
||||||
|
|
||||||
|
viewModel.setFilter(AppListFilter.ALL)
|
||||||
|
|
||||||
|
assertEquals(listOf(notes, browser), viewModel.uiState.value.apps.map { it.app })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `search filters apps by name ignoring case`() = runTest {
|
||||||
|
val viewModel = viewModel(FakeAppRepository(listOf(notes, browser)))
|
||||||
|
|
||||||
|
viewModel.setSearchQuery("bRoW")
|
||||||
|
|
||||||
|
assertEquals("bRoW", viewModel.uiState.value.searchQuery)
|
||||||
|
assertEquals(listOf(browser), viewModel.uiState.value.apps.map { it.app })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `search ignores surrounding whitespace`() = runTest {
|
||||||
|
val viewModel = viewModel(FakeAppRepository(listOf(notes, browser)))
|
||||||
|
|
||||||
|
viewModel.setSearchQuery(" notes ")
|
||||||
|
|
||||||
|
assertEquals(listOf(notes), viewModel.uiState.value.apps.map { it.app })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `blank search shows all apps`() = runTest {
|
||||||
|
val viewModel = viewModel(FakeAppRepository(listOf(notes, browser)))
|
||||||
|
viewModel.setSearchQuery("notes")
|
||||||
|
|
||||||
|
viewModel.setSearchQuery("")
|
||||||
|
|
||||||
|
assertEquals(listOf(notes, browser), viewModel.uiState.value.apps.map { it.app })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `search and filter combine`() = runTest {
|
||||||
|
val controller = FakeFirewallController(
|
||||||
|
initialState = state(blocked = setOf(notes.key)),
|
||||||
|
)
|
||||||
|
val viewModel = viewModel(
|
||||||
|
FakeAppRepository(listOf(notes, browser, androidSystem)),
|
||||||
|
controller,
|
||||||
|
showSystemApps = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModel.setFilter(AppListFilter.UNBLOCKED)
|
||||||
|
viewModel.setSearchQuery("android")
|
||||||
|
|
||||||
|
assertEquals(listOf(androidSystem), viewModel.uiState.value.apps.map { it.app })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `refresh reloads installed apps`() = runTest {
|
||||||
|
val repository = FakeAppRepository(listOf(notes))
|
||||||
|
val viewModel = viewModel(repository)
|
||||||
|
repository.apps = listOf(notes, browser)
|
||||||
|
|
||||||
|
viewModel.refresh()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf(notes, browser),
|
||||||
|
viewModel.uiState.value.apps.map { it.app },
|
||||||
|
)
|
||||||
|
assertEquals(2, repository.loadCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `load failure exposes error`() = runTest {
|
||||||
|
val viewModel = viewModel(
|
||||||
|
FakeAppRepository(emptyList(), IllegalStateException("boom")),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertFalse(viewModel.uiState.value.isLoading)
|
||||||
|
assertEquals("boom", viewModel.uiState.value.errorMessage)
|
||||||
|
assertTrue(viewModel.uiState.value.apps.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `successful refresh clears previous error`() = runTest {
|
||||||
|
val repository = FakeAppRepository(emptyList(), IllegalStateException("boom"))
|
||||||
|
val viewModel = viewModel(repository)
|
||||||
|
repository.error = null
|
||||||
|
repository.apps = listOf(notes)
|
||||||
|
|
||||||
|
viewModel.refresh()
|
||||||
|
|
||||||
|
assertNull(viewModel.uiState.value.errorMessage)
|
||||||
|
assertEquals(listOf(notes), viewModel.uiState.value.apps.map { it.app })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `blocked state is reflected in rows`() = runTest {
|
||||||
|
val controller = FakeFirewallController(
|
||||||
|
initialState = state(enabled = true, blocked = setOf(notes.key)),
|
||||||
|
)
|
||||||
|
val viewModel = viewModel(FakeAppRepository(listOf(notes, browser)), controller)
|
||||||
|
|
||||||
|
val rows = viewModel.uiState.value.apps.associateBy { it.app.key }
|
||||||
|
|
||||||
|
assertTrue(rows.getValue(notes.key).isBlocked)
|
||||||
|
assertFalse(rows.getValue(browser.key).isBlocked)
|
||||||
|
assertEquals(1, viewModel.uiState.value.firewall.blockedCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toggling an app delegates to the controller without changing the master switch`() = runTest {
|
||||||
|
val controller = FakeFirewallController()
|
||||||
|
val viewModel = viewModel(FakeAppRepository(listOf(notes)), controller)
|
||||||
|
|
||||||
|
viewModel.setBlocked(notes, true)
|
||||||
|
|
||||||
|
assertEquals(listOf(notes.key to true), controller.blockedCalls)
|
||||||
|
assertFalse(viewModel.uiState.value.firewall.enabled)
|
||||||
|
assertEquals(1, viewModel.uiState.value.firewall.blockedCount)
|
||||||
|
assertFalse(viewModel.uiState.value.firewall.isApplying)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `master switch delegates to the controller`() = runTest {
|
||||||
|
val controller = FakeFirewallController()
|
||||||
|
val viewModel = viewModel(FakeAppRepository(listOf(notes)), controller)
|
||||||
|
|
||||||
|
viewModel.setFirewallEnabled(true)
|
||||||
|
|
||||||
|
assertTrue(controller.state.value.enabled)
|
||||||
|
assertFalse(viewModel.uiState.value.firewall.isApplying)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `system uid apps are marked unblockable`() = runTest {
|
||||||
|
val viewModel = viewModel(
|
||||||
|
FakeAppRepository(listOf(notes, androidSystem)),
|
||||||
|
showSystemApps = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
val row = viewModel.uiState.value.apps.first { it.app.key == androidSystem.key }
|
||||||
|
|
||||||
|
assertEquals(AppBlockability.SYSTEM_UID, row.blockability)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `work profile rows stay distinct from personal rows`() = runTest {
|
||||||
|
val personalChat = installedApp("com.example.chat", "Chat", uid = 10125)
|
||||||
|
val viewModel = viewModel(
|
||||||
|
FakeAppRepository(listOf(personalChat, workChat)),
|
||||||
|
)
|
||||||
|
|
||||||
|
val keys = viewModel.uiState.value.apps.map { it.app.key }
|
||||||
|
|
||||||
|
assertEquals(2, keys.size)
|
||||||
|
assertTrue(personalChat.key in keys)
|
||||||
|
assertTrue(workChat.key in keys)
|
||||||
|
assertTrue(viewModel.uiState.value.apps.first { it.app == workChat }.app.isWorkProfile)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `work profile warns when shell blocks all profiles`() = runTest {
|
||||||
|
val personalChat = installedApp("com.example.chat", "Chat", uid = 10125)
|
||||||
|
val controller = FakeFirewallController(
|
||||||
|
initialAvailability = FirewallAvailability.Ready(
|
||||||
|
rulesTransport = RulesTransport.SHELL,
|
||||||
|
chainTransport = ChainTransport.SHELL,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val viewModel = viewModel(FakeAppRepository(listOf(personalChat, workChat)), controller)
|
||||||
|
|
||||||
|
val row = viewModel.uiState.value.apps.first { it.app == workChat }
|
||||||
|
|
||||||
|
assertEquals(AppBlockability.BLOCKABLE, row.blockability)
|
||||||
|
assertTrue(row.warnAllProfiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `work only app is disabled when shell is the only transport`() = runTest {
|
||||||
|
val controller = FakeFirewallController(
|
||||||
|
initialAvailability = FirewallAvailability.Ready(
|
||||||
|
rulesTransport = RulesTransport.SHELL,
|
||||||
|
chainTransport = ChainTransport.SHELL,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val viewModel = viewModel(FakeAppRepository(listOf(workChat)), controller)
|
||||||
|
|
||||||
|
val row = viewModel.uiState.value.apps.first { it.app == workChat }
|
||||||
|
|
||||||
|
assertEquals(AppBlockability.WORK_PROFILE_ONLY, row.blockability)
|
||||||
|
assertFalse(row.warnAllProfiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `permission request delegates to the controller`() = runTest {
|
||||||
|
val controller = FakeFirewallController()
|
||||||
|
val viewModel = viewModel(FakeAppRepository(listOf(notes)), controller)
|
||||||
|
|
||||||
|
viewModel.requestFirewallPermission()
|
||||||
|
|
||||||
|
assertTrue(controller.permissionRequested)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `selecting an app exposes its row`() = runTest {
|
||||||
|
val viewModel = viewModel(FakeAppRepository(listOf(notes, browser)))
|
||||||
|
val row = viewModel.uiState.value.apps.first { it.app == notes }
|
||||||
|
|
||||||
|
viewModel.selectApp(row)
|
||||||
|
|
||||||
|
assertEquals(row, viewModel.uiState.value.selectedApp)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `clearing the selection returns to the list`() = runTest {
|
||||||
|
val viewModel = viewModel(FakeAppRepository(listOf(notes)))
|
||||||
|
val row = viewModel.uiState.value.apps.first()
|
||||||
|
|
||||||
|
viewModel.selectApp(row)
|
||||||
|
viewModel.clearSelection()
|
||||||
|
|
||||||
|
assertNull(viewModel.uiState.value.selectedApp)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `profile state exposes profiles and active profile`() = runTest {
|
||||||
|
val work = RuleProfile(
|
||||||
|
id = "work",
|
||||||
|
name = "Work",
|
||||||
|
blockedApps = setOf(notes.key),
|
||||||
|
createdAtMillis = 2L,
|
||||||
|
)
|
||||||
|
val controller = FakeFirewallController(
|
||||||
|
initialState = FirewallState(
|
||||||
|
enabled = true,
|
||||||
|
profiles = listOf(defaultRuleProfile(createdAtMillis = 1L), work),
|
||||||
|
activeProfileId = work.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val viewModel = viewModel(FakeAppRepository(listOf(notes, browser)), controller)
|
||||||
|
|
||||||
|
assertEquals("Work", viewModel.uiState.value.firewall.activeProfile?.name)
|
||||||
|
assertEquals(2, viewModel.uiState.value.firewall.profiles.size)
|
||||||
|
assertEquals(1, viewModel.uiState.value.firewall.blockedCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `profile actions delegate to the controller`() = runTest {
|
||||||
|
val work = RuleProfile(
|
||||||
|
id = "work",
|
||||||
|
name = "Work",
|
||||||
|
blockedApps = setOf(notes.key),
|
||||||
|
createdAtMillis = 2L,
|
||||||
|
)
|
||||||
|
val controller = FakeFirewallController(
|
||||||
|
initialState = FirewallState(
|
||||||
|
profiles = listOf(defaultRuleProfile(createdAtMillis = 1L), work),
|
||||||
|
activeProfileId = work.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val viewModel = viewModel(FakeAppRepository(listOf(notes)), controller)
|
||||||
|
|
||||||
|
viewModel.switchProfile(work.id)
|
||||||
|
viewModel.createProfile("Travel")
|
||||||
|
viewModel.renameProfile(work.id, "Office")
|
||||||
|
viewModel.duplicateProfile(work.id, "Office copy")
|
||||||
|
viewModel.deleteProfile("unknown")
|
||||||
|
|
||||||
|
assertEquals(listOf(work.id), controller.switchedProfiles)
|
||||||
|
assertEquals(listOf("Travel"), controller.createdProfiles)
|
||||||
|
assertEquals(listOf(work.id to "Office"), controller.renamedProfiles)
|
||||||
|
assertEquals(listOf(work.id to "Office copy"), controller.duplicatedProfiles)
|
||||||
|
assertEquals(listOf("unknown"), controller.deletedProfiles)
|
||||||
|
assertFalse(viewModel.uiState.value.firewall.isApplying)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `deleting a profile used by a schedule is rejected`() = runTest {
|
||||||
|
val work = RuleProfile(
|
||||||
|
id = "work",
|
||||||
|
name = "Work",
|
||||||
|
createdAtMillis = 2L,
|
||||||
|
)
|
||||||
|
val controller = FakeFirewallController(
|
||||||
|
initialState = FirewallState(
|
||||||
|
profiles = listOf(defaultRuleProfile(createdAtMillis = 1L), work),
|
||||||
|
activeProfileId = work.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val schedule = ProfileSchedule(
|
||||||
|
id = "schedule",
|
||||||
|
name = "Work hours",
|
||||||
|
profileId = work.id,
|
||||||
|
fallbackProfileId = DEFAULT_PROFILE_ID,
|
||||||
|
recurrence = ScheduleRecurrence.Once(LocalDate.now().plusDays(1)),
|
||||||
|
startTime = LocalTime.of(9, 0),
|
||||||
|
)
|
||||||
|
val viewModel = viewModel(
|
||||||
|
repository = FakeAppRepository(listOf(notes)),
|
||||||
|
controller = controller,
|
||||||
|
scheduleStore = FakeScheduleStore(listOf(schedule)),
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModel.deleteProfile(work.id)
|
||||||
|
|
||||||
|
assertTrue(controller.deletedProfiles.isEmpty())
|
||||||
|
assertEquals(
|
||||||
|
"This profile is used by a schedule",
|
||||||
|
viewModel.uiState.value.firewall.errorMessage,
|
||||||
|
)
|
||||||
|
assertFalse(viewModel.uiState.value.firewall.isApplying)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun state(
|
||||||
|
enabled: Boolean = false,
|
||||||
|
blocked: Set<AppKey> = emptySet(),
|
||||||
|
): FirewallState = FirewallState(
|
||||||
|
enabled = enabled,
|
||||||
|
profiles = listOf(defaultRuleProfile(blockedApps = blocked, createdAtMillis = 1L)),
|
||||||
|
activeProfileId = defaultRuleProfile().id,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun viewModel(
|
||||||
|
repository: AppRepository,
|
||||||
|
controller: FakeFirewallController = FakeFirewallController(),
|
||||||
|
showSystemApps: Boolean = false,
|
||||||
|
scheduleStore: ScheduleStore = FakeScheduleStore(),
|
||||||
|
): AppListViewModel {
|
||||||
|
val viewModel = AppListViewModel(
|
||||||
|
repository = repository,
|
||||||
|
firewallController = controller,
|
||||||
|
appChangeNotifier = AppChangeNotifier(),
|
||||||
|
scheduleStore = scheduleStore,
|
||||||
|
)
|
||||||
|
if (showSystemApps) viewModel.setShowSystemApps(true)
|
||||||
|
return viewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun installedApp(
|
||||||
|
packageName: String,
|
||||||
|
label: String,
|
||||||
|
uid: Int,
|
||||||
|
userId: Int = 0,
|
||||||
|
isWorkProfile: Boolean = false,
|
||||||
|
hasInternetPermission: Boolean = true,
|
||||||
|
isSystem: Boolean = false,
|
||||||
|
) = InstalledApp(
|
||||||
|
packageName = packageName,
|
||||||
|
label = label,
|
||||||
|
isSystem = isSystem,
|
||||||
|
icon = null,
|
||||||
|
uid = uid,
|
||||||
|
userId = userId,
|
||||||
|
isWorkProfile = isWorkProfile,
|
||||||
|
hasInternetPermission = hasInternetPermission,
|
||||||
|
)
|
||||||
|
|
||||||
|
private class FakeAppRepository(
|
||||||
|
var apps: List<InstalledApp>,
|
||||||
|
var error: Exception? = null,
|
||||||
|
) : AppRepository {
|
||||||
|
|
||||||
|
var loadCount = 0
|
||||||
|
|
||||||
|
override suspend fun loadInstalledApps(): List<InstalledApp> {
|
||||||
|
loadCount++
|
||||||
|
error?.let { throw it }
|
||||||
|
return apps
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeFirewallController(
|
||||||
|
initialState: FirewallState = FirewallState(),
|
||||||
|
initialAvailability: FirewallAvailability = FirewallAvailability.Ready(
|
||||||
|
rulesTransport = RulesTransport.BINDER,
|
||||||
|
chainTransport = ChainTransport.SHELL,
|
||||||
|
),
|
||||||
|
) : FirewallController {
|
||||||
|
|
||||||
|
private val stateFlow = MutableStateFlow(initialState)
|
||||||
|
override val state: StateFlow<FirewallState> = stateFlow
|
||||||
|
|
||||||
|
private val availabilityFlow = MutableStateFlow(initialAvailability)
|
||||||
|
override val availability: StateFlow<FirewallAvailability> = availabilityFlow
|
||||||
|
|
||||||
|
override val error = MutableStateFlow<String?>(null)
|
||||||
|
override val drift = MutableStateFlow(FirewallDriftState())
|
||||||
|
|
||||||
|
val blockedCalls = mutableListOf<Pair<AppKey, Boolean>>()
|
||||||
|
val switchedProfiles = mutableListOf<String>()
|
||||||
|
val createdProfiles = mutableListOf<String>()
|
||||||
|
val renamedProfiles = mutableListOf<Pair<String, String>>()
|
||||||
|
val duplicatedProfiles = mutableListOf<Pair<String, String>>()
|
||||||
|
val deletedProfiles = mutableListOf<String>()
|
||||||
|
var permissionRequested = false
|
||||||
|
private var profileCounter = 0
|
||||||
|
|
||||||
|
override suspend fun setBlocked(app: InstalledApp, blocked: Boolean) {
|
||||||
|
blockedCalls += app.key to blocked
|
||||||
|
stateFlow.update { current ->
|
||||||
|
current.updateActiveApps { apps ->
|
||||||
|
if (blocked) apps + app.key else apps - app.key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun addBlockedSelection(app: InstalledApp): Boolean {
|
||||||
|
if (app.key in stateFlow.value.blockedApps) return false
|
||||||
|
stateFlow.update { current ->
|
||||||
|
current.updateActiveApps { apps -> apps + app.key }
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setEnabled(enabled: Boolean) {
|
||||||
|
stateFlow.update { it.copy(enabled = enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun applyScheduledProfile(
|
||||||
|
profileId: String,
|
||||||
|
enableFirewall: Boolean?,
|
||||||
|
): Boolean = true
|
||||||
|
|
||||||
|
override suspend fun restore() = Unit
|
||||||
|
|
||||||
|
override suspend fun checkForDrift() = Unit
|
||||||
|
|
||||||
|
override suspend fun refreshAvailability() = Unit
|
||||||
|
|
||||||
|
override fun requestPermission() {
|
||||||
|
permissionRequested = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun removeBlocked(key: AppKey) {
|
||||||
|
stateFlow.update { current ->
|
||||||
|
current.updateActiveApps { apps -> apps - key }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun removeBlockedForUser(userId: Int) {
|
||||||
|
stateFlow.update { current ->
|
||||||
|
current.updateActiveApps { apps -> apps.filterNot { it.userId == userId }.toSet() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun switchProfile(id: String) {
|
||||||
|
switchedProfiles += id
|
||||||
|
stateFlow.update { current ->
|
||||||
|
if (current.profiles.none { it.id == id }) current
|
||||||
|
else current.copy(activeProfileId = id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun createProfile(name: String): Boolean {
|
||||||
|
createdProfiles += name
|
||||||
|
val profile = RuleProfile(
|
||||||
|
id = "profile-${++profileCounter}",
|
||||||
|
name = name,
|
||||||
|
blockedApps = emptySet(),
|
||||||
|
createdAtMillis = profileCounter.toLong(),
|
||||||
|
)
|
||||||
|
stateFlow.update { it.copy(profiles = it.profiles + profile) }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun duplicateProfile(sourceId: String, name: String): Boolean {
|
||||||
|
duplicatedProfiles += sourceId to name
|
||||||
|
val source = stateFlow.value.profiles.firstOrNull { it.id == sourceId } ?: return false
|
||||||
|
val profile = RuleProfile(
|
||||||
|
id = "profile-${++profileCounter}",
|
||||||
|
name = name,
|
||||||
|
blockedApps = source.blockedApps,
|
||||||
|
createdAtMillis = profileCounter.toLong(),
|
||||||
|
)
|
||||||
|
stateFlow.update { it.copy(profiles = it.profiles + profile) }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun renameProfile(id: String, name: String): Boolean {
|
||||||
|
renamedProfiles += id to name
|
||||||
|
stateFlow.update { current ->
|
||||||
|
current.copy(
|
||||||
|
profiles = current.profiles.map { profile ->
|
||||||
|
if (profile.id == id) profile.copy(name = name) else profile
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteProfile(id: String): Boolean {
|
||||||
|
deletedProfiles += id
|
||||||
|
val current = stateFlow.value
|
||||||
|
if (current.profiles.size <= 1) return false
|
||||||
|
val remaining = current.profiles.filterNot { it.id == id }
|
||||||
|
val activeId = if (current.activeProfileId == id) remaining.first().id else current.activeProfileId
|
||||||
|
stateFlow.value = current.copy(profiles = remaining, activeProfileId = activeId)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun removeBlockedEverywhere(key: AppKey) {
|
||||||
|
stateFlow.update { current ->
|
||||||
|
current.copy(
|
||||||
|
profiles = current.profiles.map { profile ->
|
||||||
|
profile.copy(blockedApps = profile.blockedApps - key)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun removeBlockedForUserEverywhere(userId: Int) {
|
||||||
|
stateFlow.update { current ->
|
||||||
|
current.copy(
|
||||||
|
profiles = current.profiles.map { profile ->
|
||||||
|
profile.copy(
|
||||||
|
blockedApps = profile.blockedApps.filterNot { it.userId == userId }.toSet(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun FirewallState.updateActiveApps(transform: (Set<AppKey>) -> Set<AppKey>): FirewallState {
|
||||||
|
val activeId = activeProfile.id
|
||||||
|
return copy(
|
||||||
|
profiles = profiles.map { profile ->
|
||||||
|
if (profile.id == activeId) {
|
||||||
|
profile.copy(blockedApps = transform(profile.blockedApps))
|
||||||
|
} else {
|
||||||
|
profile
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeScheduleStore(
|
||||||
|
initialState: List<ProfileSchedule> = emptyList(),
|
||||||
|
) : ScheduleStore {
|
||||||
|
|
||||||
|
private val scheduleFlow = MutableStateFlow(initialState)
|
||||||
|
override val schedules: StateFlow<List<ProfileSchedule>> = scheduleFlow
|
||||||
|
private var runtime = SchedulerRuntime()
|
||||||
|
|
||||||
|
override suspend fun create(schedule: ProfileSchedule): Boolean {
|
||||||
|
scheduleFlow.update { it + schedule }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun update(schedule: ProfileSchedule): Boolean {
|
||||||
|
scheduleFlow.update { schedules ->
|
||||||
|
if (schedules.none { it.id == schedule.id }) {
|
||||||
|
schedules
|
||||||
|
} else {
|
||||||
|
schedules.map { if (it.id == schedule.id) schedule else it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun delete(id: String): Boolean {
|
||||||
|
scheduleFlow.update { schedules -> schedules.filterNot { it.id == id } }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setEnabled(id: String, enabled: Boolean): Boolean {
|
||||||
|
scheduleFlow.update { schedules ->
|
||||||
|
schedules.map { if (it.id == id) it.copy(enabled = enabled) else it }
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun readRuntime(): SchedulerRuntime = runtime
|
||||||
|
|
||||||
|
override suspend fun writeRuntime(runtime: SchedulerRuntime) {
|
||||||
|
this.runtime = runtime
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun clearRuntime() {
|
||||||
|
runtime = SchedulerRuntime()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val PER_USER_RANGE = 100_000
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
package com.krrishg.kwall.ui.detail
|
||||||
|
|
||||||
|
import com.krrishg.kwall.MainDispatcherRule
|
||||||
|
import com.krrishg.kwall.data.InstalledApp
|
||||||
|
import com.krrishg.kwall.firewall.AppKey
|
||||||
|
import com.krrishg.kwall.firewall.ChainTransport
|
||||||
|
import com.krrishg.kwall.firewall.FirewallAvailability
|
||||||
|
import com.krrishg.kwall.firewall.FirewallController
|
||||||
|
import com.krrishg.kwall.firewall.FirewallDriftState
|
||||||
|
import com.krrishg.kwall.firewall.FirewallState
|
||||||
|
import com.krrishg.kwall.firewall.RulesTransport
|
||||||
|
import com.krrishg.kwall.network.ConnectionInspector
|
||||||
|
import com.krrishg.kwall.network.ConnectionSnapshot
|
||||||
|
import com.krrishg.kwall.network.DataUsage
|
||||||
|
import com.krrishg.kwall.network.NetworkUsageSource
|
||||||
|
import com.krrishg.kwall.network.SocketEntry
|
||||||
|
import com.krrishg.kwall.network.SocketProtocol
|
||||||
|
import com.krrishg.kwall.network.TcpState
|
||||||
|
import com.krrishg.kwall.network.UsageRange
|
||||||
|
import com.krrishg.kwall.network.UsageResult
|
||||||
|
import java.net.InetAddress
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class AppDetailViewModelTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val mainDispatcherRule = MainDispatcherRule()
|
||||||
|
|
||||||
|
private val notes = InstalledApp(
|
||||||
|
packageName = "com.example.notes",
|
||||||
|
label = "Notes",
|
||||||
|
isSystem = false,
|
||||||
|
icon = null,
|
||||||
|
uid = 10123,
|
||||||
|
userId = 0,
|
||||||
|
isWorkProfile = false,
|
||||||
|
hasInternetPermission = true,
|
||||||
|
)
|
||||||
|
private val browser = notes.copy(
|
||||||
|
packageName = "com.example.browser",
|
||||||
|
label = "Browser",
|
||||||
|
uid = 10124,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `load exposes the app and fetches its usage`() = runTest {
|
||||||
|
val usage = FakeUsageSource(
|
||||||
|
result = UsageResult.Available(DataUsage(receivedBytes = 120, sentBytes = 30)),
|
||||||
|
)
|
||||||
|
val viewModel = viewModel(usage = usage)
|
||||||
|
|
||||||
|
viewModel.load(notes)
|
||||||
|
|
||||||
|
assertEquals(notes, viewModel.uiState.value.app)
|
||||||
|
assertEquals(
|
||||||
|
UsageUiState.Loaded(DataUsage(receivedBytes = 120, sentBytes = 30)),
|
||||||
|
viewModel.uiState.value.usage,
|
||||||
|
)
|
||||||
|
assertEquals(notes.uid, usage.lastUid)
|
||||||
|
assertEquals(listOf(UsageRange.TODAY), usage.requestedRanges)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `connections are filtered by uid and split into active and listening`() = runTest {
|
||||||
|
val inspector = FakeInspector(
|
||||||
|
result = ConnectionSnapshot.Available(
|
||||||
|
listOf(
|
||||||
|
socket(uid = notes.uid, remotePort = 443, state = TcpState.ESTABLISHED),
|
||||||
|
socket(uid = browser.uid, remotePort = 80, state = TcpState.ESTABLISHED),
|
||||||
|
socket(
|
||||||
|
uid = notes.uid,
|
||||||
|
localPort = 8080,
|
||||||
|
remotePort = 0,
|
||||||
|
state = TcpState.LISTEN,
|
||||||
|
remoteAddress = null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val viewModel = viewModel(inspector = inspector)
|
||||||
|
viewModel.load(notes)
|
||||||
|
|
||||||
|
viewModel.refreshConnections()
|
||||||
|
|
||||||
|
val connections = viewModel.uiState.value.connections as ConnectionsUiState.Loaded
|
||||||
|
assertEquals(1, connections.active.size)
|
||||||
|
assertEquals(443, connections.active.single().remotePort)
|
||||||
|
assertEquals(1, connections.listening.size)
|
||||||
|
assertEquals(8080, connections.listening.single().localPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unavailable socket inspection is reported`() = runTest {
|
||||||
|
val inspector = FakeInspector(
|
||||||
|
result = ConnectionSnapshot.Unavailable("Permission denied"),
|
||||||
|
)
|
||||||
|
val viewModel = viewModel(inspector = inspector)
|
||||||
|
viewModel.load(notes)
|
||||||
|
|
||||||
|
viewModel.refreshConnections()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
ConnectionsUiState.Unavailable("Permission denied"),
|
||||||
|
viewModel.uiState.value.connections,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `permission required is reported for usage`() = runTest {
|
||||||
|
val usage = FakeUsageSource(result = UsageResult.PermissionRequired)
|
||||||
|
val viewModel = viewModel(usage = usage)
|
||||||
|
|
||||||
|
viewModel.load(notes)
|
||||||
|
|
||||||
|
assertEquals(UsageUiState.PermissionRequired, viewModel.uiState.value.usage)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `selecting a range refreshes usage for that range`() = runTest {
|
||||||
|
val usage = FakeUsageSource()
|
||||||
|
val viewModel = viewModel(usage = usage)
|
||||||
|
viewModel.load(notes)
|
||||||
|
usage.requestedRanges.clear()
|
||||||
|
|
||||||
|
viewModel.selectUsageRange(UsageRange.WEEK)
|
||||||
|
|
||||||
|
assertEquals(UsageRange.WEEK, viewModel.uiState.value.usageRange)
|
||||||
|
assertEquals(listOf(UsageRange.WEEK), usage.requestedRanges)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `blocking delegates to the firewall controller`() = runTest {
|
||||||
|
val controller = FakeFirewallController()
|
||||||
|
val viewModel = viewModel(controller = controller)
|
||||||
|
viewModel.load(notes)
|
||||||
|
|
||||||
|
viewModel.setBlocked(true)
|
||||||
|
|
||||||
|
assertEquals(listOf(notes.key to true), controller.blockedCalls)
|
||||||
|
assertTrue(viewModel.uiState.value.isBlocked)
|
||||||
|
assertFalse(viewModel.uiState.value.isBlocking)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun viewModel(
|
||||||
|
inspector: FakeInspector = FakeInspector(),
|
||||||
|
usage: FakeUsageSource = FakeUsageSource(),
|
||||||
|
controller: FakeFirewallController = FakeFirewallController(),
|
||||||
|
) = AppDetailViewModel(
|
||||||
|
connectionInspector = inspector,
|
||||||
|
usageSource = usage,
|
||||||
|
firewallController = controller,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun socket(
|
||||||
|
uid: Int,
|
||||||
|
localPort: Int = 41234,
|
||||||
|
remotePort: Int = 443,
|
||||||
|
state: TcpState = TcpState.ESTABLISHED,
|
||||||
|
remoteAddress: InetAddress? = InetAddress.getByAddress(byteArrayOf(1, 1, 1, 1)),
|
||||||
|
) = SocketEntry(
|
||||||
|
uid = uid,
|
||||||
|
protocol = SocketProtocol.TCP,
|
||||||
|
localAddress = InetAddress.getByAddress(byteArrayOf(127, 0, 0, 1)),
|
||||||
|
localPort = localPort,
|
||||||
|
remoteAddress = remoteAddress,
|
||||||
|
remotePort = remotePort,
|
||||||
|
state = state,
|
||||||
|
)
|
||||||
|
|
||||||
|
private class FakeInspector(
|
||||||
|
var result: ConnectionSnapshot = ConnectionSnapshot.Available(emptyList()),
|
||||||
|
) : ConnectionInspector {
|
||||||
|
override suspend fun snapshot(): ConnectionSnapshot = result
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeUsageSource(
|
||||||
|
var result: UsageResult = UsageResult.Available(DataUsage.Zero),
|
||||||
|
) : NetworkUsageSource {
|
||||||
|
val requestedRanges = mutableListOf<UsageRange>()
|
||||||
|
var lastUid: Int? = null
|
||||||
|
|
||||||
|
override fun hasUsageAccess(): Boolean = true
|
||||||
|
|
||||||
|
override suspend fun usageFor(uid: Int, range: UsageRange): UsageResult {
|
||||||
|
lastUid = uid
|
||||||
|
requestedRanges += range
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeFirewallController : FirewallController {
|
||||||
|
private val stateFlow = MutableStateFlow(FirewallState())
|
||||||
|
override val state: StateFlow<FirewallState> = stateFlow
|
||||||
|
|
||||||
|
override val availability = MutableStateFlow<FirewallAvailability>(
|
||||||
|
FirewallAvailability.Ready(
|
||||||
|
rulesTransport = RulesTransport.BINDER,
|
||||||
|
chainTransport = ChainTransport.SHELL,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
override val error = MutableStateFlow<String?>(null)
|
||||||
|
override val drift = MutableStateFlow(FirewallDriftState())
|
||||||
|
|
||||||
|
val blockedCalls = mutableListOf<Pair<AppKey, Boolean>>()
|
||||||
|
|
||||||
|
override suspend fun setBlocked(app: InstalledApp, blocked: Boolean) {
|
||||||
|
blockedCalls += app.key to blocked
|
||||||
|
stateFlow.update { current ->
|
||||||
|
current.updateActiveApps { apps ->
|
||||||
|
if (blocked) apps + app.key else apps - app.key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun addBlockedSelection(app: InstalledApp): Boolean {
|
||||||
|
if (app.key in stateFlow.value.blockedApps) return false
|
||||||
|
stateFlow.update { current ->
|
||||||
|
current.updateActiveApps { apps -> apps + app.key }
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setEnabled(enabled: Boolean) {
|
||||||
|
stateFlow.update { it.copy(enabled = enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun applyScheduledProfile(
|
||||||
|
profileId: String,
|
||||||
|
enableFirewall: Boolean?,
|
||||||
|
): Boolean = true
|
||||||
|
|
||||||
|
override suspend fun restore() = Unit
|
||||||
|
|
||||||
|
override suspend fun checkForDrift() = Unit
|
||||||
|
|
||||||
|
override suspend fun refreshAvailability() = Unit
|
||||||
|
|
||||||
|
override fun requestPermission() = Unit
|
||||||
|
|
||||||
|
override suspend fun removeBlocked(key: AppKey) {
|
||||||
|
stateFlow.update { current ->
|
||||||
|
current.updateActiveApps { apps -> apps - key }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun removeBlockedForUser(userId: Int) {
|
||||||
|
stateFlow.update { current ->
|
||||||
|
current.updateActiveApps { apps ->
|
||||||
|
apps.filterNot { it.userId == userId }.toSet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun switchProfile(id: String) = Unit
|
||||||
|
|
||||||
|
override suspend fun createProfile(name: String) = false
|
||||||
|
|
||||||
|
override suspend fun duplicateProfile(sourceId: String, name: String) = false
|
||||||
|
|
||||||
|
override suspend fun renameProfile(id: String, name: String) = false
|
||||||
|
|
||||||
|
override suspend fun deleteProfile(id: String) = false
|
||||||
|
|
||||||
|
override suspend fun removeBlockedEverywhere(key: AppKey) = Unit
|
||||||
|
|
||||||
|
override suspend fun removeBlockedForUserEverywhere(userId: Int) = Unit
|
||||||
|
|
||||||
|
private fun FirewallState.updateActiveApps(
|
||||||
|
transform: (Set<AppKey>) -> Set<AppKey>,
|
||||||
|
): FirewallState {
|
||||||
|
val activeId = activeProfile.id
|
||||||
|
return copy(
|
||||||
|
profiles = profiles.map { profile ->
|
||||||
|
if (profile.id == activeId) {
|
||||||
|
profile.copy(blockedApps = transform(profile.blockedApps))
|
||||||
|
} else {
|
||||||
|
profile
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
package com.krrishg.kwall.ui.schedules
|
||||||
|
|
||||||
|
import com.krrishg.kwall.MainDispatcherRule
|
||||||
|
import com.krrishg.kwall.data.InstalledApp
|
||||||
|
import com.krrishg.kwall.firewall.AppKey
|
||||||
|
import com.krrishg.kwall.firewall.ChainTransport
|
||||||
|
import com.krrishg.kwall.firewall.DEFAULT_PROFILE_ID
|
||||||
|
import com.krrishg.kwall.firewall.FirewallAvailability
|
||||||
|
import com.krrishg.kwall.firewall.FirewallController
|
||||||
|
import com.krrishg.kwall.firewall.FirewallDriftState
|
||||||
|
import com.krrishg.kwall.firewall.FirewallState
|
||||||
|
import com.krrishg.kwall.firewall.RuleProfile
|
||||||
|
import com.krrishg.kwall.firewall.RulesTransport
|
||||||
|
import com.krrishg.kwall.firewall.defaultRuleProfile
|
||||||
|
import com.krrishg.kwall.scheduler.ProfileSchedule
|
||||||
|
import com.krrishg.kwall.scheduler.ScheduleAlarms
|
||||||
|
import com.krrishg.kwall.scheduler.ScheduleIssue
|
||||||
|
import com.krrishg.kwall.scheduler.ScheduleRecurrence
|
||||||
|
import com.krrishg.kwall.scheduler.ScheduleStore
|
||||||
|
import com.krrishg.kwall.scheduler.SchedulerRuntime
|
||||||
|
import java.time.Clock
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class SchedulesViewModelTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val mainDispatcherRule = MainDispatcherRule()
|
||||||
|
|
||||||
|
private val work = RuleProfile(
|
||||||
|
id = "work",
|
||||||
|
name = "Work",
|
||||||
|
createdAtMillis = 2L,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `saving an overlapping schedule is rejected`() = runTest {
|
||||||
|
val existing = schedule(start = 9, end = 12)
|
||||||
|
val store = FakeScheduleStore(listOf(existing))
|
||||||
|
val viewModel = viewModel(store)
|
||||||
|
|
||||||
|
viewModel.save(
|
||||||
|
schedule(
|
||||||
|
id = "new",
|
||||||
|
start = 11,
|
||||||
|
end = 13,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(ScheduleIssue.OVERLAP, viewModel.uiState.value.errorIssue)
|
||||||
|
assertEquals(listOf(existing), store.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `saving a valid schedule persists it`() = runTest {
|
||||||
|
val store = FakeScheduleStore(emptyList())
|
||||||
|
val viewModel = viewModel(store)
|
||||||
|
val schedule = schedule(id = "new", start = 9, end = 12)
|
||||||
|
|
||||||
|
viewModel.save(schedule)
|
||||||
|
|
||||||
|
assertEquals(schedule, store.value.single())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `enabling an overlapping schedule is rejected`() = runTest {
|
||||||
|
val existing = schedule(start = 9, end = 12)
|
||||||
|
val disabled = schedule(id = "new", start = 11, end = 13).copy(enabled = false)
|
||||||
|
val store = FakeScheduleStore(listOf(existing, disabled))
|
||||||
|
val viewModel = viewModel(store)
|
||||||
|
|
||||||
|
viewModel.setEnabled(disabled.id, true)
|
||||||
|
|
||||||
|
assertEquals(ScheduleIssue.OVERLAP, viewModel.uiState.value.errorIssue)
|
||||||
|
assertTrue(store.value.none { it.id == disabled.id && it.enabled })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun viewModel(store: ScheduleStore): SchedulesViewModel = SchedulesViewModel(
|
||||||
|
scheduleStore = store,
|
||||||
|
firewallController = FakeController(),
|
||||||
|
alarms = FakeAlarms(),
|
||||||
|
clock = Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC),
|
||||||
|
zoneProvider = { ZoneOffset.UTC },
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun schedule(
|
||||||
|
id: String = "schedule",
|
||||||
|
start: Int,
|
||||||
|
end: Int?,
|
||||||
|
): ProfileSchedule = ProfileSchedule(
|
||||||
|
id = id,
|
||||||
|
name = "Work hours",
|
||||||
|
profileId = work.id,
|
||||||
|
fallbackProfileId = DEFAULT_PROFILE_ID,
|
||||||
|
recurrence = ScheduleRecurrence.Once(LocalDate.of(2026, 1, 5)),
|
||||||
|
startTime = LocalTime.of(start, 0),
|
||||||
|
endTime = end?.let { LocalTime.of(it, 0) },
|
||||||
|
createdAtMillis = 3L,
|
||||||
|
)
|
||||||
|
|
||||||
|
private class FakeAlarms : ScheduleAlarms {
|
||||||
|
override fun canScheduleExactAlarms(): Boolean = true
|
||||||
|
override fun scheduleNext(at: Instant) = Unit
|
||||||
|
override fun cancel() = Unit
|
||||||
|
override fun requestExactAlarmPermission() = Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeScheduleStore(
|
||||||
|
initial: List<ProfileSchedule>,
|
||||||
|
) : ScheduleStore {
|
||||||
|
private val flow = MutableStateFlow(initial)
|
||||||
|
override val schedules: StateFlow<List<ProfileSchedule>> = flow
|
||||||
|
val value: List<ProfileSchedule> get() = flow.value
|
||||||
|
private var runtime = SchedulerRuntime()
|
||||||
|
|
||||||
|
override suspend fun create(schedule: ProfileSchedule): Boolean {
|
||||||
|
flow.update { it + schedule }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun update(schedule: ProfileSchedule): Boolean {
|
||||||
|
flow.update { schedules ->
|
||||||
|
schedules.map { if (it.id == schedule.id) schedule else it }
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun delete(id: String): Boolean {
|
||||||
|
flow.update { schedules -> schedules.filterNot { it.id == id } }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setEnabled(id: String, enabled: Boolean): Boolean {
|
||||||
|
flow.update { schedules ->
|
||||||
|
schedules.map { if (it.id == id) it.copy(enabled = enabled) else it }
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun readRuntime(): SchedulerRuntime = runtime
|
||||||
|
|
||||||
|
override suspend fun writeRuntime(runtime: SchedulerRuntime) {
|
||||||
|
this.runtime = runtime
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun clearRuntime() {
|
||||||
|
runtime = SchedulerRuntime()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeController : FirewallController {
|
||||||
|
private val stateFlow = MutableStateFlow(
|
||||||
|
FirewallState(
|
||||||
|
profiles = listOf(
|
||||||
|
defaultRuleProfile(createdAtMillis = 1L),
|
||||||
|
RuleProfile(id = "work", name = "Work", createdAtMillis = 2L),
|
||||||
|
),
|
||||||
|
activeProfileId = DEFAULT_PROFILE_ID,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
override val state: StateFlow<FirewallState> = stateFlow
|
||||||
|
override val availability: StateFlow<FirewallAvailability> = MutableStateFlow(
|
||||||
|
FirewallAvailability.Ready(
|
||||||
|
rulesTransport = RulesTransport.BINDER,
|
||||||
|
chainTransport = ChainTransport.SHELL,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
override val error = MutableStateFlow<String?>(null)
|
||||||
|
override val drift = MutableStateFlow(FirewallDriftState())
|
||||||
|
|
||||||
|
override suspend fun setBlocked(app: InstalledApp, blocked: Boolean) = Unit
|
||||||
|
override suspend fun addBlockedSelection(app: InstalledApp): Boolean = false
|
||||||
|
override suspend fun setEnabled(enabled: Boolean) = Unit
|
||||||
|
override suspend fun applyScheduledProfile(
|
||||||
|
profileId: String,
|
||||||
|
enableFirewall: Boolean?,
|
||||||
|
): Boolean = true
|
||||||
|
override suspend fun restore() = Unit
|
||||||
|
override suspend fun checkForDrift() = Unit
|
||||||
|
override suspend fun refreshAvailability() = Unit
|
||||||
|
override fun requestPermission() = Unit
|
||||||
|
override suspend fun removeBlocked(key: AppKey) = Unit
|
||||||
|
override suspend fun removeBlockedForUser(userId: Int) = Unit
|
||||||
|
override suspend fun switchProfile(id: String) = Unit
|
||||||
|
override suspend fun createProfile(name: String): Boolean = false
|
||||||
|
override suspend fun duplicateProfile(sourceId: String, name: String): Boolean = false
|
||||||
|
override suspend fun renameProfile(id: String, name: String): Boolean = false
|
||||||
|
override suspend fun deleteProfile(id: String): Boolean = false
|
||||||
|
override suspend fun removeBlockedEverywhere(key: AppKey) = Unit
|
||||||
|
override suspend fun removeBlockedForUserEverywhere(userId: Int) = Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
alias(libs.plugins.kotlin.serialization) apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8
|
||||||
|
org.gradle.caching=true
|
||||||
|
org.gradle.parallel=true
|
||||||
|
android.useAndroidX=true
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
|
kotlin.code.style=official
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
[versions]
|
||||||
|
agp = "8.13.2"
|
||||||
|
kotlin = "2.2.10"
|
||||||
|
coreKtx = "1.17.0"
|
||||||
|
lifecycle = "2.9.4"
|
||||||
|
activityCompose = "1.11.0"
|
||||||
|
composeBom = "2025.09.01"
|
||||||
|
coroutines = "1.10.2"
|
||||||
|
datastore = "1.3.0"
|
||||||
|
junit = "4.13.2"
|
||||||
|
shizuku = "13.1.5"
|
||||||
|
biometric = "1.1.0"
|
||||||
|
fragment = "1.8.9"
|
||||||
|
serialization = "1.9.0"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
||||||
|
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
|
||||||
|
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
||||||
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
|
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
|
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
|
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||||
|
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||||
|
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
|
androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" }
|
||||||
|
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||||
|
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||||
|
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
|
||||||
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
|
shizuku-api = { group = "dev.rikka.shizuku", name = "api", version.ref = "shizuku" }
|
||||||
|
shizuku-provider = { group = "dev.rikka.shizuku", name = "provider", version.ref = "shizuku" }
|
||||||
|
androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" }
|
||||||
|
androidx-fragment = { group = "androidx.fragment", name = "fragment", version.ref = "fragment" }
|
||||||
|
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user