diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..d385540d9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,48 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +Geto is a multi-module Android app written in Kotlin and Jetpack Compose. `app/` contains the +application, activities, navigation, and manifest. Keep UI features in `feature/` (`apps`, +`app-settings`, `home`, `settings`), reusable Compose pieces in `design-system/` and `ui/`, and +background protection code in `service/`. + +The clean architecture layers are `domain/` (models, repository interfaces, use cases), `data/` +(DataStore, Room, repository implementations), and `framework/` (Android API adapters). Put Room +entities, DAOs, migrations, and schemas in `data/room/`; protobuf definitions are in +`data/datastore-proto/src/main/proto/`. Assets such as setting templates belong under the owning +module's `src/main/assets/`. + +## Build, Test, and Development Commands + +Run commands from the repository root. Ensure `ANDROID_HOME` points to an installed Android SDK. + +- `./gradlew :app:assembleDebug` builds the debug APK. +- `./gradlew testDebugUnitTest` runs Android module unit tests; use a module task such as + `./gradlew :domain:use-case:test` for focused work. +- `./gradlew :data:room:connectedDebugAndroidTest` runs Room migration tests on a connected device + or emulator. +- `./gradlew lintDebug` runs Android lint. +- `./gradlew spotlessApply --init-script gradle/init.gradle.kts` formats Kotlin, Gradle Kotlin, + and XML; use `spotlessCheck` in review/CI checks. + +## Coding Style & Naming Conventions + +Use four-space indentation and Kotlin idioms. Spotless/Ktlint is authoritative; run it before +committing. Follow existing package names under `com.android.geto`. Name Compose screens +`*Screen`, ViewModels `*ViewModel`, UI state classes `*UiState`, Hilt modules `*Module`, and use +cases as verb-led `*UseCase`. Preserve the GPL header used by nearby Kotlin and Gradle files. + +## Testing Guidelines + +Place unit tests in `src/test/kotlin` and device tests in `src/androidTest/kotlin`. Use descriptive +test names that state the behavior, e.g. `migrate9To10_preservesProfilesAndAddsEmptyProtectionTables`. +Add tests for use-case transactions, ViewModel state, and every Room migration; update the schema +JSON when changing Room entities. + +## Commit & Pull Request Guidelines + +Use concise Conventional Commit-style subjects seen in history: `feat:`, `fix:`, `refactor:`, or +`chore:`. Keep commits focused. PRs should explain behavior and risk, link the issue when present, +list validation commands, and include screenshots or recordings for visible Compose UI changes. +Never commit SDK paths, local properties, signing keys, or device-specific data. diff --git a/README.md b/README.md index 5a2e16038..edd2f18e4 100644 --- a/README.md +++ b/README.md @@ -18,12 +18,20 @@ Apply device settings to your apps About The Project ================== -The only reason I created this app is to turn off that damn Developer Options when using a banking -app. The only annoying thing about it is you have to go to the Settings app. When you turn off that -switch button, your Developer Options configurations will be reset to default. The good thing is -that when you modify your settings through its Shared Preferences, you won't lose all your settings -once the Developer Options is modified. So basically, you have to grant this app -with `android.permission.WRITE_SECURE_SETTINGS` in order for it to modify your Settings values. +Geto applies a saved Android-settings profile before opening an app—for example, temporarily hiding +Developer Options from a banking app. Geto needs `android.permission.WRITE_SECURE_SETTINGS` to read +and update those values. Grant it from **Settings → Permission**, either with Shizuku on the device +itself or by copying the ADB command and running it from a connected computer. + +Use **Launch once** for temporary changes. Geto snapshots the real original values, applies the +profile as one recoverable transaction, and keeps a Restore notification until the originals are +verified. Use **Keep profile active** when the target must also work from its original launcher icon. +In that mode a lightweight foreground service watches only the selected setting keys and repairs +drift without polling or holding a wake lock. + +Settings includes an optional **Restart protection automatically** control for unexpected service +stops, reboots, and app updates. It is off by default to avoid background work unless the user opts +in. Geto reports interrupted or incomplete recovery through its protection notifications. > [!IMPORTANT] > Watch the tutorial on [YouTube](https://youtu.be/CJrJyHpVVRM?si=ACrEC0hcPed53RAj) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 931b4efc9..4bf8c9ebf 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -70,14 +70,16 @@ dependencies { implementation(projects.framework.assetManager) implementation(projects.framework.drawable) + implementation(projects.framework.foregroundApp) implementation(projects.framework.launcherApps) implementation(projects.framework.notificationManager) implementation(projects.framework.packageManager) implementation(projects.framework.secureSettings) + implementation(projects.framework.shizuku) implementation(projects.framework.shortcutManager) + implementation(projects.service) implementation(projects.ui) - implementation(libs.accompanist.permissions) implementation(libs.androidx.activity.ktx) implementation(libs.androidx.activity.compose) implementation(libs.androidx.core.ktx) diff --git a/app/src/main/kotlin/com/android/geto/GetoApplication.kt b/app/src/main/kotlin/com/android/geto/GetoApplication.kt index e59a442d2..63f4d09d1 100644 --- a/app/src/main/kotlin/com/android/geto/GetoApplication.kt +++ b/app/src/main/kotlin/com/android/geto/GetoApplication.kt @@ -21,6 +21,8 @@ import android.app.Application import android.app.NotificationManager import android.os.Build import com.android.geto.framework.notificationmanager.AndroidNotificationManagerWrapper +import com.android.geto.service.ForegroundProtectionCoordinator +import com.android.geto.service.ProtectionServiceManager import dagger.hilt.android.HiltAndroidApp import javax.inject.Inject @@ -29,6 +31,12 @@ class GetoApplication : Application() { @Inject lateinit var notificationManagerWrapper: AndroidNotificationManagerWrapper + @Inject + lateinit var protectionServiceManager: ProtectionServiceManager + + @Inject + lateinit var foregroundProtectionCoordinator: ForegroundProtectionCoordinator + override fun onCreate() { super.onCreate() @@ -38,6 +46,29 @@ class GetoApplication : Application() { name = getString(R.string.app_name), importance = NotificationManager.IMPORTANCE_DEFAULT, ) + + notificationManagerWrapper.createNotificationChannel( + channelId = AndroidNotificationManagerWrapper.PROTECTION_NOTIFICATION_CHANNEL_ID, + name = getString(com.android.geto.framework.notificationmanager.R.string.protection_channel_name), + importance = NotificationManager.IMPORTANCE_LOW, + description = getString( + com.android.geto.framework.notificationmanager.R.string.protection_channel_description, + ), + ) + + notificationManagerWrapper.createNotificationChannel( + channelId = AndroidNotificationManagerWrapper.PROTECTION_ALERT_CHANNEL_ID, + name = getString(com.android.geto.framework.notificationmanager.R.string.protection_alert_channel_name), + importance = NotificationManager.IMPORTANCE_DEFAULT, + description = getString( + com.android.geto.framework.notificationmanager.R.string.protection_alert_channel_description, + ), + ) } + + protectionServiceManager.initialize() + // Restores anything left applied by a previous process before it starts watching, so a + // profile can never outlive the run that applied it. + foregroundProtectionCoordinator.initialize() } } diff --git a/app/src/main/kotlin/com/android/geto/activity/main/MainActivity.kt b/app/src/main/kotlin/com/android/geto/activity/main/MainActivity.kt index 8bb4357ae..da9538c01 100644 --- a/app/src/main/kotlin/com/android/geto/activity/main/MainActivity.kt +++ b/app/src/main/kotlin/com/android/geto/activity/main/MainActivity.kt @@ -22,16 +22,28 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.rememberNavController +import com.android.geto.R import com.android.geto.designsystem.theme.GetoTheme +import com.android.geto.domain.model.Theme import com.android.geto.framework.launcherapps.AndroidLauncherAppsWrapper import com.android.geto.framework.notificationmanager.AndroidNotificationManagerWrapper import com.android.geto.navigation.GetoNavHost +import com.android.geto.service.ProtectionServiceManager import com.android.geto.ui.local.LocalLauncherApps import com.android.geto.ui.local.LocalNotificationManager import dagger.hilt.android.AndroidEntryPoint @@ -45,15 +57,22 @@ class MainActivity : ComponentActivity() { @Inject lateinit var androidNotificationManagerWrapper: AndroidNotificationManagerWrapper + @Inject + lateinit var protectionServiceManager: ProtectionServiceManager + private val viewModel: MainActivityViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { - installSplashScreen() + val splashScreen = installSplashScreen() enableEdgeToEdge() super.onCreate(savedInstanceState) + splashScreen.setKeepOnScreenCondition { + viewModel.uiState.value is MainActivityUiState.Loading + } + setContent { CompositionLocalProvider( LocalLauncherApps provides androidLauncherAppsWrapper, @@ -64,7 +83,29 @@ class MainActivity : ComponentActivity() { val mainActivityUiState by viewModel.uiState.collectAsStateWithLifecycle() when (val uiState = mainActivityUiState) { - MainActivityUiState.Loading -> Unit + MainActivityUiState.Loading -> { + LoadingContent() + } + + is MainActivityUiState.Error -> { + GetoTheme( + theme = Theme.FOLLOW_SYSTEM, + dynamicTheme = false, + ) { + Surface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = stringResource(R.string.preferences_load_failed)) + Button(onClick = viewModel::retry) { + Text(text = stringResource(R.string.retry)) + } + } + } + } + } is MainActivityUiState.Success -> { GetoTheme( @@ -80,4 +121,27 @@ class MainActivity : ComponentActivity() { } } } + + override fun onStart() { + super.onStart() + protectionServiceManager.reconcileFromVisibleApp() + } +} + +@androidx.compose.runtime.Composable +private fun LoadingContent() { + GetoTheme( + theme = Theme.FOLLOW_SYSTEM, + dynamicTheme = false, + ) { + Surface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CircularProgressIndicator() + } + } + } } diff --git a/app/src/main/kotlin/com/android/geto/activity/main/MainActivityUiState.kt b/app/src/main/kotlin/com/android/geto/activity/main/MainActivityUiState.kt index ac1f15863..90b20dbbd 100644 --- a/app/src/main/kotlin/com/android/geto/activity/main/MainActivityUiState.kt +++ b/app/src/main/kotlin/com/android/geto/activity/main/MainActivityUiState.kt @@ -23,4 +23,6 @@ sealed interface MainActivityUiState { data object Loading : MainActivityUiState data class Success(val userData: UserData) : MainActivityUiState + + data class Error(val message: String?) : MainActivityUiState } diff --git a/app/src/main/kotlin/com/android/geto/activity/main/MainActivityViewModel.kt b/app/src/main/kotlin/com/android/geto/activity/main/MainActivityViewModel.kt index 3e4f224a3..65f85498c 100644 --- a/app/src/main/kotlin/com/android/geto/activity/main/MainActivityViewModel.kt +++ b/app/src/main/kotlin/com/android/geto/activity/main/MainActivityViewModel.kt @@ -19,20 +19,39 @@ package com.android.geto.activity.main import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.android.geto.domain.model.UserData import com.android.geto.domain.repository.UserDataRepository import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import javax.inject.Inject @HiltViewModel +@OptIn(ExperimentalCoroutinesApi::class) class MainActivityViewModel @Inject constructor( - userDataRepository: UserDataRepository, + private val userDataRepository: UserDataRepository, ) : ViewModel() { - val uiState = userDataRepository.userData.map(MainActivityUiState::Success).stateIn( + private val retryTrigger = MutableStateFlow(0) + + val uiState = retryTrigger.flatMapLatest { + userDataRepository.userData + .map(MainActivityUiState::Success) + .onStart { emit(MainActivityUiState.Loading) } + .catch { throwable -> emit(MainActivityUiState.Error(throwable.message)) } + }.stateIn( scope = viewModelScope, initialValue = MainActivityUiState.Loading, started = SharingStarted.WhileSubscribed(5_000), ) + + fun retry() { + retryTrigger.update(Int::inc) + } } diff --git a/app/src/main/kotlin/com/android/geto/activity/shortcut/ShortcutActivity.kt b/app/src/main/kotlin/com/android/geto/activity/shortcut/ShortcutActivity.kt index b1e36b892..c10ba3287 100644 --- a/app/src/main/kotlin/com/android/geto/activity/shortcut/ShortcutActivity.kt +++ b/app/src/main/kotlin/com/android/geto/activity/shortcut/ShortcutActivity.kt @@ -17,27 +17,39 @@ */ package com.android.geto.activity.shortcut +import android.app.AlertDialog import android.app.Notification import android.app.PendingIntent import android.app.PendingIntent.FLAG_IMMUTABLE import android.app.PendingIntent.FLAG_UPDATE_CURRENT import android.content.Intent import android.graphics.drawable.Icon +import android.net.Uri +import android.os.Build import android.os.Bundle +import android.provider.Settings import androidx.activity.ComponentActivity import androidx.activity.viewModels import androidx.core.app.NotificationCompat +import androidx.core.net.toUri import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle +import com.android.geto.R import com.android.geto.broadcastreceiver.RevertSettingsBroadcastReceiver import com.android.geto.domain.framework.ShortcutManagerCompatWrapper -import com.android.geto.domain.model.AppSettingsResult +import com.android.geto.domain.model.ProtectionMode +import com.android.geto.domain.model.ProtectionResult +import com.android.geto.domain.model.ProtectionState import com.android.geto.framework.launcherapps.AndroidLauncherAppsWrapper +import com.android.geto.framework.launcherapps.LaunchResult import com.android.geto.framework.notificationmanager.AndroidNotificationManagerWrapper import com.android.geto.framework.notificationmanager.AndroidNotificationManagerWrapper.Companion.ACTION_REVERT_SETTINGS import com.android.geto.framework.notificationmanager.AndroidNotificationManagerWrapper.Companion.NOTIFICATION_EXTRA_COMPONENT_NAME import com.android.geto.framework.notificationmanager.AndroidNotificationManagerWrapper.Companion.NOTIFICATION_EXTRA_NOTIFICATION_ID +import com.android.geto.framework.notificationmanager.AndroidNotificationManagerWrapper.Companion.NOTIFICATION_EXTRA_SESSION_TOKEN +import com.android.geto.framework.notificationmanager.AndroidNotificationManagerWrapper.Companion.ONE_SHOT_NOTIFICATION_ID +import com.android.geto.service.ProtectionServiceManager import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import javax.inject.Inject @@ -50,6 +62,9 @@ class ShortcutActivity : ComponentActivity() { @Inject lateinit var androidLauncherAppsWrapper: AndroidLauncherAppsWrapper + @Inject + lateinit var protectionServiceManager: ProtectionServiceManager + private val viewModel: ShortcutActivityViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { @@ -57,110 +72,226 @@ class ShortcutActivity : ComponentActivity() { val componentName = intent.getStringExtra(ShortcutManagerCompatWrapper.SHORTCUT_EXTRA_COMPONENT_NAME) - ?: return + ?: run { + finish() + return + } + + val oneShotChannelId = AndroidNotificationManagerWrapper.NOTIFICATION_CHANNEL_ID + val notificationsEnabled = androidNotificationManagerWrapper.areNotificationsEnabled() + val oneShotChannelEnabled = + androidNotificationManagerWrapper.isNotificationChannelEnabled(oneShotChannelId) + if (!notificationsEnabled || !oneShotChannelEnabled) { + val openChannelSettings = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + notificationsEnabled && + !oneShotChannelEnabled + AlertDialog.Builder(this) + .setTitle(R.string.shortcut_notifications_required_title) + .setMessage(R.string.shortcut_notifications_required_message) + .setPositiveButton(R.string.open_notification_settings) { _, _ -> + val notificationSettingsIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Intent( + if (openChannelSettings) { + Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS + } else { + Settings.ACTION_APP_NOTIFICATION_SETTINGS + }, + ).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, packageName) + if (openChannelSettings) { + putExtra(Settings.EXTRA_CHANNEL_ID, oneShotChannelId) + } + } + } else { + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", packageName, null), + ) + } + startActivity(notificationSettingsIntent) + finish() + } + .setNegativeButton(android.R.string.cancel) { _, _ -> finish() } + .setOnCancelListener { finish() } + .show() + return + } - val notificationId = componentName.hashCode() + val notificationId = ONE_SHOT_NOTIFICATION_ID viewModel.applyAppSettings(componentName = componentName) lifecycleScope.launch { lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.shortcutActivityUiState.collect { shortcutActivityUiState -> - if (shortcutActivityUiState is ShortcutActivityUiState.Success) { - when (shortcutActivityUiState.appSettingsResult) { - AppSettingsResult.Success -> { - androidNotificationManagerWrapper.notify( - id = notificationId, - notification = getNotification( - notificationId = notificationId, - componentName = componentName, - icon = shortcutActivityUiState.applicationIcon, - contentTitle = getString(com.android.geto.feature.appsettings.R.string.geto_settings), - contentText = getString(com.android.geto.feature.appsettings.R.string.apply_success), - ), - ) - - androidLauncherAppsWrapper.startMainActivity(componentName = componentName) - - finish() - } + when (shortcutActivityUiState) { + ShortcutActivityUiState.Loading -> Unit - AppSettingsResult.Failure -> { - androidNotificationManagerWrapper.notify( - id = notificationId, - notification = getNotification( - notificationId = notificationId, - componentName = componentName, - icon = shortcutActivityUiState.applicationIcon, - contentTitle = getString(com.android.geto.feature.appsettings.R.string.geto_settings), - contentText = getString(com.android.geto.feature.appsettings.R.string.apply_failure), - ), - ) - - finish() - } + ShortcutActivityUiState.Error -> { + androidNotificationManagerWrapper.notify( + id = LAUNCH_FAILURE_NOTIFICATION_ID, + notification = getNotification( + notificationId = LAUNCH_FAILURE_NOTIFICATION_ID, + componentName = componentName, + icon = null, + contentTitle = getString(com.android.geto.feature.appsettings.R.string.geto_settings), + contentText = getString(R.string.shortcut_load_failed), + showRestoreAction = false, + ), + ) + finish() + } - AppSettingsResult.NoPermission -> { - androidNotificationManagerWrapper.notify( - id = notificationId, - notification = getNotification( - notificationId = notificationId, - componentName = componentName, - icon = shortcutActivityUiState.applicationIcon, - contentTitle = getString(com.android.geto.feature.appsettings.R.string.geto_settings), - contentText = getString(com.android.geto.feature.appsettings.R.string.no_permission), - ), - ) - - finish() - } + is ShortcutActivityUiState.Success -> { + when (val result = shortcutActivityUiState.protectionResult) { + is ProtectionResult.Success -> { + val activeApp = result.app + val oneShotToken = activeApp + ?.takeIf { it.mode == ProtectionMode.ONE_SHOT } + ?.sessionToken - AppSettingsResult.InvalidValues -> { - androidNotificationManagerWrapper.notify( - id = notificationId, - notification = getNotification( - notificationId = notificationId, - componentName = componentName, - icon = shortcutActivityUiState.applicationIcon, - contentTitle = getString(com.android.geto.feature.appsettings.R.string.geto_settings), - contentText = getString(com.android.geto.feature.appsettings.R.string.settings_has_invalid_values), - ), - ) - - finish() - } + if (activeApp?.mode == ProtectionMode.FOREGROUND) { + protectionServiceManager.onProtectionEnabled() + } - AppSettingsResult.EmptyAppSettings -> { - androidNotificationManagerWrapper.notify( - id = notificationId, - notification = getNotification( - notificationId = notificationId, - componentName = componentName, - icon = shortcutActivityUiState.applicationIcon, - contentTitle = getString(com.android.geto.feature.appsettings.R.string.geto_settings), - contentText = getString(com.android.geto.feature.appsettings.R.string.empty_app_settings_list), - ), - ) - - finish() - } + if (oneShotToken != null) { + androidNotificationManagerWrapper.notify( + id = notificationId, + notification = getNotification( + notificationId = notificationId, + sessionToken = oneShotToken, + componentName = componentName, + icon = shortcutActivityUiState.applicationIcon, + contentTitle = getString(com.android.geto.feature.appsettings.R.string.geto_settings), + contentText = getString(com.android.geto.feature.appsettings.R.string.apply_success), + ), + ) + } - AppSettingsResult.DisabledAppSettings -> { - androidNotificationManagerWrapper.notify( - id = notificationId, - notification = getNotification( - notificationId = notificationId, - componentName = componentName, - icon = shortcutActivityUiState.applicationIcon, - contentTitle = getString(com.android.geto.feature.appsettings.R.string.geto_settings), - contentText = getString(com.android.geto.feature.appsettings.R.string.app_settings_disabled), - ), - ) - - finish() - } + if ( + androidLauncherAppsWrapper.startMainActivity( + componentName = componentName, + ) !is LaunchResult.Success + ) { + val restoreResult = oneShotToken?.let { token -> + viewModel.restore(token) + } + if (restoreResult is ProtectionResult.Success) { + androidNotificationManagerWrapper.cancel(notificationId) + } else if ( + oneShotToken != null && + restoreResult !is ProtectionResult.StaleSession + ) { + androidNotificationManagerWrapper.notify( + id = notificationId, + notification = getNotification( + notificationId = notificationId, + sessionToken = oneShotToken, + componentName = componentName, + icon = shortcutActivityUiState.applicationIcon, + contentTitle = getString(com.android.geto.feature.appsettings.R.string.geto_settings), + contentText = getString( + com.android.geto.feature.appsettings.R.string.launch_failed_restore_failed, + ), + ), + ) + } + androidNotificationManagerWrapper.notify( + id = LAUNCH_FAILURE_NOTIFICATION_ID, + notification = getNotification( + notificationId = LAUNCH_FAILURE_NOTIFICATION_ID, + componentName = componentName, + icon = shortcutActivityUiState.applicationIcon, + contentTitle = getString(com.android.geto.feature.appsettings.R.string.geto_settings), + contentText = getString(com.android.geto.feature.appsettings.R.string.launch_failed), + showRestoreAction = false, + ), + ) + } + + finish() + } - null -> Unit + is ProtectionResult.PermissionDenied -> { + androidNotificationManagerWrapper.notify( + id = notificationId, + notification = getNotification( + notificationId = notificationId, + componentName = componentName, + icon = shortcutActivityUiState.applicationIcon, + contentTitle = getString(com.android.geto.feature.appsettings.R.string.geto_settings), + contentText = getString(com.android.geto.feature.appsettings.R.string.no_permission), + showRestoreAction = false, + ), + ) + + finish() + } + + ProtectionResult.EmptyProfile -> { + androidNotificationManagerWrapper.notify( + id = notificationId, + notification = getNotification( + notificationId = notificationId, + componentName = componentName, + icon = shortcutActivityUiState.applicationIcon, + contentTitle = getString(com.android.geto.feature.appsettings.R.string.geto_settings), + contentText = getString(com.android.geto.feature.appsettings.R.string.empty_app_settings_list), + showRestoreAction = false, + ), + ) + + finish() + } + + ProtectionResult.NoEnabledSettings -> { + androidNotificationManagerWrapper.notify( + id = notificationId, + notification = getNotification( + notificationId = notificationId, + componentName = componentName, + icon = shortcutActivityUiState.applicationIcon, + contentTitle = getString(com.android.geto.feature.appsettings.R.string.geto_settings), + contentText = getString(com.android.geto.feature.appsettings.R.string.app_settings_disabled), + showRestoreAction = false, + ), + ) + + finish() + } + + is ProtectionResult.KeyConflict -> { + androidNotificationManagerWrapper.notify( + id = notificationId, + notification = getNotification( + notificationId = notificationId, + componentName = componentName, + icon = shortcutActivityUiState.applicationIcon, + contentTitle = getString(com.android.geto.feature.appsettings.R.string.geto_settings), + contentText = getString(com.android.geto.feature.appsettings.R.string.another_profile_active), + showRestoreAction = false, + ), + ) + + finish() + } + + else -> { + androidNotificationManagerWrapper.notify( + id = notificationId, + notification = getNotification( + notificationId = notificationId, + componentName = componentName, + icon = shortcutActivityUiState.applicationIcon, + contentTitle = getString(com.android.geto.feature.appsettings.R.string.geto_settings), + contentText = getString(com.android.geto.feature.appsettings.R.string.apply_failure), + showRestoreAction = false, + ), + ) + + finish() + } + } } } } @@ -170,23 +301,35 @@ class ShortcutActivity : ComponentActivity() { private fun getNotification( notificationId: Int, + sessionToken: String? = null, componentName: String, icon: ByteArray?, contentTitle: String, contentText: String, + showRestoreAction: Boolean = true, ): Notification { val revertIntent = Intent(this, RevertSettingsBroadcastReceiver::class.java).apply { action = ACTION_REVERT_SETTINGS + data = "geto://restore/${Uri.encode(sessionToken.orEmpty())}".toUri() putExtra(NOTIFICATION_EXTRA_COMPONENT_NAME, componentName) putExtra(NOTIFICATION_EXTRA_NOTIFICATION_ID, notificationId) + putExtra(NOTIFICATION_EXTRA_SESSION_TOKEN, sessionToken) } val revertPendingIntent = PendingIntent.getBroadcast( this, - 0, + sessionToken?.hashCode() ?: notificationId, revertIntent, FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE, ) + val contentPendingIntent = packageManager.getLaunchIntentForPackage(packageName)?.let { + PendingIntent.getActivity( + this, + notificationId, + it, + FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE, + ) + } return NotificationCompat.Builder( this, @@ -201,11 +344,22 @@ class ShortcutActivity : ComponentActivity() { setContentTitle(contentTitle) setContentText(contentText) setPriority(NotificationCompat.PRIORITY_DEFAULT) - addAction( - com.android.geto.framework.notificationmanager.R.drawable.baseline_settings_24, - getString(com.android.geto.framework.notificationmanager.R.string.revert), - revertPendingIntent, - ) + setCategory(NotificationCompat.CATEGORY_STATUS) + setOnlyAlertOnce(true) + setOngoing(showRestoreAction) + setAutoCancel(!showRestoreAction) + contentPendingIntent?.let(::setContentIntent) + if (showRestoreAction && sessionToken != null) { + addAction( + com.android.geto.framework.notificationmanager.R.drawable.baseline_settings_24, + getString(com.android.geto.framework.notificationmanager.R.string.revert), + revertPendingIntent, + ) + } }.build() } + + private companion object { + const val LAUNCH_FAILURE_NOTIFICATION_ID = 10_002 + } } diff --git a/app/src/main/kotlin/com/android/geto/activity/shortcut/ShortcutActivityUiState.kt b/app/src/main/kotlin/com/android/geto/activity/shortcut/ShortcutActivityUiState.kt index cdca6251d..ba8afd714 100644 --- a/app/src/main/kotlin/com/android/geto/activity/shortcut/ShortcutActivityUiState.kt +++ b/app/src/main/kotlin/com/android/geto/activity/shortcut/ShortcutActivityUiState.kt @@ -17,13 +17,15 @@ */ package com.android.geto.activity.shortcut -import com.android.geto.domain.model.AppSettingsResult +import com.android.geto.domain.model.ProtectionResult sealed interface ShortcutActivityUiState { data object Loading : ShortcutActivityUiState + data object Error : ShortcutActivityUiState + data class Success( - val appSettingsResult: AppSettingsResult?, + val protectionResult: ProtectionResult, val applicationIcon: ByteArray?, ) : ShortcutActivityUiState { override fun equals(other: Any?): Boolean { @@ -32,14 +34,14 @@ sealed interface ShortcutActivityUiState { other as Success - if (appSettingsResult != other.appSettingsResult) return false + if (protectionResult != other.protectionResult) return false if (!applicationIcon.contentEquals(other.applicationIcon)) return false return true } override fun hashCode(): Int { - var result = appSettingsResult?.hashCode() ?: 0 + var result = protectionResult.hashCode() result = 31 * result + (applicationIcon?.contentHashCode() ?: 0) return result } diff --git a/app/src/main/kotlin/com/android/geto/activity/shortcut/ShortcutActivityViewModel.kt b/app/src/main/kotlin/com/android/geto/activity/shortcut/ShortcutActivityViewModel.kt index cbd9ff37c..ad1210cc2 100644 --- a/app/src/main/kotlin/com/android/geto/activity/shortcut/ShortcutActivityViewModel.kt +++ b/app/src/main/kotlin/com/android/geto/activity/shortcut/ShortcutActivityViewModel.kt @@ -20,17 +20,18 @@ package com.android.geto.activity.shortcut import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.android.geto.domain.framework.PackageManagerWrapper -import com.android.geto.domain.usecase.ApplyAppSettingsUseCase +import com.android.geto.domain.model.ProtectionMode +import com.android.geto.domain.usecase.ProtectionController import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class ShortcutActivityViewModel @Inject constructor( - private val applyAppSettingsUseCase: ApplyAppSettingsUseCase, + private val protectionController: ProtectionController, private val packageManagerWrapper: PackageManagerWrapper, ) : ViewModel() { private val _shortcutActivityUiState = @@ -39,12 +40,22 @@ class ShortcutActivityViewModel @Inject constructor( fun applyAppSettings(componentName: String) { viewModelScope.launch { - _shortcutActivityUiState.update { + _shortcutActivityUiState.value = ShortcutActivityUiState.Loading + _shortcutActivityUiState.value = try { ShortcutActivityUiState.Success( - appSettingsResult = applyAppSettingsUseCase(componentName = componentName), + protectionResult = protectionController.enable( + componentName = componentName, + mode = ProtectionMode.ONE_SHOT, + ), applicationIcon = packageManagerWrapper.getActivityIcon(componentName = componentName), ) + } catch (exception: CancellationException) { + throw exception + } catch (_: RuntimeException) { + ShortcutActivityUiState.Error } } } + + suspend fun restore(sessionToken: String) = protectionController.restore(sessionToken) } diff --git a/app/src/main/kotlin/com/android/geto/navigation/GetoNavHost.kt b/app/src/main/kotlin/com/android/geto/navigation/GetoNavHost.kt index 546f3d205..60de57374 100644 --- a/app/src/main/kotlin/com/android/geto/navigation/GetoNavHost.kt +++ b/app/src/main/kotlin/com/android/geto/navigation/GetoNavHost.kt @@ -17,17 +17,11 @@ */ package com.android.geto.navigation -import android.os.Build -import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.SnackbarResult import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember -import androidx.compose.ui.res.stringResource import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost -import com.android.geto.R import com.android.geto.feature.apps.navigation.AppsRouteData import com.android.geto.feature.apps.navigation.appsScreen import com.android.geto.feature.apps.navigation.navigateToApps @@ -39,16 +33,11 @@ import com.android.geto.feature.settings.navigation.navigateToSettings import com.android.geto.feature.settings.navigation.settingsScreen import com.android.geto.navigation.TopLevelDestination.APPS import com.android.geto.navigation.TopLevelDestination.SETTINGS -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.PermissionStatus -import com.google.accompanist.permissions.rememberPermissionState @Composable fun GetoNavHost(navController: NavHostController) { val snackbarHostState = remember { SnackbarHostState() } - PostNotificationsPermission(snackbarHostState = snackbarHostState) - NavHost( navController = navController, startDestination = HomeRouteData::class, @@ -66,41 +55,10 @@ fun GetoNavHost(navController: NavHostController) { builder = { appsScreen(onClickApp = navController::navigateToAppSettings) - settingsScreen() + settingsScreen(snackbarHostState = snackbarHostState) }, ) appSettingsScreen(onNavigationIconClick = navController::navigateUp) } } - -@Composable -@OptIn(ExperimentalPermissionsApi::class) -private fun PostNotificationsPermission(snackbarHostState: SnackbarHostState) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return - - val permissionState = rememberPermissionState(android.Manifest.permission.POST_NOTIFICATIONS) - - val message = stringResource(R.string.please_grant_notifications_permission) - val actionLabel = stringResource(R.string.allow) - - LaunchedEffect(key1 = permissionState.status) { - val status = permissionState.status - - if (status is PermissionStatus.Denied) { - permissionState.launchPermissionRequest() - - if (status.shouldShowRationale) { - val result = snackbarHostState.showSnackbar( - message = message, - actionLabel = actionLabel, - duration = SnackbarDuration.Indefinite, - ) - - if (result == SnackbarResult.ActionPerformed) { - permissionState.launchPermissionRequest() - } - } - } - } -} diff --git a/app/src/main/res/drawable/ic_splash.xml b/app/src/main/res/drawable/ic_splash.xml index c857cb996..7766a0d73 100644 --- a/app/src/main/res/drawable/ic_splash.xml +++ b/app/src/main/res/drawable/ic_splash.xml @@ -16,10 +16,12 @@ ~ --> + android:viewportHeight="288" + tools:ignore="VectorRaster"> Geto Apps Settings - Please grant notifications permission so you can access the revert notification - Allow - \ No newline at end of file + Geto could not load its preferences. + Retry + Notifications required + Geto needs its settings notification enabled before this shortcut can safely apply temporary changes. Enable notifications, then run the shortcut again. + Open notification settings + The shortcut could not load this app profile. Open Geto and try again. + diff --git a/broadcast-receiver/src/main/kotlin/com/android/geto/broadcastreceiver/RevertSettingsBroadcastReceiver.kt b/broadcast-receiver/src/main/kotlin/com/android/geto/broadcastreceiver/RevertSettingsBroadcastReceiver.kt index 558e0fb61..f692b5dc4 100644 --- a/broadcast-receiver/src/main/kotlin/com/android/geto/broadcastreceiver/RevertSettingsBroadcastReceiver.kt +++ b/broadcast-receiver/src/main/kotlin/com/android/geto/broadcastreceiver/RevertSettingsBroadcastReceiver.kt @@ -21,10 +21,14 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import com.android.geto.common.ApplicationScope +import com.android.geto.domain.model.AppSettingsResult +import com.android.geto.domain.model.ProtectionResult +import com.android.geto.domain.usecase.ProtectionController import com.android.geto.domain.usecase.RevertAppSettingsUseCase import com.android.geto.framework.notificationmanager.AndroidNotificationManagerWrapper import com.android.geto.framework.notificationmanager.AndroidNotificationManagerWrapper.Companion.NOTIFICATION_EXTRA_COMPONENT_NAME import com.android.geto.framework.notificationmanager.AndroidNotificationManagerWrapper.Companion.NOTIFICATION_EXTRA_NOTIFICATION_ID +import com.android.geto.framework.notificationmanager.AndroidNotificationManagerWrapper.Companion.NOTIFICATION_EXTRA_SESSION_TOKEN import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -37,6 +41,9 @@ class RevertSettingsBroadcastReceiver @Inject constructor() : BroadcastReceiver( @ApplicationScope lateinit var appScope: CoroutineScope + @Inject + lateinit var protectionController: ProtectionController + @Inject lateinit var revertAppSettingsUseCase: RevertAppSettingsUseCase @@ -44,14 +51,28 @@ class RevertSettingsBroadcastReceiver @Inject constructor() : BroadcastReceiver( lateinit var notificationManagerWrapper: AndroidNotificationManagerWrapper override fun onReceive(context: Context?, intent: Intent?) { - val componentName = intent?.extras?.getString(NOTIFICATION_EXTRA_COMPONENT_NAME) ?: return - - val notificationId = intent.extras?.getInt(NOTIFICATION_EXTRA_NOTIFICATION_ID) ?: return + val notificationId = intent?.extras?.getInt(NOTIFICATION_EXTRA_NOTIFICATION_ID) ?: return + val sessionToken = intent.extras?.getString(NOTIFICATION_EXTRA_SESSION_TOKEN) + val componentName = intent.extras?.getString(NOTIFICATION_EXTRA_COMPONENT_NAME) + if (sessionToken == null && componentName == null) return + val pendingResult = goAsync() appScope.launch { - revertAppSettingsUseCase(componentName = componentName) - - notificationManagerWrapper.cancel(notificationId) + try { + val restored = if (sessionToken != null) { + protectionController.restore(sessionToken) is ProtectionResult.Success + } else { + revertAppSettingsUseCase(requireNotNull(componentName)) == + AppSettingsResult.Success + } + // Leaving the notification up is the only feedback a receiver can give. The session + // is moved to RECOVERY_REQUIRED either way, which Settings surfaces with a reason. + if (restored) { + notificationManagerWrapper.cancel(notificationId) + } + } finally { + pendingResult.finish() + } } } } diff --git a/changes.txt b/changes.txt new file mode 100644 index 000000000..1163f8eb3 --- /dev/null +++ b/changes.txt @@ -0,0 +1,32 @@ +Geto changes +============ + +Performance +- App metadata loads before icons; icons are fetched lazily and cached as list rows appear. +- Added a process-wide app-list cache, bulk update-time lookup, incremental package updates, + debounced search, retry support, and fully-drawn startup reporting. + +Protection reliability +- Reworked setting application into a persisted transaction: snapshot original values, apply and + verify every value, then roll back in reverse order if anything fails. +- Added session tokens so old notifications or delayed callbacks cannot restore a newer profile. +- Added persistent “Keep profile active” protection for apps opened from their original icon. +- Added recovery states and an actionable WRITE_SECURE_SETTINGS permission path. + +Background and battery +- Replaced the old observer service with a special-use foreground service. +- It observes only protected setting keys, coalesces changes for 500 ms, avoids polling, and holds + no wake lock. +- Added optional automatic restart with bounded exponential backoff, boot/update recovery, service + status, Resume controls, and notifications for interruptions or recovery failures. + +UI and accessibility +- Improved notification-permission flows, including rotation-safe continuation after settings. +- Improved shortcut error handling and launch-failure restoration. +- Added clearer profile-switch confirmation, responsive action layouts, accessible switch semantics, + descriptive delete/template actions, and improved dialog sizing. + +Data and validation +- Added Room database migration 9 to 10 for persisted protection sessions and snapshots. +- Added unit tests for protection transactions and app-list state, plus on-device Room migration + tests. Formatting, Android lint, unit tests, migration tests, and debug APK assembly passed. diff --git a/data/datastore-proto/src/main/proto/com/android/geto/data/datastore/proto/grant_method.proto b/data/datastore-proto/src/main/proto/com/android/geto/data/datastore/proto/grant_method.proto new file mode 100644 index 000000000..d69da6d3e --- /dev/null +++ b/data/datastore-proto/src/main/proto/com/android/geto/data/datastore/proto/grant_method.proto @@ -0,0 +1,28 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +syntax = "proto3"; + +option java_package = "com.android.geto.data.datastore.proto"; +option java_multiple_files = true; + +enum GrantMethodProto { + GRANT_METHOD_UNSPECIFIED = 0; + GRANT_METHOD_ADB = 1; + GRANT_METHOD_SHIZUKU = 2; +} diff --git a/data/datastore-proto/src/main/proto/com/android/geto/data/datastore/proto/user_preferences.proto b/data/datastore-proto/src/main/proto/com/android/geto/data/datastore/proto/user_preferences.proto index 15199cb84..08decceeb 100644 --- a/data/datastore-proto/src/main/proto/com/android/geto/data/datastore/proto/user_preferences.proto +++ b/data/datastore-proto/src/main/proto/com/android/geto/data/datastore/proto/user_preferences.proto @@ -18,6 +18,7 @@ syntax = "proto3"; +import "com/android/geto/data/datastore/proto/grant_method.proto"; import "com/android/geto/data/datastore/proto/theme.proto"; import "com/android/geto/data/datastore/proto/sort_launcher_apps_activity_info.proto"; import "com/android/geto/data/datastore/proto/sort_order_launcher_apps_activity_info.proto"; @@ -31,5 +32,8 @@ message UserPreferences { SortLauncherAppsActivityInfoProto sortLauncherAppsActivityInfo = 3; SortOrderLauncherAppsActivityInfoProto sortOrderLauncherAppsActivityInfo = 4; bool showSystem = 5; + bool autoRestartProtection = 6; + GrantMethodProto grantMethod = 7; + // 0 = not paused, Long.MAX_VALUE = paused with no deadline, otherwise an epoch-millis deadline. + int64 protectionPausedUntilMillis = 8; } - diff --git a/data/datastore/src/main/kotlin/com/android/geto/data/datastore/UserPreferencesDataSource.kt b/data/datastore/src/main/kotlin/com/android/geto/data/datastore/UserPreferencesDataSource.kt index 6a61b8c02..c1bdab111 100644 --- a/data/datastore/src/main/kotlin/com/android/geto/data/datastore/UserPreferencesDataSource.kt +++ b/data/datastore/src/main/kotlin/com/android/geto/data/datastore/UserPreferencesDataSource.kt @@ -18,6 +18,8 @@ package com.android.geto.data.datastore import androidx.datastore.core.DataStore +import com.android.geto.data.datastore.mapper.asGrantMethod +import com.android.geto.data.datastore.mapper.asGrantMethodProto import com.android.geto.data.datastore.mapper.asSortLauncherAppsActivityInfo import com.android.geto.data.datastore.mapper.asSortLauncherAppsActivityInfoProto import com.android.geto.data.datastore.mapper.asSortOrderLauncherAppsActivityInfo @@ -26,6 +28,7 @@ import com.android.geto.data.datastore.mapper.asTheme import com.android.geto.data.datastore.mapper.asThemeProto import com.android.geto.data.datastore.proto.UserPreferences import com.android.geto.data.datastore.proto.copy +import com.android.geto.domain.model.GrantMethod import com.android.geto.domain.model.SortLauncherAppsActivityInfo import com.android.geto.domain.model.SortOrderLauncherAppsActivityInfo import com.android.geto.domain.model.Theme @@ -33,7 +36,14 @@ import com.android.geto.domain.model.UserData import kotlinx.coroutines.flow.map import javax.inject.Inject -class UserPreferencesDataSource @Inject constructor(private val userPreferences: DataStore) { +class UserPreferencesDataSource @Inject constructor( + private val userPreferences: DataStore, + recoveryTracker: UserPreferencesRecoveryTracker, +) { + val preferencesWereReset = recoveryTracker.preferencesWereReset + + private val preferencesRecoveryTracker = recoveryTracker + val userData = userPreferences.data.map { UserData( theme = it.theme.asTheme(), @@ -41,6 +51,9 @@ class UserPreferencesDataSource @Inject constructor(private val userPreferences: sortLauncherAppsActivityInfo = it.sortLauncherAppsActivityInfo.asSortLauncherAppsActivityInfo(), sortOrderLauncherAppsActivityInfo = it.sortOrderLauncherAppsActivityInfo.asSortOrderLauncherAppsActivityInfo(), showSystem = it.showSystem, + autoRestartProtection = it.autoRestartProtection, + grantMethod = it.grantMethod.asGrantMethod(), + protectionPausedUntilMillis = it.protectionPausedUntilMillis, ) } @@ -83,4 +96,36 @@ class UserPreferencesDataSource @Inject constructor(private val userPreferences: } } } + + suspend fun updateAutoRestartProtection(autoRestartProtection: Boolean) { + userPreferences.updateData { + it.copy { + this.autoRestartProtection = autoRestartProtection + } + } + } + + suspend fun updateGrantMethod(grantMethod: GrantMethod) { + userPreferences.updateData { + it.copy { + this.grantMethod = grantMethod.asGrantMethodProto() + } + } + } + + suspend fun updateProtectionPausedUntil(protectionPausedUntilMillis: Long) { + userPreferences.updateData { + it.copy { + this.protectionPausedUntilMillis = protectionPausedUntilMillis + } + } + } + + suspend fun resetUserPreferences() { + userPreferences.updateData { UserPreferences.getDefaultInstance() } + } + + fun acknowledgePreferencesReset() { + preferencesRecoveryTracker.acknowledgeRecovery() + } } diff --git a/data/datastore/src/main/kotlin/com/android/geto/data/datastore/UserPreferencesRecoveryTracker.kt b/data/datastore/src/main/kotlin/com/android/geto/data/datastore/UserPreferencesRecoveryTracker.kt new file mode 100644 index 000000000..9e5a62cc8 --- /dev/null +++ b/data/datastore/src/main/kotlin/com/android/geto/data/datastore/UserPreferencesRecoveryTracker.kt @@ -0,0 +1,38 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.data.datastore + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class UserPreferencesRecoveryTracker @Inject constructor() { + private val _preferencesWereReset = MutableStateFlow(false) + + val preferencesWereReset = _preferencesWereReset.asStateFlow() + + internal fun recordRecovery() { + _preferencesWereReset.value = true + } + + fun acknowledgeRecovery() { + _preferencesWereReset.value = false + } +} diff --git a/data/datastore/src/main/kotlin/com/android/geto/data/datastore/di/DataStoreModule.kt b/data/datastore/src/main/kotlin/com/android/geto/data/datastore/di/DataStoreModule.kt index c21d9bdaa..dc56da27e 100644 --- a/data/datastore/src/main/kotlin/com/android/geto/data/datastore/di/DataStoreModule.kt +++ b/data/datastore/src/main/kotlin/com/android/geto/data/datastore/di/DataStoreModule.kt @@ -20,8 +20,10 @@ package com.android.geto.data.datastore.di import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.core.DataStoreFactory +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import androidx.datastore.dataStoreFile import com.android.geto.common.ApplicationScope +import com.android.geto.data.datastore.UserPreferencesRecoveryTracker import com.android.geto.data.datastore.UserPreferencesSerializer import com.android.geto.data.datastore.proto.UserPreferences import com.android.geto.domain.common.dispatcher.Dispatcher @@ -46,9 +48,14 @@ object DataStoreModule { @Dispatcher(IO) ioDispatcher: CoroutineDispatcher, @ApplicationScope scope: CoroutineScope, userPreferencesSerializer: UserPreferencesSerializer, + recoveryTracker: UserPreferencesRecoveryTracker, ): DataStore = DataStoreFactory.create( serializer = userPreferencesSerializer, scope = CoroutineScope(scope.coroutineContext + ioDispatcher), + corruptionHandler = ReplaceFileCorruptionHandler { + recoveryTracker.recordRecovery() + userPreferencesSerializer.defaultValue + }, ) { context.dataStoreFile("user_preferences.pb") } diff --git a/data/datastore/src/main/kotlin/com/android/geto/data/datastore/mapper/DataStoreMapper.kt b/data/datastore/src/main/kotlin/com/android/geto/data/datastore/mapper/DataStoreMapper.kt index 0f5e8769c..2bea778fe 100644 --- a/data/datastore/src/main/kotlin/com/android/geto/data/datastore/mapper/DataStoreMapper.kt +++ b/data/datastore/src/main/kotlin/com/android/geto/data/datastore/mapper/DataStoreMapper.kt @@ -17,13 +17,38 @@ */ package com.android.geto.data.datastore.mapper +import com.android.geto.data.datastore.proto.GrantMethodProto import com.android.geto.data.datastore.proto.SortLauncherAppsActivityInfoProto import com.android.geto.data.datastore.proto.SortOrderLauncherAppsActivityInfoProto import com.android.geto.data.datastore.proto.ThemeProto +import com.android.geto.domain.model.GrantMethod import com.android.geto.domain.model.SortLauncherAppsActivityInfo import com.android.geto.domain.model.SortOrderLauncherAppsActivityInfo import com.android.geto.domain.model.Theme +internal fun GrantMethodProto.asGrantMethod(): GrantMethod = when (this) { + GrantMethodProto.GRANT_METHOD_UNSPECIFIED, + GrantMethodProto.GRANT_METHOD_ADB, + GrantMethodProto.UNRECOGNIZED, + -> { + GrantMethod.ADB + } + + GrantMethodProto.GRANT_METHOD_SHIZUKU -> { + GrantMethod.SHIZUKU + } +} + +internal fun GrantMethod.asGrantMethodProto(): GrantMethodProto = when (this) { + GrantMethod.ADB -> { + GrantMethodProto.GRANT_METHOD_ADB + } + + GrantMethod.SHIZUKU -> { + GrantMethodProto.GRANT_METHOD_SHIZUKU + } +} + internal fun ThemeProto.asTheme(): Theme = when (this) { ThemeProto.THEME_UNSPECIFIED, ThemeProto.THEME_FOLLOW_SYSTEM, diff --git a/data/repository/src/main/kotlin/com/android/geto/data/repository/DefaultAppSettingsRepository.kt b/data/repository/src/main/kotlin/com/android/geto/data/repository/DefaultAppSettingsRepository.kt index cdf84eeeb..294cc83e5 100644 --- a/data/repository/src/main/kotlin/com/android/geto/data/repository/DefaultAppSettingsRepository.kt +++ b/data/repository/src/main/kotlin/com/android/geto/data/repository/DefaultAppSettingsRepository.kt @@ -38,6 +38,10 @@ class DefaultAppSettingsRepository @Inject constructor(private val appSettingsDa appSettingsDao.upsertAppSettingEntity(entity = appSetting.asEntity()) } + override suspend fun upsertAppSettings(appSettings: List) { + appSettingsDao.upsertAppSettingEntities(appSettings.map { appSetting -> appSetting.asEntity() }) + } + override suspend fun deleteAppSetting(appSetting: AppSetting) { appSettingsDao.deleteAppSettingEntity(entity = appSetting.asEntity()) } diff --git a/data/repository/src/main/kotlin/com/android/geto/data/repository/DefaultProtectionRepository.kt b/data/repository/src/main/kotlin/com/android/geto/data/repository/DefaultProtectionRepository.kt new file mode 100644 index 000000000..5bed7384f --- /dev/null +++ b/data/repository/src/main/kotlin/com/android/geto/data/repository/DefaultProtectionRepository.kt @@ -0,0 +1,173 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.data.repository + +import com.android.geto.data.room.dao.ProtectionDao +import com.android.geto.data.room.model.ArmedProfileEntity +import com.android.geto.data.room.model.ProtectedKeyEntity +import com.android.geto.data.room.model.ProtectedKeyWithClaims +import com.android.geto.data.room.model.ProtectionSessionEntity +import com.android.geto.data.room.model.ProtectionSessionWithValues +import com.android.geto.data.room.model.ProtectionValueEntity +import com.android.geto.domain.model.ProtectedKey +import com.android.geto.domain.model.ProtectedSetting +import com.android.geto.domain.model.ProtectionSession +import com.android.geto.domain.model.ProtectionSessionStatus +import com.android.geto.domain.model.SettingType +import com.android.geto.domain.repository.ProtectionRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class DefaultProtectionRepository @Inject constructor( + private val protectionDao: ProtectionDao, +) : ProtectionRepository { + + override val armedComponentNamesFlow: Flow> = + protectionDao.getArmedComponentNamesFlow().map(List::toSet) + + override suspend fun getArmedComponentNames(): Set = protectionDao.getArmedComponentNames().toSet() + + override suspend fun armProfile(componentName: String, armedAtMillis: Long) { + protectionDao.armProfile( + ArmedProfileEntity(componentName = componentName, armedAtMillis = armedAtMillis), + ) + } + + override suspend fun disarmProfile(componentName: String): Boolean = protectionDao.disarmProfile(componentName) == 1 + + override val sessionsFlow: Flow> = protectionDao.getSessionsFlow().map { relations -> + relations.map(ProtectionSessionWithValues::asExternalModel) + } + + override suspend fun getSessions(): List = protectionDao.getSessions().map(ProtectionSessionWithValues::asExternalModel) + + override suspend fun getSessionByToken(token: String): ProtectionSession? = protectionDao.getSessionByToken(token)?.asExternalModel() + + override suspend fun getSessionByComponentName(componentName: String): ProtectionSession? = protectionDao.getSessionByComponentName(componentName)?.asExternalModel() + + override suspend fun getProtectedKeys(): List = protectionDao.getProtectedKeys().map(ProtectedKeyWithClaims::asExternalModel) + + override suspend fun getProtectedKey( + settingType: SettingType, + key: String, + ): ProtectedKey? = protectionDao.getProtectedKey(settingType = settingType, key = key)?.asExternalModel() + + override suspend fun getClaimantComponentNames( + settingType: SettingType, + key: String, + ): List = protectionDao.getClaimantComponentNames(settingType = settingType, key = key) + + override suspend fun getKeysReleasedBy(token: String): List = protectionDao.getKeysReleasedBy(token).map(ProtectedKeyEntity::asExternalModel) + + override suspend fun getOrphanKeys(): List = protectionDao.getOrphanKeys().map(ProtectedKeyEntity::asExternalModel) + + override suspend fun deleteOrphanKeys(): Int = protectionDao.deleteOrphanKeys() + + override suspend fun createSession(session: ProtectionSession, newKeys: List) { + val sessionEntity = ProtectionSessionEntity( + token = session.token, + componentName = session.componentName, + mode = session.mode, + status = session.status, + createdAtMillis = session.createdAtMillis, + updatedAtMillis = session.updatedAtMillis, + lastError = session.lastError, + pausedUntilMillis = session.pausedUntilMillis, + ) + + val keyEntities = newKeys.map { key -> + ProtectedKeyEntity( + settingType = key.settingType, + key = key.key, + originalValue = key.originalValue, + enforcedValue = key.enforcedValue, + claimedAtMillis = key.claimedAtMillis, + ) + } + + val valueEntities = session.protectedSettings.map { setting -> + ProtectionValueEntity( + sessionToken = session.token, + settingType = setting.settingType, + key = setting.key, + protectedValue = setting.protectedValue, + writeOrder = setting.writeOrder, + ) + } + + protectionDao.createSession( + session = sessionEntity, + newKeys = keyEntities, + values = valueEntities, + ) + } + + override suspend fun updateStatus( + token: String, + status: ProtectionSessionStatus, + updatedAtMillis: Long, + lastError: String?, + ): Boolean = protectionDao.updateStatus( + token = token, + status = status, + updatedAtMillis = updatedAtMillis, + lastError = lastError, + ) == 1 + + override suspend fun updatePausedUntil( + token: String, + pausedUntilMillis: Long, + updatedAtMillis: Long, + ): Boolean = protectionDao.updatePausedUntil( + token = token, + pausedUntilMillis = pausedUntilMillis, + updatedAtMillis = updatedAtMillis, + ) == 1 + + override suspend fun clearSession(token: String): Boolean = protectionDao.releaseSession(token) == 1 +} + +private fun ProtectionSessionWithValues.asExternalModel(): ProtectionSession = ProtectionSession( + token = session.token, + componentName = session.componentName, + mode = session.mode, + status = session.status, + protectedSettings = values.sortedBy { it.writeOrder }.map { value -> + ProtectedSetting( + settingType = value.settingType, + key = value.key, + protectedValue = value.protectedValue, + writeOrder = value.writeOrder, + ) + }, + createdAtMillis = session.createdAtMillis, + updatedAtMillis = session.updatedAtMillis, + pausedUntilMillis = session.pausedUntilMillis, + lastError = session.lastError, +) + +private fun ProtectedKeyEntity.asExternalModel(): ProtectedKey = ProtectedKey( + settingType = settingType, + key = key, + originalValue = originalValue, + enforcedValue = enforcedValue, + claimedAtMillis = claimedAtMillis, +) + +private fun ProtectedKeyWithClaims.asExternalModel(): ProtectedKey = key.asExternalModel().copy(claimCount = claimCount) diff --git a/data/repository/src/main/kotlin/com/android/geto/data/repository/DefaultUserDataRepository.kt b/data/repository/src/main/kotlin/com/android/geto/data/repository/DefaultUserDataRepository.kt index 8b34f94ca..5df58997e 100644 --- a/data/repository/src/main/kotlin/com/android/geto/data/repository/DefaultUserDataRepository.kt +++ b/data/repository/src/main/kotlin/com/android/geto/data/repository/DefaultUserDataRepository.kt @@ -18,6 +18,7 @@ package com.android.geto.data.repository import com.android.geto.data.datastore.UserPreferencesDataSource +import com.android.geto.domain.model.GrantMethod import com.android.geto.domain.model.SortLauncherAppsActivityInfo import com.android.geto.domain.model.SortOrderLauncherAppsActivityInfo import com.android.geto.domain.model.Theme @@ -31,6 +32,9 @@ class DefaultUserDataRepository @Inject constructor( ) : UserDataRepository { override val userData: Flow = userPreferencesDataSource.userData + override val preferencesWereReset: Flow = + userPreferencesDataSource.preferencesWereReset + override suspend fun updateTheme(theme: Theme) { userPreferencesDataSource.updateTheme(theme = theme) } @@ -52,4 +56,28 @@ class DefaultUserDataRepository @Inject constructor( override suspend fun updateShowSystem(showSystem: Boolean) { userPreferencesDataSource.updateShowSystem(showSystem = showSystem) } + + override suspend fun updateAutoRestartProtection(autoRestartProtection: Boolean) { + userPreferencesDataSource.updateAutoRestartProtection( + autoRestartProtection = autoRestartProtection, + ) + } + + override suspend fun updateGrantMethod(grantMethod: GrantMethod) { + userPreferencesDataSource.updateGrantMethod(grantMethod = grantMethod) + } + + override suspend fun updateProtectionPausedUntil(protectionPausedUntilMillis: Long) { + userPreferencesDataSource.updateProtectionPausedUntil( + protectionPausedUntilMillis = protectionPausedUntilMillis, + ) + } + + override suspend fun resetUserPreferences() { + userPreferencesDataSource.resetUserPreferences() + } + + override fun acknowledgePreferencesReset() { + userPreferencesDataSource.acknowledgePreferencesReset() + } } diff --git a/data/repository/src/main/kotlin/com/android/geto/data/repository/di/RepositoryModule.kt b/data/repository/src/main/kotlin/com/android/geto/data/repository/di/RepositoryModule.kt index 23010fb84..87dbae279 100644 --- a/data/repository/src/main/kotlin/com/android/geto/data/repository/di/RepositoryModule.kt +++ b/data/repository/src/main/kotlin/com/android/geto/data/repository/di/RepositoryModule.kt @@ -18,8 +18,10 @@ package com.android.geto.data.repository.di import com.android.geto.data.repository.DefaultAppSettingsRepository +import com.android.geto.data.repository.DefaultProtectionRepository import com.android.geto.data.repository.DefaultUserDataRepository import com.android.geto.domain.repository.AppSettingsRepository +import com.android.geto.domain.repository.ProtectionRepository import com.android.geto.domain.repository.UserDataRepository import dagger.Binds import dagger.Module @@ -35,6 +37,10 @@ interface RepositoryModule { @Singleton fun appSettingsRepository(impl: DefaultAppSettingsRepository): AppSettingsRepository + @Binds + @Singleton + fun protectionRepository(impl: DefaultProtectionRepository): ProtectionRepository + @Binds @Singleton fun userDataRepository(impl: DefaultUserDataRepository): UserDataRepository diff --git a/data/room/schemas/com.android.geto.data.room.AppDatabase/10.json b/data/room/schemas/com.android.geto.data.room.AppDatabase/10.json new file mode 100644 index 000000000..28bc5f009 --- /dev/null +++ b/data/room/schemas/com.android.geto.data.room.AppDatabase/10.json @@ -0,0 +1,205 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "52ff3c8086699b6144ede7242dacdc5e", + "entities": [ + { + "tableName": "AppSettingEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `enabled` INTEGER NOT NULL, `settingType` TEXT NOT NULL, `componentName` TEXT NOT NULL, `label` TEXT NOT NULL, `key` TEXT NOT NULL, `valueOnLaunch` TEXT NOT NULL, `valueOnRevert` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "settingType", + "columnName": "settingType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "componentName", + "columnName": "componentName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "valueOnLaunch", + "columnName": "valueOnLaunch", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "valueOnRevert", + "columnName": "valueOnRevert", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "ProtectionSessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `token` TEXT NOT NULL, `componentName` TEXT NOT NULL, `mode` TEXT NOT NULL, `status` TEXT NOT NULL, `createdAtMillis` INTEGER NOT NULL, `updatedAtMillis` INTEGER NOT NULL, `lastError` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "componentName", + "columnName": "componentName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAtMillis", + "columnName": "createdAtMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAtMillis", + "columnName": "updatedAtMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "ProtectionValueEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` INTEGER NOT NULL, `settingType` TEXT NOT NULL, `key` TEXT NOT NULL, `protectedValue` TEXT NOT NULL, `originalValue` TEXT, `writeOrder` INTEGER NOT NULL, PRIMARY KEY(`sessionId`, `settingType`, `key`), FOREIGN KEY(`sessionId`) REFERENCES `ProtectionSessionEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "settingType", + "columnName": "settingType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "protectedValue", + "columnName": "protectedValue", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalValue", + "columnName": "originalValue", + "affinity": "TEXT" + }, + { + "fieldPath": "writeOrder", + "columnName": "writeOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId", + "settingType", + "key" + ] + }, + "indices": [ + { + "name": "index_ProtectionValueEntity_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ProtectionValueEntity_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "ProtectionSessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '52ff3c8086699b6144ede7242dacdc5e')" + ] + } +} \ No newline at end of file diff --git a/data/room/schemas/com.android.geto.data.room.AppDatabase/11.json b/data/room/schemas/com.android.geto.data.room.AppDatabase/11.json new file mode 100644 index 000000000..173fb764c --- /dev/null +++ b/data/room/schemas/com.android.geto.data.room.AppDatabase/11.json @@ -0,0 +1,300 @@ +{ + "formatVersion": 1, + "database": { + "version": 11, + "identityHash": "9a86db749d63091eb594ad194e6e7e65", + "entities": [ + { + "tableName": "AppSettingEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `enabled` INTEGER NOT NULL, `settingType` TEXT NOT NULL, `componentName` TEXT NOT NULL, `label` TEXT NOT NULL, `key` TEXT NOT NULL, `valueOnLaunch` TEXT NOT NULL, `valueOnRevert` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "settingType", + "columnName": "settingType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "componentName", + "columnName": "componentName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "valueOnLaunch", + "columnName": "valueOnLaunch", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "valueOnRevert", + "columnName": "valueOnRevert", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "ArmedProfileEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`componentName` TEXT NOT NULL, `armedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`componentName`))", + "fields": [ + { + "fieldPath": "componentName", + "columnName": "componentName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "armedAtMillis", + "columnName": "armedAtMillis", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "componentName" + ] + } + }, + { + "tableName": "ProtectedKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`settingType` TEXT NOT NULL, `key` TEXT NOT NULL, `originalValue` TEXT, `enforcedValue` TEXT NOT NULL, `claimedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`settingType`, `key`))", + "fields": [ + { + "fieldPath": "settingType", + "columnName": "settingType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalValue", + "columnName": "originalValue", + "affinity": "TEXT" + }, + { + "fieldPath": "enforcedValue", + "columnName": "enforcedValue", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "claimedAtMillis", + "columnName": "claimedAtMillis", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "settingType", + "key" + ] + } + }, + { + "tableName": "ProtectionSessionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`token` TEXT NOT NULL, `componentName` TEXT NOT NULL, `mode` TEXT NOT NULL, `status` TEXT NOT NULL, `createdAtMillis` INTEGER NOT NULL, `updatedAtMillis` INTEGER NOT NULL, `lastError` TEXT, `pausedUntilMillis` INTEGER NOT NULL, PRIMARY KEY(`token`))", + "fields": [ + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "componentName", + "columnName": "componentName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAtMillis", + "columnName": "createdAtMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAtMillis", + "columnName": "updatedAtMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT" + }, + { + "fieldPath": "pausedUntilMillis", + "columnName": "pausedUntilMillis", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "token" + ] + }, + "indices": [ + { + "name": "index_ProtectionSessionEntity_componentName", + "unique": true, + "columnNames": [ + "componentName" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ProtectionSessionEntity_componentName` ON `${TABLE_NAME}` (`componentName`)" + } + ] + }, + { + "tableName": "ProtectionValueEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionToken` TEXT NOT NULL, `settingType` TEXT NOT NULL, `key` TEXT NOT NULL, `protectedValue` TEXT NOT NULL, `writeOrder` INTEGER NOT NULL, PRIMARY KEY(`sessionToken`, `settingType`, `key`), FOREIGN KEY(`sessionToken`) REFERENCES `ProtectionSessionEntity`(`token`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`settingType`, `key`) REFERENCES `ProtectedKeyEntity`(`settingType`, `key`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "sessionToken", + "columnName": "sessionToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "settingType", + "columnName": "settingType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "protectedValue", + "columnName": "protectedValue", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "writeOrder", + "columnName": "writeOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionToken", + "settingType", + "key" + ] + }, + "indices": [ + { + "name": "index_ProtectionValueEntity_sessionToken", + "unique": false, + "columnNames": [ + "sessionToken" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ProtectionValueEntity_sessionToken` ON `${TABLE_NAME}` (`sessionToken`)" + }, + { + "name": "index_ProtectionValueEntity_settingType_key", + "unique": false, + "columnNames": [ + "settingType", + "key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ProtectionValueEntity_settingType_key` ON `${TABLE_NAME}` (`settingType`, `key`)" + } + ], + "foreignKeys": [ + { + "table": "ProtectionSessionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionToken" + ], + "referencedColumns": [ + "token" + ] + }, + { + "table": "ProtectedKeyEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "settingType", + "key" + ], + "referencedColumns": [ + "settingType", + "key" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9a86db749d63091eb594ad194e6e7e65')" + ] + } +} \ No newline at end of file diff --git a/data/room/src/androidTest/kotlin/com/android/geto/data/room/Migration10To11Test.kt b/data/room/src/androidTest/kotlin/com/android/geto/data/room/Migration10To11Test.kt new file mode 100644 index 000000000..9180befe2 --- /dev/null +++ b/data/room/src/androidTest/kotlin/com/android/geto/data/room/Migration10To11Test.kt @@ -0,0 +1,166 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.data.room + +import androidx.room.testing.MigrationTestHelper +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.android.geto.data.room.migration.Migration10To11 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class Migration10To11Test { + private val testDatabase = "migration-10-11-test" + + @get:Rule + val helper: MigrationTestHelper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + AppDatabase::class.java, + ) + + /** + * The case that cannot be fixed in a later release: a user upgrading while an app is protected. + * Their pre-Geto values live only in the old `originalValue` column, so the ledger has to inherit + * them exactly. + */ + @Test + fun migrate10To11_movesSingletonSessionAndSeedsKeyLedgerWithOriginals() { + helper.createDatabase(testDatabase, 10).use { db -> + db.execSQL( + """ + INSERT INTO ProtectionSessionEntity ( + id, token, componentName, mode, status, + createdAtMillis, updatedAtMillis, lastError + ) VALUES ( + 1, 'token-abc', 'com.example/.MainActivity', 'PERSISTENT', 'ACTIVE', + 1000, 2000, NULL + ) + """.trimIndent(), + ) + db.execSQL( + """ + INSERT INTO ProtectionValueEntity ( + sessionId, settingType, key, protectedValue, originalValue, writeOrder + ) VALUES + (1, 'GLOBAL', 'development_settings_enabled', '0', '1', 0), + (1, 'SECURE', 'some_null_original', 'on', NULL, 1) + """.trimIndent(), + ) + } + + helper.runMigrationsAndValidate(testDatabase, 11, true, Migration10To11()).use { db -> + db.query( + "SELECT * FROM ProtectionSessionEntity WHERE token = 'token-abc'", + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals( + "com.example/.MainActivity", + cursor.getString(cursor.getColumnIndexOrThrow("componentName")), + ) + assertEquals(1000, cursor.getLong(cursor.getColumnIndexOrThrow("createdAtMillis"))) + // The removed PERSISTENT constant must be rewritten, or Room cannot read the row and + // the user's original values become unreachable. + assertEquals("FOREGROUND", cursor.getString(cursor.getColumnIndexOrThrow("mode"))) + // Existing sessions come across as not paused. + assertEquals(0, cursor.getLong(cursor.getColumnIndexOrThrow("pausedUntilMillis"))) + } + + db.query( + "SELECT * FROM ProtectedKeyEntity WHERE key = 'development_settings_enabled'", + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals("1", cursor.getString(cursor.getColumnIndexOrThrow("originalValue"))) + assertEquals("0", cursor.getString(cursor.getColumnIndexOrThrow("enforcedValue"))) + assertEquals("GLOBAL", cursor.getString(cursor.getColumnIndexOrThrow("settingType"))) + assertEquals(1000, cursor.getLong(cursor.getColumnIndexOrThrow("claimedAtMillis"))) + } + + // A null original is a real value meaning "the key was unset", not missing data. + db.query( + "SELECT * FROM ProtectedKeyEntity WHERE key = 'some_null_original'", + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertTrue(cursor.isNull(cursor.getColumnIndexOrThrow("originalValue"))) + assertEquals("on", cursor.getString(cursor.getColumnIndexOrThrow("enforcedValue"))) + } + + db.query("SELECT COUNT(*) FROM ProtectedKeyEntity").use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals(2, cursor.getInt(0)) + } + + // Claims are re-keyed from the old sessionId onto the session token. + db.query( + "SELECT * FROM ProtectionValueEntity WHERE key = 'development_settings_enabled'", + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals( + "token-abc", + cursor.getString(cursor.getColumnIndexOrThrow("sessionToken")), + ) + assertEquals("0", cursor.getString(cursor.getColumnIndexOrThrow("protectedValue"))) + assertEquals(0, cursor.getInt(cursor.getColumnIndexOrThrow("writeOrder"))) + // originalValue moved to the ledger and must be gone from the claim row. + assertEquals(-1, cursor.getColumnIndex("originalValue")) + } + } + } + + @Test + fun migrate10To11_withNoProtection_leavesProfilesIntactAndTablesEmpty() { + helper.createDatabase(testDatabase, 10).use { db -> + db.execSQL( + """ + INSERT INTO AppSettingEntity ( + id, enabled, settingType, componentName, label, key, + valueOnLaunch, valueOnRevert + ) VALUES ( + 1, 1, 'GLOBAL', 'com.example/.MainActivity', 'Developer options', + 'development_settings_enabled', '0', '1' + ) + """.trimIndent(), + ) + } + + helper.runMigrationsAndValidate(testDatabase, 11, true, Migration10To11()).use { db -> + db.query("SELECT * FROM AppSettingEntity WHERE id = 1").use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals( + "com.example/.MainActivity", + cursor.getString(cursor.getColumnIndexOrThrow("componentName")), + ) + assertEquals("1", cursor.getString(cursor.getColumnIndexOrThrow("valueOnRevert"))) + } + + db.query("SELECT * FROM ProtectedKeyEntity").use { cursor -> + assertFalse(cursor.moveToFirst()) + } + db.query("SELECT * FROM ProtectionSessionEntity").use { cursor -> + assertFalse(cursor.moveToFirst()) + } + db.query("SELECT * FROM ProtectionValueEntity").use { cursor -> + assertFalse(cursor.moveToFirst()) + } + } + } +} diff --git a/data/room/src/androidTest/kotlin/com/android/geto/data/room/Migration9To10Test.kt b/data/room/src/androidTest/kotlin/com/android/geto/data/room/Migration9To10Test.kt new file mode 100644 index 000000000..c81830115 --- /dev/null +++ b/data/room/src/androidTest/kotlin/com/android/geto/data/room/Migration9To10Test.kt @@ -0,0 +1,79 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.data.room + +import androidx.room.testing.MigrationTestHelper +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.android.geto.data.room.migration.Migration9To10 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class Migration9To10Test { + private val testDatabase = "migration-9-10-test" + + @get:Rule + val helper: MigrationTestHelper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + AppDatabase::class.java, + ) + + @Test + fun migrate9To10_preservesProfilesAndAddsEmptyProtectionTables() { + helper.createDatabase(testDatabase, 9).use { db -> + db.execSQL( + """ + INSERT INTO AppSettingEntity ( + id, enabled, settingType, componentName, label, key, + valueOnLaunch, valueOnRevert + ) VALUES ( + 1, 1, 'GLOBAL', 'com.example/.MainActivity', 'Developer options', + 'development_settings_enabled', '0', '1' + ) + """.trimIndent(), + ) + } + + helper.runMigrationsAndValidate( + testDatabase, + 10, + true, + Migration9To10(), + ).use { db -> + db.query("SELECT * FROM AppSettingEntity WHERE id = 1").use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals( + "com.example/.MainActivity", + cursor.getString(cursor.getColumnIndexOrThrow("componentName")), + ) + } + + db.query("SELECT * FROM ProtectionSessionEntity").use { cursor -> + assertFalse(cursor.moveToFirst()) + } + db.query("SELECT * FROM ProtectionValueEntity").use { cursor -> + assertFalse(cursor.moveToFirst()) + } + } + } +} diff --git a/data/room/src/main/kotlin/com/android/geto/data/room/AppDatabase.kt b/data/room/src/main/kotlin/com/android/geto/data/room/AppDatabase.kt index c3ce911fc..d17c89748 100644 --- a/data/room/src/main/kotlin/com/android/geto/data/room/AppDatabase.kt +++ b/data/room/src/main/kotlin/com/android/geto/data/room/AppDatabase.kt @@ -21,16 +21,27 @@ import androidx.room.AutoMigration import androidx.room.Database import androidx.room.RoomDatabase import com.android.geto.data.room.dao.AppSettingsDao +import com.android.geto.data.room.dao.ProtectionDao import com.android.geto.data.room.migration.AutoMigrationSpec1To2 import com.android.geto.data.room.migration.AutoMigrationSpec4To5 import com.android.geto.data.room.migration.AutoMigrationSpec5To6 import com.android.geto.data.room.migration.AutoMigrationSpec6To7 import com.android.geto.data.room.migration.AutoMigrationSpec8To9 import com.android.geto.data.room.model.AppSettingEntity +import com.android.geto.data.room.model.ArmedProfileEntity +import com.android.geto.data.room.model.ProtectedKeyEntity +import com.android.geto.data.room.model.ProtectionSessionEntity +import com.android.geto.data.room.model.ProtectionValueEntity @Database( - entities = [AppSettingEntity::class], - version = 9, + entities = [ + AppSettingEntity::class, + ArmedProfileEntity::class, + ProtectedKeyEntity::class, + ProtectionSessionEntity::class, + ProtectionValueEntity::class, + ], + version = 11, autoMigrations = [ AutoMigration( from = 1, @@ -64,6 +75,8 @@ internal abstract class AppDatabase : RoomDatabase() { abstract fun appSettingsDao(): AppSettingsDao + abstract fun protectionDao(): ProtectionDao + companion object { const val DATABASE_NAME = "Geto.db" } diff --git a/data/room/src/main/kotlin/com/android/geto/data/room/dao/ProtectionDao.kt b/data/room/src/main/kotlin/com/android/geto/data/room/dao/ProtectionDao.kt new file mode 100644 index 000000000..c749f641c --- /dev/null +++ b/data/room/src/main/kotlin/com/android/geto/data/room/dao/ProtectionDao.kt @@ -0,0 +1,192 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.data.room.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import com.android.geto.data.room.model.ArmedProfileEntity +import com.android.geto.data.room.model.ProtectedKeyEntity +import com.android.geto.data.room.model.ProtectedKeyWithClaims +import com.android.geto.data.room.model.ProtectionSessionEntity +import com.android.geto.data.room.model.ProtectionSessionWithValues +import com.android.geto.data.room.model.ProtectionValueEntity +import com.android.geto.domain.model.ProtectionSessionStatus +import com.android.geto.domain.model.SettingType +import kotlinx.coroutines.flow.Flow + +@Dao +interface ProtectionDao { + + @Query("SELECT componentName FROM ArmedProfileEntity") + fun getArmedComponentNamesFlow(): Flow> + + @Query("SELECT componentName FROM ArmedProfileEntity") + suspend fun getArmedComponentNames(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun armProfile(profile: ArmedProfileEntity) + + @Query("DELETE FROM ArmedProfileEntity WHERE componentName = :componentName") + suspend fun disarmProfile(componentName: String): Int + + @Transaction + @Query("SELECT * FROM ProtectionSessionEntity ORDER BY createdAtMillis") + fun getSessionsFlow(): Flow> + + @Transaction + @Query("SELECT * FROM ProtectionSessionEntity ORDER BY createdAtMillis") + suspend fun getSessions(): List + + @Transaction + @Query("SELECT * FROM ProtectionSessionEntity WHERE token = :token") + suspend fun getSessionByToken(token: String): ProtectionSessionWithValues? + + @Transaction + @Query("SELECT * FROM ProtectionSessionEntity WHERE componentName = :componentName") + suspend fun getSessionByComponentName(componentName: String): ProtectionSessionWithValues? + + @Query( + """ + SELECT k.*, ( + SELECT COUNT(*) FROM ProtectionValueEntity v + WHERE v.settingType = k.settingType AND v.key = k.key + ) AS claimCount + FROM ProtectedKeyEntity k + """, + ) + suspend fun getProtectedKeys(): List + + @Query("SELECT * FROM ProtectedKeyEntity WHERE settingType = :settingType AND key = :key") + suspend fun getProtectedKey(settingType: SettingType, key: String): ProtectedKeyEntity? + + /** Which apps claim this key, so a conflict can name them. */ + @Query( + """ + SELECT s.componentName FROM ProtectionSessionEntity s + JOIN ProtectionValueEntity v ON v.sessionToken = s.token + WHERE v.settingType = :settingType AND v.key = :key + """, + ) + suspend fun getClaimantComponentNames(settingType: SettingType, key: String): List + + /** + * Keys whose only claimant is [token] — exactly the ones a turn-off must write back. Keys another + * app still claims are excluded, which is what keeps one app's turn-off from breaking another's. + */ + @Query( + """ + SELECT k.* FROM ProtectedKeyEntity k + WHERE EXISTS ( + SELECT 1 FROM ProtectionValueEntity v + WHERE v.sessionToken = :token AND v.settingType = k.settingType AND v.key = k.key + ) + AND ( + SELECT COUNT(*) FROM ProtectionValueEntity v2 + WHERE v2.settingType = k.settingType AND v2.key = k.key + ) = 1 + """, + ) + suspend fun getKeysReleasedBy(token: String): List + + /** + * Ledger rows nobody claims any more. Only reachable if the process died between writing an + * original back and deleting its rows, so finding one means the settings are already correct and + * the row just needs sweeping. + */ + @Query( + """ + SELECT * FROM ProtectedKeyEntity k WHERE NOT EXISTS ( + SELECT 1 FROM ProtectionValueEntity v + WHERE v.settingType = k.settingType AND v.key = k.key + ) + """, + ) + suspend fun getOrphanKeys(): List + + @Query( + """ + DELETE FROM ProtectedKeyEntity WHERE NOT EXISTS ( + SELECT 1 FROM ProtectionValueEntity v + WHERE v.settingType = ProtectedKeyEntity.settingType AND v.key = ProtectedKeyEntity.key + ) + """, + ) + suspend fun deleteOrphanKeys(): Int + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertKeys(keys: List) + + @Insert + suspend fun insertSession(session: ProtectionSessionEntity) + + @Insert + suspend fun insertValues(values: List) + + @Query("DELETE FROM ProtectionSessionEntity WHERE token = :token") + suspend fun deleteSession(token: String): Int + + @Query( + """ + UPDATE ProtectionSessionEntity + SET status = :status, updatedAtMillis = :updatedAtMillis, lastError = :lastError + WHERE token = :token + """, + ) + suspend fun updateStatus( + token: String, + status: ProtectionSessionStatus, + updatedAtMillis: Long, + lastError: String?, + ): Int + + @Query( + """ + UPDATE ProtectionSessionEntity + SET pausedUntilMillis = :pausedUntilMillis, updatedAtMillis = :updatedAtMillis + WHERE token = :token + """, + ) + suspend fun updatePausedUntil( + token: String, + pausedUntilMillis: Long, + updatedAtMillis: Long, + ): Int + + /** Ledger rows must land before the claims that reference them. */ + @Transaction + suspend fun createSession( + session: ProtectionSessionEntity, + newKeys: List, + values: List, + ) { + insertKeys(newKeys) + insertSession(session) + insertValues(values) + } + + /** Drops the session, its claims (by cascade) and any ledger row left with no claims. */ + @Transaction + suspend fun releaseSession(token: String): Int { + val deleted = deleteSession(token) + deleteOrphanKeys() + return deleted + } +} diff --git a/data/room/src/main/kotlin/com/android/geto/data/room/di/DaoModule.kt b/data/room/src/main/kotlin/com/android/geto/data/room/di/DaoModule.kt index e5b6d698d..3b1ab77fa 100644 --- a/data/room/src/main/kotlin/com/android/geto/data/room/di/DaoModule.kt +++ b/data/room/src/main/kotlin/com/android/geto/data/room/di/DaoModule.kt @@ -19,6 +19,7 @@ package com.android.geto.data.room.di import com.android.geto.data.room.AppDatabase import com.android.geto.data.room.dao.AppSettingsDao +import com.android.geto.data.room.dao.ProtectionDao import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -32,4 +33,8 @@ internal object DaoModule { @Provides @Singleton fun appSettingsDao(appDatabase: AppDatabase): AppSettingsDao = appDatabase.appSettingsDao() + + @Provides + @Singleton + fun protectionDao(appDatabase: AppDatabase): ProtectionDao = appDatabase.protectionDao() } diff --git a/data/room/src/main/kotlin/com/android/geto/data/room/di/RoomModule.kt b/data/room/src/main/kotlin/com/android/geto/data/room/di/RoomModule.kt index f0608ba0d..4a4414245 100644 --- a/data/room/src/main/kotlin/com/android/geto/data/room/di/RoomModule.kt +++ b/data/room/src/main/kotlin/com/android/geto/data/room/di/RoomModule.kt @@ -20,6 +20,7 @@ package com.android.geto.data.room.di import android.content.Context import androidx.room.Room import com.android.geto.data.room.AppDatabase +import com.android.geto.data.room.migration.Migration10To11 import com.android.geto.data.room.migration.Migration1To2 import com.android.geto.data.room.migration.Migration2To3 import com.android.geto.data.room.migration.Migration3To4 @@ -27,6 +28,7 @@ import com.android.geto.data.room.migration.Migration4To5 import com.android.geto.data.room.migration.Migration5To6 import com.android.geto.data.room.migration.Migration6To7 import com.android.geto.data.room.migration.Migration7To8 +import com.android.geto.data.room.migration.Migration9To10 import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -51,5 +53,7 @@ internal object RoomModule { Migration5To6(), Migration6To7(), Migration7To8(), + Migration9To10(), + Migration10To11(), ).build() } diff --git a/data/room/src/main/kotlin/com/android/geto/data/room/migration/Migration10To11.kt b/data/room/src/main/kotlin/com/android/geto/data/room/migration/Migration10To11.kt new file mode 100644 index 000000000..67602af6d --- /dev/null +++ b/data/room/src/main/kotlin/com/android/geto/data/room/migration/Migration10To11.kt @@ -0,0 +1,122 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.data.room.migration + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +/** + * Single-app protection becomes multi-app protection. + * + * Version 10 kept one session on a hardcoded row (`id = 1`) and stored each key's original value on + * the session's own value rows. Version 11 gives every app its own session keyed by token, and moves + * the original values into a shared ledger so several apps can claim the same key. + * + * The ledger is seeded from the old `originalValue` column **before** anything is dropped: that + * column is the only record of what the device looked like before Geto touched it, and for a user who + * upgrades mid-protection it is unrecoverable if lost. + * + * The old `PERSISTENT` mode is rewritten to `FOREGROUND`, because the enum no longer has a constant + * for "applied forever" and Room would fail to read the row. Carrying the row across rather than + * deleting it is deliberate: the session still holds the claims that point at the ledger, so startup + * reconciliation can write the user's original values back. Deleting it here would strip the claims + * and orphan the originals. + */ +internal class Migration10To11 : Migration(10, 11) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS `ArmedProfileEntity` (`componentName` TEXT NOT NULL, " + + "`armedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`componentName`))", + ) + + db.execSQL( + "CREATE TABLE IF NOT EXISTS `ProtectedKeyEntity` (`settingType` TEXT NOT NULL, " + + "`key` TEXT NOT NULL, `originalValue` TEXT, `enforcedValue` TEXT NOT NULL, " + + "`claimedAtMillis` INTEGER NOT NULL, PRIMARY KEY(`settingType`, `key`))", + ) + + // Seed first, drop later. INSERT OR IGNORE guards the theoretical case of two rows for one + // key; the first one wins, which is the same rule the running code applies. + db.execSQL( + """ + INSERT OR IGNORE INTO `ProtectedKeyEntity` + (`settingType`, `key`, `originalValue`, `enforcedValue`, `claimedAtMillis`) + SELECT v.`settingType`, v.`key`, v.`originalValue`, v.`protectedValue`, s.`createdAtMillis` + FROM `ProtectionValueEntity` v + JOIN `ProtectionSessionEntity` s ON v.`sessionId` = s.`id` + """.trimIndent(), + ) + + db.execSQL( + "CREATE TABLE IF NOT EXISTS `ProtectionSessionEntity_new` (`token` TEXT NOT NULL, " + + "`componentName` TEXT NOT NULL, `mode` TEXT NOT NULL, `status` TEXT NOT NULL, " + + "`createdAtMillis` INTEGER NOT NULL, `updatedAtMillis` INTEGER NOT NULL, " + + "`lastError` TEXT, `pausedUntilMillis` INTEGER NOT NULL, PRIMARY KEY(`token`))", + ) + + db.execSQL( + """ + INSERT INTO `ProtectionSessionEntity_new` + (`token`, `componentName`, `mode`, `status`, `createdAtMillis`, `updatedAtMillis`, + `lastError`, `pausedUntilMillis`) + SELECT `token`, `componentName`, + CASE `mode` WHEN 'PERSISTENT' THEN 'FOREGROUND' ELSE `mode` END, + `status`, `createdAtMillis`, `updatedAtMillis`, `lastError`, 0 + FROM `ProtectionSessionEntity` + """.trimIndent(), + ) + + db.execSQL( + "CREATE TABLE IF NOT EXISTS `ProtectionValueEntity_new` (`sessionToken` TEXT NOT NULL, " + + "`settingType` TEXT NOT NULL, `key` TEXT NOT NULL, `protectedValue` TEXT NOT NULL, " + + "`writeOrder` INTEGER NOT NULL, PRIMARY KEY(`sessionToken`, `settingType`, `key`), " + + "FOREIGN KEY(`sessionToken`) REFERENCES `ProtectionSessionEntity`(`token`) " + + "ON UPDATE NO ACTION ON DELETE CASCADE , " + + "FOREIGN KEY(`settingType`, `key`) REFERENCES `ProtectedKeyEntity`(`settingType`, `key`) " + + "ON UPDATE NO ACTION ON DELETE NO ACTION )", + ) + + db.execSQL( + """ + INSERT INTO `ProtectionValueEntity_new` + (`sessionToken`, `settingType`, `key`, `protectedValue`, `writeOrder`) + SELECT s.`token`, v.`settingType`, v.`key`, v.`protectedValue`, v.`writeOrder` + FROM `ProtectionValueEntity` v + JOIN `ProtectionSessionEntity` s ON v.`sessionId` = s.`id` + """.trimIndent(), + ) + + db.execSQL("DROP TABLE `ProtectionValueEntity`") + db.execSQL("DROP TABLE `ProtectionSessionEntity`") + db.execSQL("ALTER TABLE `ProtectionSessionEntity_new` RENAME TO `ProtectionSessionEntity`") + db.execSQL("ALTER TABLE `ProtectionValueEntity_new` RENAME TO `ProtectionValueEntity`") + + db.execSQL( + "CREATE UNIQUE INDEX IF NOT EXISTS `index_ProtectionSessionEntity_componentName` " + + "ON `ProtectionSessionEntity` (`componentName`)", + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_ProtectionValueEntity_sessionToken` " + + "ON `ProtectionValueEntity` (`sessionToken`)", + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_ProtectionValueEntity_settingType_key` " + + "ON `ProtectionValueEntity` (`settingType`, `key`)", + ) + } +} diff --git a/data/room/src/main/kotlin/com/android/geto/data/room/migration/Migration9To10.kt b/data/room/src/main/kotlin/com/android/geto/data/room/migration/Migration9To10.kt new file mode 100644 index 000000000..95a831b82 --- /dev/null +++ b/data/room/src/main/kotlin/com/android/geto/data/room/migration/Migration9To10.kt @@ -0,0 +1,62 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.data.room.migration + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +internal class Migration9To10 : Migration(9, 10) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS ProtectionSessionEntity ( + id INTEGER NOT NULL, + token TEXT NOT NULL, + componentName TEXT NOT NULL, + mode TEXT NOT NULL, + status TEXT NOT NULL, + createdAtMillis INTEGER NOT NULL, + updatedAtMillis INTEGER NOT NULL, + lastError TEXT, + PRIMARY KEY(id) + ) + """.trimIndent(), + ) + + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS ProtectionValueEntity ( + sessionId INTEGER NOT NULL, + settingType TEXT NOT NULL, + key TEXT NOT NULL, + protectedValue TEXT NOT NULL, + originalValue TEXT, + writeOrder INTEGER NOT NULL, + PRIMARY KEY(sessionId, settingType, key), + FOREIGN KEY(sessionId) REFERENCES ProtectionSessionEntity(id) + ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent(), + ) + + db.execSQL( + "CREATE INDEX IF NOT EXISTS index_ProtectionValueEntity_sessionId " + + "ON ProtectionValueEntity (sessionId)", + ) + } +} diff --git a/data/room/src/main/kotlin/com/android/geto/data/room/model/ArmedProfileEntity.kt b/data/room/src/main/kotlin/com/android/geto/data/room/model/ArmedProfileEntity.kt new file mode 100644 index 000000000..b46c84bea --- /dev/null +++ b/data/room/src/main/kotlin/com/android/geto/data/room/model/ArmedProfileEntity.kt @@ -0,0 +1,34 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.data.room.model + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * An app the user wants Geto to act on when it comes to the foreground. + * + * Kept separate from [ProtectionSessionEntity] on purpose: a session means "settings are applied right + * now" and must never survive a restart, while arming is the user's standing intent and must. Storing + * both in one row is what made it possible to leave settings applied forever. + */ +@Entity +data class ArmedProfileEntity( + @PrimaryKey val componentName: String, + val armedAtMillis: Long, +) diff --git a/data/room/src/main/kotlin/com/android/geto/data/room/model/ProtectedKeyEntity.kt b/data/room/src/main/kotlin/com/android/geto/data/room/model/ProtectedKeyEntity.kt new file mode 100644 index 000000000..9479a623a --- /dev/null +++ b/data/room/src/main/kotlin/com/android/geto/data/room/model/ProtectedKeyEntity.kt @@ -0,0 +1,41 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.data.room.model + +import androidx.room.Entity +import com.android.geto.domain.model.SettingType + +/** + * The ledger of setting keys currently under protection — one row per key, no matter how many apps + * claim it. + * + * [originalValue] is written when the key is first claimed and never touched again, which is the + * whole reason this table exists: if the original lived on the per-app claim rows, the second app to + * claim a key would snapshot the already-protected value and the real one would be lost. + * + * [enforcedValue] is the value every claimant agrees on. A claim asking for a different value is a + * conflict and is rejected. Should that ever need to become a stack, this column is its top entry. + */ +@Entity(primaryKeys = ["settingType", "key"]) +data class ProtectedKeyEntity( + val settingType: SettingType, + val key: String, + val originalValue: String?, + val enforcedValue: String, + val claimedAtMillis: Long, +) diff --git a/data/room/src/main/kotlin/com/android/geto/data/room/model/ProtectionSessionEntity.kt b/data/room/src/main/kotlin/com/android/geto/data/room/model/ProtectionSessionEntity.kt new file mode 100644 index 000000000..2cf87c022 --- /dev/null +++ b/data/room/src/main/kotlin/com/android/geto/data/room/model/ProtectionSessionEntity.kt @@ -0,0 +1,41 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.data.room.model + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import com.android.geto.domain.model.ProtectionMode +import com.android.geto.domain.model.ProtectionSessionStatus + +/** + * One protected app. Several rows can coexist; the unique index on [componentName] is what keeps an + * app from being protected twice. + */ +@Entity(indices = [Index(value = ["componentName"], unique = true)]) +data class ProtectionSessionEntity( + @PrimaryKey val token: String, + val componentName: String, + val mode: ProtectionMode, + val status: ProtectionSessionStatus, + val createdAtMillis: Long, + val updatedAtMillis: Long, + val lastError: String?, + /** 0 = running, [Long.MAX_VALUE] = paused with no deadline, otherwise an epoch-millis deadline. */ + val pausedUntilMillis: Long = 0L, +) diff --git a/data/room/src/main/kotlin/com/android/geto/data/room/model/ProtectionSessionWithValues.kt b/data/room/src/main/kotlin/com/android/geto/data/room/model/ProtectionSessionWithValues.kt new file mode 100644 index 000000000..d4beb235f --- /dev/null +++ b/data/room/src/main/kotlin/com/android/geto/data/room/model/ProtectionSessionWithValues.kt @@ -0,0 +1,36 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.data.room.model + +import androidx.room.Embedded +import androidx.room.Relation + +data class ProtectionSessionWithValues( + @Embedded val session: ProtectionSessionEntity, + @Relation( + parentColumn = "token", + entityColumn = "sessionToken", + ) + val values: List, +) + +/** A ledger row plus how many apps currently claim it. */ +data class ProtectedKeyWithClaims( + @Embedded val key: ProtectedKeyEntity, + val claimCount: Int, +) diff --git a/data/room/src/main/kotlin/com/android/geto/data/room/model/ProtectionValueEntity.kt b/data/room/src/main/kotlin/com/android/geto/data/room/model/ProtectionValueEntity.kt new file mode 100644 index 000000000..2aa848534 --- /dev/null +++ b/data/room/src/main/kotlin/com/android/geto/data/room/model/ProtectionValueEntity.kt @@ -0,0 +1,57 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.data.room.model + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import com.android.geto.domain.model.SettingType + +/** + * One app's claim on one key. Counting these rows per key gives the reference count, which is what + * decides whether releasing an app may write the original value back. + * + * The foreign key to [ProtectedKeyEntity] is NO ACTION rather than CASCADE on purpose: deleting a + * ledger row must never silently drop live claims. Ledger rows are removed only once no claim is + * left, by [com.android.geto.data.room.dao.ProtectionDao.deleteOrphanKeys]. + */ +@Entity( + primaryKeys = ["sessionToken", "settingType", "key"], + foreignKeys = [ + ForeignKey( + entity = ProtectionSessionEntity::class, + parentColumns = ["token"], + childColumns = ["sessionToken"], + onDelete = ForeignKey.CASCADE, + ), + ForeignKey( + entity = ProtectedKeyEntity::class, + parentColumns = ["settingType", "key"], + childColumns = ["settingType", "key"], + onDelete = ForeignKey.NO_ACTION, + ), + ], + indices = [Index("sessionToken"), Index(value = ["settingType", "key"])], +) +data class ProtectionValueEntity( + val sessionToken: String, + val settingType: SettingType, + val key: String, + val protectedValue: String, + val writeOrder: Int, +) diff --git a/docs/app-list-benchmark.md b/docs/app-list-benchmark.md new file mode 100644 index 000000000..10059014b --- /dev/null +++ b/docs/app-list-benchmark.md @@ -0,0 +1,20 @@ +# App-list performance check + +The apps screen calls `ReportDrawnWhen` after its first metadata result (or terminal load error), so +Android records a fully-drawn marker without waiting for every installed-app icon. + +Use the same physical device and installed-app set for before/after measurements. Build and install +the debug variant, then perform ten cold runs: + +```bash +./gradlew :app:installDebug +adb shell am force-stop com.android.geto.debug +adb logcat -c +adb shell am start -W -S -n com.android.geto.debug/com.android.geto.activity.main.MainActivity +adb logcat -d -s ActivityTaskManager:I | rg 'Fully drawn.*com.android.geto.debug' +``` + +Record the fully-drawn duration from each run and compare the medians. Also scroll the complete list, +change each sort mode, search by app label and package name, clear search, and verify that package +install/update/removal callbacks change only the affected rows. The acceptance target is at least a +50% lower median time to the first metadata list with no visible scrolling regression. diff --git a/domain/framework/src/main/kotlin/com/android/geto/domain/framework/ForegroundAppWrapper.kt b/domain/framework/src/main/kotlin/com/android/geto/domain/framework/ForegroundAppWrapper.kt new file mode 100644 index 000000000..9950211d5 --- /dev/null +++ b/domain/framework/src/main/kotlin/com/android/geto/domain/framework/ForegroundAppWrapper.kt @@ -0,0 +1,44 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.domain.framework + +import kotlinx.coroutines.flow.Flow + +/** + * Reports which app is in front, so a profile can be applied only while its app is actually open. + * + * Applying settings permanently is what makes Geto dangerous: turning developer options off and + * leaving it off takes ADB and Shizuku with it. Scoping the change to the moments the target app is + * on screen keeps the device usable the rest of the time. + * + * The implementation is push-based — Android delivers a window-change event — so observing this costs + * nothing while nothing changes. + */ +interface ForegroundAppWrapper { + /** Package name of the foreground app, or null when it is not known yet. */ + val foregroundPackage: Flow + + /** + * Whether the detector is actually running. False means Geto cannot see app switches at all, so + * nothing will be applied and anything currently applied has to be restored. + */ + val isRunning: Flow + + /** True when the user has granted the permission the detector needs. */ + fun isEnabled(): Boolean +} diff --git a/domain/framework/src/main/kotlin/com/android/geto/domain/framework/LauncherAppsWrapper.kt b/domain/framework/src/main/kotlin/com/android/geto/domain/framework/LauncherAppsWrapper.kt index 6a522fca5..d8f3dddd2 100644 --- a/domain/framework/src/main/kotlin/com/android/geto/domain/framework/LauncherAppsWrapper.kt +++ b/domain/framework/src/main/kotlin/com/android/geto/domain/framework/LauncherAppsWrapper.kt @@ -21,5 +21,8 @@ import com.android.geto.domain.model.LauncherAppsActivityInfo import kotlinx.coroutines.flow.Flow interface LauncherAppsWrapper { - fun getActivityListFlow(): Flow> + fun getActivityListFlow(): Flow>> + + /** Rebuilds the cached launcher metadata snapshot. */ + fun refresh() } diff --git a/domain/framework/src/main/kotlin/com/android/geto/domain/framework/PackageManagerWrapper.kt b/domain/framework/src/main/kotlin/com/android/geto/domain/framework/PackageManagerWrapper.kt index 62f393958..053a880e7 100644 --- a/domain/framework/src/main/kotlin/com/android/geto/domain/framework/PackageManagerWrapper.kt +++ b/domain/framework/src/main/kotlin/com/android/geto/domain/framework/PackageManagerWrapper.kt @@ -20,7 +20,9 @@ package com.android.geto.domain.framework interface PackageManagerWrapper { suspend fun getActivityIcon(componentName: String): ByteArray? - suspend fun getLastInstallTime(packageName: String): Long + suspend fun getLastUpdateTime(packageName: String): Long + + suspend fun getLastUpdateTimes(packageNames: Set): Map fun isSystem(flags: Int): Boolean } diff --git a/domain/framework/src/main/kotlin/com/android/geto/domain/framework/SecureSettingsWrapper.kt b/domain/framework/src/main/kotlin/com/android/geto/domain/framework/SecureSettingsWrapper.kt index f804fd710..db2b47437 100644 --- a/domain/framework/src/main/kotlin/com/android/geto/domain/framework/SecureSettingsWrapper.kt +++ b/domain/framework/src/main/kotlin/com/android/geto/domain/framework/SecureSettingsWrapper.kt @@ -18,14 +18,31 @@ package com.android.geto.domain.framework import com.android.geto.domain.model.SecureSetting +import com.android.geto.domain.model.SettingReadResult import com.android.geto.domain.model.SettingType +import com.android.geto.domain.model.SettingWriteResult interface SecureSettingsWrapper { + fun hasWriteSecureSettingsPermission(): Boolean + + suspend fun read( + settingType: SettingType, + key: String, + ): SettingReadResult + + /** Writes a nullable value and verifies the effective value by reading it back. */ + suspend fun write( + settingType: SettingType, + key: String, + value: String?, + ): SettingWriteResult + + @Deprecated("Use write(), which reports verification failures") suspend fun canWriteSecureSettings( settingType: SettingType, key: String, value: String, - ): Boolean + ): Boolean = write(settingType, key, value) is SettingWriteResult.Success suspend fun getSecureSettings(settingType: SettingType): List } diff --git a/domain/framework/src/main/kotlin/com/android/geto/domain/framework/ShizukuWrapper.kt b/domain/framework/src/main/kotlin/com/android/geto/domain/framework/ShizukuWrapper.kt new file mode 100644 index 000000000..6391f6bf2 --- /dev/null +++ b/domain/framework/src/main/kotlin/com/android/geto/domain/framework/ShizukuWrapper.kt @@ -0,0 +1,47 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.domain.framework + +import com.android.geto.domain.model.ShizukuGrantResult +import com.android.geto.domain.model.ShizukuState +import kotlinx.coroutines.flow.Flow + +interface ShizukuWrapper { + /** The current [ShizukuState], kept up to date while at least one caller has [start]ed. */ + val state: Flow + + /** Begins observing Shizuku's binder. Reference counted, so overlapping callers are safe. */ + fun start() + + /** Balances a previous [start]. Listeners are only removed once every caller has stopped. */ + fun stop() + + /** Re-evaluates [state] on demand, e.g. after the user returns from the Shizuku app. */ + fun refresh() + + /** + * Asks Shizuku for permission if needed, binds the privileged user service, and grants + * `WRITE_SECURE_SETTINGS` to Geto. + * + * Suspends until the whole sequence has resolved, so the returned result always describes what + * actually happened rather than what was merely started. Never returns + * [ShizukuGrantResult.RequiresRestart] — confirming the grant is visible to this process is the + * caller's job. + */ + suspend fun grantWriteSecureSettings(): ShizukuGrantResult +} diff --git a/domain/model/src/main/kotlin/com/android/geto/domain/model/AppSettingTemplate.kt b/domain/model/src/main/kotlin/com/android/geto/domain/model/AppSettingTemplate.kt index 862424732..2b95359db 100644 --- a/domain/model/src/main/kotlin/com/android/geto/domain/model/AppSettingTemplate.kt +++ b/domain/model/src/main/kotlin/com/android/geto/domain/model/AppSettingTemplate.kt @@ -17,10 +17,31 @@ */ package com.android.geto.domain.model -data class AppSettingTemplate( +data class AppSettingTemplateEntry( val settingType: SettingType, val label: String, val key: String, val valueOnLaunch: String, + /** Legacy/manual fallback only. Protection sessions restore captured original values. */ val valueOnRevert: String, ) + +data class AppSettingTemplate( + val id: String, + val label: String, + val description: String, + val warning: String? = null, + val entries: List, +) { + fun toAppSettings(componentName: String): List = entries.map { entry -> + AppSetting( + enabled = true, + settingType = entry.settingType, + componentName = componentName, + label = entry.label, + key = entry.key, + valueOnLaunch = entry.valueOnLaunch, + valueOnRevert = entry.valueOnRevert, + ) + } +} diff --git a/domain/model/src/main/kotlin/com/android/geto/domain/model/GrantMethod.kt b/domain/model/src/main/kotlin/com/android/geto/domain/model/GrantMethod.kt new file mode 100644 index 000000000..d22e6f000 --- /dev/null +++ b/domain/model/src/main/kotlin/com/android/geto/domain/model/GrantMethod.kt @@ -0,0 +1,24 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.domain.model + +/** How the user prefers to grant `WRITE_SECURE_SETTINGS` to Geto. */ +enum class GrantMethod { + ADB, + SHIZUKU, +} diff --git a/domain/model/src/main/kotlin/com/android/geto/domain/model/LauncherAppIcon.kt b/domain/model/src/main/kotlin/com/android/geto/domain/model/LauncherAppIcon.kt new file mode 100644 index 000000000..ac51a0e20 --- /dev/null +++ b/domain/model/src/main/kotlin/com/android/geto/domain/model/LauncherAppIcon.kt @@ -0,0 +1,24 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.domain.model + +/** A lightweight Coil model whose version changes whenever the installed app changes. */ +data class LauncherAppIcon( + val componentName: String, + val lastUpdateTime: Long, +) diff --git a/domain/model/src/main/kotlin/com/android/geto/domain/model/LauncherAppsActivityInfo.kt b/domain/model/src/main/kotlin/com/android/geto/domain/model/LauncherAppsActivityInfo.kt index 62336eaad..63a4845fe 100644 --- a/domain/model/src/main/kotlin/com/android/geto/domain/model/LauncherAppsActivityInfo.kt +++ b/domain/model/src/main/kotlin/com/android/geto/domain/model/LauncherAppsActivityInfo.kt @@ -20,37 +20,8 @@ package com.android.geto.domain.model data class LauncherAppsActivityInfo( val componentName: String, val packageName: String, - val activityIcon: ByteArray?, val activityLabel: String, val firstInstallTime: Long, val lastUpdateTime: Long, val isSystem: Boolean, -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as LauncherAppsActivityInfo - - if (firstInstallTime != other.firstInstallTime) return false - if (lastUpdateTime != other.lastUpdateTime) return false - if (isSystem != other.isSystem) return false - if (componentName != other.componentName) return false - if (packageName != other.packageName) return false - if (!activityIcon.contentEquals(other.activityIcon)) return false - if (activityLabel != other.activityLabel) return false - - return true - } - - override fun hashCode(): Int { - var result = firstInstallTime.hashCode() - result = 31 * result + lastUpdateTime.hashCode() - result = 31 * result + isSystem.hashCode() - result = 31 * result + componentName.hashCode() - result = 31 * result + packageName.hashCode() - result = 31 * result + (activityIcon?.contentHashCode() ?: 0) - result = 31 * result + activityLabel.hashCode() - return result - } -} +) diff --git a/domain/model/src/main/kotlin/com/android/geto/domain/model/Protection.kt b/domain/model/src/main/kotlin/com/android/geto/domain/model/Protection.kt new file mode 100644 index 000000000..3134b0d69 --- /dev/null +++ b/domain/model/src/main/kotlin/com/android/geto/domain/model/Protection.kt @@ -0,0 +1,226 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.domain.model + +enum class ProtectionMode { + /** + * Applied only while the app is in the foreground, and restored the moment it leaves. + * + * This replaced a mode that applied settings permanently. Leaving something like + * `development_settings_enabled = 0` in place disables developer options system-wide, taking ADB + * and Shizuku with it, with no automatic way back — so the scope is now the app's time on screen. + */ + FOREGROUND, + + /** Applied once when launched through Geto, restored when the app leaves the foreground. */ + ONE_SHOT, +} + +enum class ProtectionSessionStatus { + STARTING, + ACTIVE, + RESTORING, + RECOVERY_REQUIRED, +} + +/** + * One app's claim on a setting key. + * + * Deliberately carries no original value: many apps can claim the same key, and only the first claim + * ever sees the true pre-Geto value. That lives on [ProtectedKey] instead. + */ +data class ProtectedSetting( + val settingType: SettingType, + val key: String, + val protectedValue: String, + val writeOrder: Int, +) + +/** + * The ledger entry for a setting key that is currently under protection. + * + * [originalValue] is captured when the key is claimed for the first time and is never overwritten, + * so the last app to release the key can always put the device back the way it was. [claimCount] is + * how many apps currently claim it. + */ +data class ProtectedKey( + val settingType: SettingType, + val key: String, + val originalValue: String?, + val enforcedValue: String, + val claimedAtMillis: Long, + val claimCount: Int = 1, +) + +data class ProtectionSession( + val token: String, + val componentName: String, + val mode: ProtectionMode, + val status: ProtectionSessionStatus, + val protectedSettings: List, + val createdAtMillis: Long, + val updatedAtMillis: Long, + val pausedUntilMillis: Long = 0L, + val lastError: String? = null, +) + +/** One protected app, as the rest of the app sees it. */ +data class ProtectedApp( + val sessionToken: String, + val componentName: String, + val mode: ProtectionMode, + val status: ProtectionSessionStatus, + val protectedSettings: List, + val pause: ProtectionPauseState, + val lastError: String? = null, +) { + /** Mid-transition, so the UI should not offer actions that would race it. */ + val isBusy: Boolean + get() = status == ProtectionSessionStatus.STARTING || + status == ProtectionSessionStatus.RESTORING + + /** Actually watching for drift right now. */ + val isWatched: Boolean + get() = status == ProtectionSessionStatus.ACTIVE && pause == ProtectionPauseState.Running +} + +/** + * Every protected app at once. + * + * Several apps can be protected simultaneously and they are independent, so this is a list rather + * than a set of mutually exclusive states. + */ +@JvmInline +value class ProtectionState(val apps: List) { + val isEmpty: Boolean get() = apps.isEmpty() + + fun forComponentName(componentName: String): ProtectedApp? = apps.firstOrNull { it.componentName == componentName } + + fun forToken(sessionToken: String): ProtectedApp? = apps.firstOrNull { it.sessionToken == sessionToken } + + companion object { + val Empty = ProtectionState(emptyList()) + } +} + +enum class ProtectionStage { + PREFLIGHT, + SNAPSHOT, + PERSIST, + APPLY, + RESTORE, + REAPPLY, +} + +enum class ProtectionFailureReason { + PERMISSION_DENIED, + INVALID_PROFILE, + INVALID_KEY, + READ_FAILED, + WRITE_REJECTED, + VERIFICATION_FAILED, + PERSISTENCE_FAILED, + INVALID_STATE, + DUPLICATE_KEY, + KEY_CONFLICT, +} + +data class ProtectionFailure( + val reason: ProtectionFailureReason, + val settingType: SettingType? = null, + val key: String? = null, + val expectedValue: String? = null, + val actualValue: String? = null, + val message: String? = null, +) + +sealed interface ProtectionResult { + /** [app] is null when the operation ended with nothing protected, e.g. after a turn-off. */ + data class Success( + val app: ProtectedApp?, + ) : ProtectionResult + + data object EmptyProfile : ProtectionResult + + data object NoEnabledSettings : ProtectionResult + + data object NoActiveProtection : ProtectionResult + + /** + * Another app already enforces this key with a different value. + * + * Sharing a key is normal — several apps wanting the same value is the common case — so this only + * covers a genuine contradiction, where one setting would have to hold two values at once. It is + * returned before anything is written, so the device is left untouched. + */ + data class KeyConflict( + val settingType: SettingType, + val key: String, + val requestedValue: String, + val enforcedValue: String, + val holderComponentNames: List, + ) : ProtectionResult + + data class StaleSession( + val expectedSessionToken: String, + val activeSessionToken: String?, + ) : ProtectionResult + + data class PermissionDenied( + val failure: ProtectionFailure = ProtectionFailure( + reason = ProtectionFailureReason.PERMISSION_DENIED, + ), + ) : ProtectionResult + + data class InvalidProfile( + val failures: List, + ) : ProtectionResult + + data class KeyNotProtected( + val settingType: SettingType, + val key: String, + ) : ProtectionResult + + data class Failure( + val stage: ProtectionStage, + val failures: List, + val rollbackSucceeded: Boolean? = null, + ) : ProtectionResult + + data class RecoveryRequired( + val app: ProtectedApp, + val failures: List, + ) : ProtectionResult +} + +/** + * @param nowMillis passed in rather than read here so the pause deadline is evaluated against the + * caller's clock on every read — an expired pause reports as running even if nothing woke up to clear it. + */ +fun ProtectionSession.asProtectedApp(nowMillis: Long): ProtectedApp = ProtectedApp( + sessionToken = token, + componentName = componentName, + mode = mode, + status = status, + protectedSettings = protectedSettings, + pause = pauseStateOf(pausedUntilMillis = pausedUntilMillis, nowMillis = nowMillis), + lastError = lastError, +) + +/** True while this session claims [key] of [settingType]. */ +fun ProtectionSession.claims(settingType: SettingType, key: String): Boolean = protectedSettings.any { it.settingType == settingType && it.key == key } diff --git a/domain/model/src/main/kotlin/com/android/geto/domain/model/ProtectionPauseDuration.kt b/domain/model/src/main/kotlin/com/android/geto/domain/model/ProtectionPauseDuration.kt new file mode 100644 index 000000000..7fae77c76 --- /dev/null +++ b/domain/model/src/main/kotlin/com/android/geto/domain/model/ProtectionPauseDuration.kt @@ -0,0 +1,30 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.domain.model + +/** + * How long protection should stay paused. + * + * A null [millis] means the pause has no deadline and only ends when the user resumes it. + */ +enum class ProtectionPauseDuration(val millis: Long?) { + TEN_MINUTES(10 * 60 * 1_000L), + THIRTY_MINUTES(30 * 60 * 1_000L), + ONE_HOUR(60 * 60 * 1_000L), + INDEFINITE(null), +} diff --git a/domain/model/src/main/kotlin/com/android/geto/domain/model/ProtectionPauseState.kt b/domain/model/src/main/kotlin/com/android/geto/domain/model/ProtectionPauseState.kt new file mode 100644 index 000000000..f227b5984 --- /dev/null +++ b/domain/model/src/main/kotlin/com/android/geto/domain/model/ProtectionPauseState.kt @@ -0,0 +1,62 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.domain.model + +/** + * Whether protection is currently watching its settings. + * + * A pause leaves the profile applied and the session untouched; it only stops Geto from repairing + * drift. Undoing a profile is still [ProtectionState] territory. + */ +sealed interface ProtectionPauseState { + /** Protection is doing its job. */ + data object Running : ProtectionPauseState + + /** Paused until [untilMillis] (epoch millis), after which it resumes on its own. */ + data class PausedUntil(val untilMillis: Long) : ProtectionPauseState + + /** Paused with no deadline; only the user can end it. */ + data object PausedIndefinitely : ProtectionPauseState +} + +/** True while protection is paused, whichever kind of pause it is. */ +val ProtectionPauseState.isPaused: Boolean + get() = this != ProtectionPauseState.Running + +/** Stored value meaning "not paused". */ +const val PAUSE_NOT_PAUSED = 0L + +/** Stored value meaning "paused with no deadline". */ +const val PAUSE_INDEFINITE = Long.MAX_VALUE + +/** + * Turns a stored deadline into a state, comparing it against [nowMillis] every time. + * + * That comparison is what makes a pause self-correcting: a deadline that ran out while nothing was + * awake to clear it still reads as [ProtectionPauseState.Running], so no timer has to fire on time + * for the state to be right. + */ +fun pauseStateOf(pausedUntilMillis: Long, nowMillis: Long): ProtectionPauseState = when { + pausedUntilMillis == PAUSE_NOT_PAUSED -> ProtectionPauseState.Running + pausedUntilMillis == PAUSE_INDEFINITE -> ProtectionPauseState.PausedIndefinitely + pausedUntilMillis <= nowMillis -> ProtectionPauseState.Running + else -> ProtectionPauseState.PausedUntil(pausedUntilMillis) +} + +/** The stored deadline for pausing by [duration] starting at [nowMillis]. */ +fun ProtectionPauseDuration.deadlineFrom(nowMillis: Long): Long = millis?.let { nowMillis + it } ?: PAUSE_INDEFINITE diff --git a/domain/model/src/main/kotlin/com/android/geto/domain/model/SettingAccessResult.kt b/domain/model/src/main/kotlin/com/android/geto/domain/model/SettingAccessResult.kt new file mode 100644 index 000000000..58288abb2 --- /dev/null +++ b/domain/model/src/main/kotlin/com/android/geto/domain/model/SettingAccessResult.kt @@ -0,0 +1,42 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.domain.model + +sealed interface SettingReadResult { + data class Success( + val value: String?, + ) : SettingReadResult + + data class Failure( + val reason: ProtectionFailureReason, + val message: String? = null, + ) : SettingReadResult +} + +sealed interface SettingWriteResult { + data class Success( + val value: String?, + ) : SettingWriteResult + + data class Failure( + val reason: ProtectionFailureReason, + val expectedValue: String?, + val actualValue: String? = null, + val message: String? = null, + ) : SettingWriteResult +} diff --git a/domain/model/src/main/kotlin/com/android/geto/domain/model/ShizukuGrantResult.kt b/domain/model/src/main/kotlin/com/android/geto/domain/model/ShizukuGrantResult.kt new file mode 100644 index 000000000..82aef1246 --- /dev/null +++ b/domain/model/src/main/kotlin/com/android/geto/domain/model/ShizukuGrantResult.kt @@ -0,0 +1,42 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.domain.model + +/** + * The outcome of one attempt to grant `WRITE_SECURE_SETTINGS` through Shizuku. + * + * This is returned from a suspending call rather than emitted on a flow, so a repeated attempt with + * the same outcome can never be conflated away, and [Success] can only be reported after the grant + * has actually been read back. + */ +sealed interface ShizukuGrantResult { + /** The permission was granted and Geto can already see it. */ + data object Success : ShizukuGrantResult + + /** + * The grant call went through, but this process still reads the permission as denied. Some ROMs + * only surface it to a freshly started process, so the user needs to restart Geto. + */ + data object RequiresRestart : ShizukuGrantResult + + /** Shizuku was not in a state where a grant could even be attempted. */ + data class Failure(val state: ShizukuState) : ShizukuGrantResult + + /** The grant was attempted and something went wrong along the way. */ + data class Error(val message: String?) : ShizukuGrantResult +} diff --git a/domain/model/src/main/kotlin/com/android/geto/domain/model/ShizukuState.kt b/domain/model/src/main/kotlin/com/android/geto/domain/model/ShizukuState.kt new file mode 100644 index 000000000..4e806ab81 --- /dev/null +++ b/domain/model/src/main/kotlin/com/android/geto/domain/model/ShizukuState.kt @@ -0,0 +1,45 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.domain.model + +/** + * What Shizuku can do for us right now. Each state maps to exactly one thing the user can do about + * it, so the UI never has to guess. + */ +enum class ShizukuState { + /** Still working out which of the states below applies. */ + Unknown, + + /** The Shizuku app is not installed at all. */ + NotInstalled, + + /** Shizuku is installed but its service is not running, so there is no binder to talk to. */ + NotRunning, + + /** Shizuku is running but too old to bind a user service. */ + OutdatedVersion, + + /** Shizuku is running; it has not been asked for permission yet, or the ask was dismissed. */ + PermissionRequired, + + /** The user explicitly denied Geto in Shizuku, and Shizuku will not ask again. */ + PermissionDenied, + + /** Shizuku is running and Geto is authorised, so a grant can be attempted. */ + Ready, +} diff --git a/domain/model/src/main/kotlin/com/android/geto/domain/model/UserData.kt b/domain/model/src/main/kotlin/com/android/geto/domain/model/UserData.kt index 2abb8f753..931ec23fc 100644 --- a/domain/model/src/main/kotlin/com/android/geto/domain/model/UserData.kt +++ b/domain/model/src/main/kotlin/com/android/geto/domain/model/UserData.kt @@ -23,4 +23,7 @@ data class UserData( val sortLauncherAppsActivityInfo: SortLauncherAppsActivityInfo, val sortOrderLauncherAppsActivityInfo: SortOrderLauncherAppsActivityInfo, val showSystem: Boolean, + val autoRestartProtection: Boolean = false, + val grantMethod: GrantMethod = GrantMethod.ADB, + val protectionPausedUntilMillis: Long = 0L, ) diff --git a/domain/repository/src/main/kotlin/com/android/geto/domain/repository/AppSettingsRepository.kt b/domain/repository/src/main/kotlin/com/android/geto/domain/repository/AppSettingsRepository.kt index 2b614c43c..9ab2a4f7c 100644 --- a/domain/repository/src/main/kotlin/com/android/geto/domain/repository/AppSettingsRepository.kt +++ b/domain/repository/src/main/kotlin/com/android/geto/domain/repository/AppSettingsRepository.kt @@ -26,6 +26,8 @@ interface AppSettingsRepository { suspend fun upsertAppSetting(appSetting: AppSetting) + suspend fun upsertAppSettings(appSettings: List) + suspend fun deleteAppSetting(appSetting: AppSetting) fun getAppSettingsFlowByComponentName(componentName: String): Flow> diff --git a/domain/repository/src/main/kotlin/com/android/geto/domain/repository/ProtectionRepository.kt b/domain/repository/src/main/kotlin/com/android/geto/domain/repository/ProtectionRepository.kt new file mode 100644 index 000000000..b0c8b6c36 --- /dev/null +++ b/domain/repository/src/main/kotlin/com/android/geto/domain/repository/ProtectionRepository.kt @@ -0,0 +1,84 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.domain.repository + +import com.android.geto.domain.model.ProtectedKey +import com.android.geto.domain.model.ProtectionSession +import com.android.geto.domain.model.ProtectionSessionStatus +import com.android.geto.domain.model.SettingType +import kotlinx.coroutines.flow.Flow + +interface ProtectionRepository { + /** + * Apps the user has armed. Separate from sessions on purpose: arming is standing intent and + * persists, a session means "applied right now" and must not survive a restart. + */ + val armedComponentNamesFlow: Flow> + + suspend fun getArmedComponentNames(): Set + + suspend fun armProfile(componentName: String, armedAtMillis: Long) + + suspend fun disarmProfile(componentName: String): Boolean + + val sessionsFlow: Flow> + + suspend fun getSessions(): List + + suspend fun getSessionByToken(token: String): ProtectionSession? + + suspend fun getSessionByComponentName(componentName: String): ProtectionSession? + + /** Every key currently under protection, with how many apps claim each one. */ + suspend fun getProtectedKeys(): List + + suspend fun getProtectedKey(settingType: SettingType, key: String): ProtectedKey? + + /** Which apps claim this key, so a conflict can name them. */ + suspend fun getClaimantComponentNames(settingType: SettingType, key: String): List + + /** + * Keys whose only claimant is [token] — exactly the ones turning that app off must write back. + * Keys another app still claims are excluded. + */ + suspend fun getKeysReleasedBy(token: String): List + + /** Ledger rows no app claims any more, left behind by a process death mid-restore. */ + suspend fun getOrphanKeys(): List + + suspend fun deleteOrphanKeys(): Int + + /** Inserts [newKeys], the session and its claims in one transaction. */ + suspend fun createSession(session: ProtectionSession, newKeys: List) + + suspend fun updateStatus( + token: String, + status: ProtectionSessionStatus, + updatedAtMillis: Long, + lastError: String? = null, + ): Boolean + + suspend fun updatePausedUntil( + token: String, + pausedUntilMillis: Long, + updatedAtMillis: Long, + ): Boolean + + /** Deletes the session, its claims, and any ledger row left with no claims. */ + suspend fun clearSession(token: String): Boolean +} diff --git a/domain/repository/src/main/kotlin/com/android/geto/domain/repository/UserDataRepository.kt b/domain/repository/src/main/kotlin/com/android/geto/domain/repository/UserDataRepository.kt index f6ec7a287..41115ec69 100644 --- a/domain/repository/src/main/kotlin/com/android/geto/domain/repository/UserDataRepository.kt +++ b/domain/repository/src/main/kotlin/com/android/geto/domain/repository/UserDataRepository.kt @@ -17,6 +17,7 @@ */ package com.android.geto.domain.repository +import com.android.geto.domain.model.GrantMethod import com.android.geto.domain.model.SortLauncherAppsActivityInfo import com.android.geto.domain.model.SortOrderLauncherAppsActivityInfo import com.android.geto.domain.model.Theme @@ -27,6 +28,8 @@ interface UserDataRepository { val userData: Flow + val preferencesWereReset: Flow + suspend fun updateTheme(theme: Theme) suspend fun updateDynamicTheme(dynamicTheme: Boolean) @@ -36,4 +39,14 @@ interface UserDataRepository { suspend fun updateSortOrderLauncherAppsActivityInfo(sortOrderLauncherAppsActivityInfo: SortOrderLauncherAppsActivityInfo) suspend fun updateShowSystem(showSystem: Boolean) + + suspend fun updateAutoRestartProtection(autoRestartProtection: Boolean) + + suspend fun updateGrantMethod(grantMethod: GrantMethod) + + suspend fun updateProtectionPausedUntil(protectionPausedUntilMillis: Long) + + suspend fun resetUserPreferences() + + fun acknowledgePreferencesReset() } diff --git a/domain/use-case/build.gradle.kts b/domain/use-case/build.gradle.kts index 105e35d16..abdfb0bb1 100644 --- a/domain/use-case/build.gradle.kts +++ b/domain/use-case/build.gradle.kts @@ -26,4 +26,7 @@ dependencies { implementation(projects.domain.common) implementation(projects.domain.framework) implementation(projects.domain.repository) -} \ No newline at end of file + + testImplementation(kotlin("test")) + testImplementation(libs.kotlinx.coroutines.test) +} diff --git a/domain/use-case/src/main/kotlin/com/android/geto/domain/usecase/AddAppSettingTemplateUseCase.kt b/domain/use-case/src/main/kotlin/com/android/geto/domain/usecase/AddAppSettingTemplateUseCase.kt new file mode 100644 index 000000000..254e34a11 --- /dev/null +++ b/domain/use-case/src/main/kotlin/com/android/geto/domain/usecase/AddAppSettingTemplateUseCase.kt @@ -0,0 +1,54 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.domain.usecase + +import com.android.geto.domain.common.dispatcher.Dispatcher +import com.android.geto.domain.common.dispatcher.GetoDispatchers +import com.android.geto.domain.model.AddAppSettingResult +import com.android.geto.domain.model.AppSettingTemplate +import com.android.geto.domain.repository.AppSettingsRepository +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class AddAppSettingTemplateUseCase @Inject constructor( + private val appSettingsRepository: AppSettingsRepository, + @param:Dispatcher(GetoDispatchers.Default) private val defaultDispatcher: CoroutineDispatcher, +) { + suspend operator fun invoke( + componentName: String, + template: AppSettingTemplate, + ): AddAppSettingResult = withContext(defaultDispatcher) { + val settings = template.toAppSettings(componentName) + val identities = settings.map { it.settingType to it.key } + if (settings.isEmpty() || identities.distinct().size != identities.size) { + return@withContext AddAppSettingResult.Failed + } + + val existingIdentities = appSettingsRepository + .getAppSettingsByComponentName(componentName) + .map { it.settingType to it.key } + .toSet() + if (identities.any(existingIdentities::contains)) { + return@withContext AddAppSettingResult.Failed + } + + appSettingsRepository.upsertAppSettings(settings) + AddAppSettingResult.Success + } +} diff --git a/domain/use-case/src/main/kotlin/com/android/geto/domain/usecase/ApplyAppSettingsUseCase.kt b/domain/use-case/src/main/kotlin/com/android/geto/domain/usecase/ApplyAppSettingsUseCase.kt index 6dce373bf..67cbc814e 100644 --- a/domain/use-case/src/main/kotlin/com/android/geto/domain/usecase/ApplyAppSettingsUseCase.kt +++ b/domain/use-case/src/main/kotlin/com/android/geto/domain/usecase/ApplyAppSettingsUseCase.kt @@ -19,43 +19,36 @@ package com.android.geto.domain.usecase import com.android.geto.domain.common.dispatcher.Dispatcher import com.android.geto.domain.common.dispatcher.GetoDispatchers -import com.android.geto.domain.framework.SecureSettingsWrapper import com.android.geto.domain.model.AppSettingsResult -import com.android.geto.domain.repository.AppSettingsRepository +import com.android.geto.domain.model.ProtectionMode +import com.android.geto.domain.model.ProtectionResult import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import javax.inject.Inject class ApplyAppSettingsUseCase @Inject constructor( - private val appSettingsRepository: AppSettingsRepository, - private val secureSettingsWrapper: SecureSettingsWrapper, + private val protectionController: ProtectionController, @param:Dispatcher(GetoDispatchers.Default) private val defaultDispatcher: CoroutineDispatcher, ) { suspend operator fun invoke(componentName: String): AppSettingsResult = withContext(defaultDispatcher) { - val appSettings = - appSettingsRepository.getAppSettingsByComponentName(componentName = componentName) + when (protectionController.enable(componentName, ProtectionMode.ONE_SHOT)) { + is ProtectionResult.Success -> AppSettingsResult.Success - if (appSettings.isEmpty()) return@withContext AppSettingsResult.EmptyAppSettings + ProtectionResult.EmptyProfile -> AppSettingsResult.EmptyAppSettings - if (appSettings.all { !it.enabled }) return@withContext AppSettingsResult.DisabledAppSettings + ProtectionResult.NoEnabledSettings -> AppSettingsResult.DisabledAppSettings - try { - if (appSettings.all { - secureSettingsWrapper.canWriteSecureSettings( - settingType = it.settingType, - key = it.key, - value = it.valueOnLaunch, - ) - } - ) { - AppSettingsResult.Success - } else { - AppSettingsResult.Failure - } - } catch (_: SecurityException) { - AppSettingsResult.NoPermission - } catch (_: IllegalArgumentException) { - AppSettingsResult.InvalidValues + is ProtectionResult.PermissionDenied -> AppSettingsResult.NoPermission + + is ProtectionResult.InvalidProfile -> AppSettingsResult.InvalidValues + + is ProtectionResult.Failure, + is ProtectionResult.KeyConflict, + is ProtectionResult.KeyNotProtected, + is ProtectionResult.StaleSession, + ProtectionResult.NoActiveProtection, + is ProtectionResult.RecoveryRequired, + -> AppSettingsResult.Failure } } } diff --git a/domain/use-case/src/main/kotlin/com/android/geto/domain/usecase/GetLauncherAppsActivityInfosUseCase.kt b/domain/use-case/src/main/kotlin/com/android/geto/domain/usecase/GetLauncherAppsActivityInfosUseCase.kt index 389fa5a1d..d25b5ba75 100644 --- a/domain/use-case/src/main/kotlin/com/android/geto/domain/usecase/GetLauncherAppsActivityInfosUseCase.kt +++ b/domain/use-case/src/main/kotlin/com/android/geto/domain/usecase/GetLauncherAppsActivityInfosUseCase.kt @@ -36,53 +36,52 @@ class GetLauncherAppsActivityInfosUseCase @Inject constructor( private val userDataRepository: UserDataRepository, @param:Dispatcher(GetoDispatchers.Default) private val defaultDispatcher: CoroutineDispatcher, ) { - operator fun invoke(textFlow: Flow) = combine( - textFlow, + operator fun invoke(): Flow> = combine( launcherAppsWrapper.getActivityListFlow(), userDataRepository.userData, - ) { text, launcherAppsActivityInfos, userData -> - val comparator = when (userData.sortLauncherAppsActivityInfo) { - SortLauncherAppsActivityInfo.Name -> { - compareBy(String.CASE_INSENSITIVE_ORDER) { it.activityLabel } - } + ) { launcherAppsResult, userData -> + launcherAppsResult.map { launcherAppsActivityInfos -> + val comparator = when (userData.sortLauncherAppsActivityInfo) { + SortLauncherAppsActivityInfo.Name -> { + compareBy(String.CASE_INSENSITIVE_ORDER) { + it.activityLabel + } + } - SortLauncherAppsActivityInfo.UpdateTime -> { - compareBy { it.lastUpdateTime } - .thenBy(String.CASE_INSENSITIVE_ORDER) { it.activityLabel } - } + SortLauncherAppsActivityInfo.UpdateTime -> { + compareBy { it.lastUpdateTime } + .thenBy(String.CASE_INSENSITIVE_ORDER) { it.activityLabel } + } - SortLauncherAppsActivityInfo.InstallTime -> { - compareBy { it.firstInstallTime } - .thenBy(String.CASE_INSENSITIVE_ORDER) { it.activityLabel } + SortLauncherAppsActivityInfo.InstallTime -> { + compareBy { it.firstInstallTime } + .thenBy(String.CASE_INSENSITIVE_ORDER) { it.activityLabel } + } } - } - val filteredLauncherAppsActivityInfos = if (userData.showSystem) { - launcherAppsActivityInfos - } else { - launcherAppsActivityInfos.filterNot { it.isSystem } - } - - val sortedLauncherAppsActivityInfos = filteredLauncherAppsActivityInfos.sortedWith( - if (userData.sortOrderLauncherAppsActivityInfo == SortOrderLauncherAppsActivityInfo.Ascending) { - comparator + val filteredLauncherAppsActivityInfos = if (userData.showSystem) { + launcherAppsActivityInfos } else { - comparator.reversed() - }, - ) + launcherAppsActivityInfos.filterNot { it.isSystem } + } - LauncherAppsActivityInfoData( - launcherAppsActivityInfos = if (text.isNullOrEmpty()) { - sortedLauncherAppsActivityInfos - } else { - sortedLauncherAppsActivityInfos.filter { - it.activityLabel.contains( - other = text, - ignoreCase = true, - ) + val orderedComparator = + if (userData.sortOrderLauncherAppsActivityInfo == SortOrderLauncherAppsActivityInfo.Ascending) { + comparator + } else { + comparator.reversed() } - }, - userData = userData, - ) + + LauncherAppsActivityInfoData( + launcherAppsActivityInfos = filteredLauncherAppsActivityInfos.sortedWith( + orderedComparator.thenBy { it.componentName }, + ), + userData = userData, + ) + } }.flowOn(defaultDispatcher) + + fun refresh() { + launcherAppsWrapper.refresh() + } } diff --git a/domain/use-case/src/main/kotlin/com/android/geto/domain/usecase/ProtectionController.kt b/domain/use-case/src/main/kotlin/com/android/geto/domain/usecase/ProtectionController.kt new file mode 100644 index 000000000..a4244243a --- /dev/null +++ b/domain/use-case/src/main/kotlin/com/android/geto/domain/usecase/ProtectionController.kt @@ -0,0 +1,1097 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.domain.usecase + +import com.android.geto.domain.framework.SecureSettingsWrapper +import com.android.geto.domain.model.AppSetting +import com.android.geto.domain.model.PAUSE_NOT_PAUSED +import com.android.geto.domain.model.ProtectedApp +import com.android.geto.domain.model.ProtectedKey +import com.android.geto.domain.model.ProtectedSetting +import com.android.geto.domain.model.ProtectionFailure +import com.android.geto.domain.model.ProtectionFailureReason +import com.android.geto.domain.model.ProtectionMode +import com.android.geto.domain.model.ProtectionPauseDuration +import com.android.geto.domain.model.ProtectionPauseState +import com.android.geto.domain.model.ProtectionResult +import com.android.geto.domain.model.ProtectionSession +import com.android.geto.domain.model.ProtectionSessionStatus +import com.android.geto.domain.model.ProtectionStage +import com.android.geto.domain.model.ProtectionState +import com.android.geto.domain.model.SettingReadResult +import com.android.geto.domain.model.SettingType +import com.android.geto.domain.model.SettingWriteResult +import com.android.geto.domain.model.asProtectedApp +import com.android.geto.domain.model.claims +import com.android.geto.domain.model.deadlineFrom +import com.android.geto.domain.model.pauseStateOf +import com.android.geto.domain.repository.AppSettingsRepository +import com.android.geto.domain.repository.ProtectionRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Owns which apps are protected and what the device's settings must therefore look like. + * + * Several apps can be protected at once and they routinely want the *same* key — hiding developer + * options from a handful of banking apps is the motivating case. Keys are therefore reference + * counted through a ledger: the first app to claim a key records the real pre-Geto value, later apps + * simply join the claim, and the original is written back only when the last claim goes away. + */ +@Singleton +class ProtectionController @Inject constructor( + private val appSettingsRepository: AppSettingsRepository, + private val protectionRepository: ProtectionRepository, + private val secureSettingsWrapper: SecureSettingsWrapper, +) { + /** + * One global lock, deliberately not sharded per app. + * + * The ledger and the device's settings are shared by every app. Two concurrent enables claiming + * the same key under per-app locks would both find no ledger row, both snapshot an "original", + * and one of those originals would be lost for good. These operations are user-initiated and + * drift repair is already coalesced, so the contention is not worth the risk. + */ + private val operationMutex = Mutex() + + /** Overridable in tests; the repo has no clock abstraction. */ + internal var now: () -> Long = System::currentTimeMillis + + /** Apps the user wants acted on when they come to the foreground. */ + val armedComponentNames: Flow> = protectionRepository.armedComponentNamesFlow + + val state: Flow = protectionRepository.sessionsFlow + .map { sessions -> ProtectionState(sessions.map { it.asProtectedApp(now()) }) } + .distinctUntilChanged() + + suspend fun enable( + componentName: String, + mode: ProtectionMode, + ): ProtectionResult = operationMutex.withLock { + enableLocked(componentName, mode) + } + + suspend fun armProfile(componentName: String) { + protectionRepository.armProfile(componentName = componentName, armedAtMillis = now()) + } + + /** Disarms and, if the app happens to be in front right now, puts the originals back. */ + suspend fun disarmProfile(componentName: String): ProtectionResult { + protectionRepository.disarmProfile(componentName) + + val session = operationMutex.withLock { + protectionRepository.getSessionByComponentName(componentName) + } ?: return ProtectionResult.Success(app = null) + + return restore(session.token) + } + + /** + * Puts every applied session back. + * + * Called at process start and whenever foreground detection stops. A session means "settings are + * applied right now", so one surviving a restart — or outliving the detector — means the device is + * sitting in a state the user never asked to keep. This is the safety net that stops a profile + * silently disabling developer options forever. + */ + suspend fun restoreEverythingApplied(): ProtectionResult = restoreAll() + + /** + * Puts one app's originals back without changing whether it is armed. + * + * This is the normal path when the app leaves the foreground: the user still wants the profile + * next time, they have just stopped using the app. + */ + suspend fun restoreApplied(componentName: String): ProtectionResult { + val session = operationMutex.withLock { + protectionRepository.getSessionByComponentName(componentName) + } ?: return ProtectionResult.NoActiveProtection + + return restore(session.token) + } + + /** Pauses drift repair for one app without touching its applied values. */ + suspend fun pause( + sessionToken: String, + duration: ProtectionPauseDuration, + ): ProtectionResult = operationMutex.withLock { + val session = protectionRepository.getSessionByToken(sessionToken) + ?: return@withLock ProtectionResult.NoActiveProtection + + val updatedAtMillis = now() + val paused = protectionRepository.updatePausedUntil( + token = sessionToken, + pausedUntilMillis = duration.deadlineFrom(updatedAtMillis), + updatedAtMillis = updatedAtMillis, + ) + + if (!paused) { + return@withLock ProtectionResult.Failure( + stage = ProtectionStage.PERSIST, + failures = listOf( + ProtectionFailure( + reason = ProtectionFailureReason.PERSISTENCE_FAILED, + message = "Could not pause protection", + ), + ), + ) + } + + ProtectionResult.Success( + session.copy(pausedUntilMillis = duration.deadlineFrom(updatedAtMillis)) + .asProtectedApp(updatedAtMillis), + ) + } + + /** + * Ends a pause and repairs whatever drifted while nobody was watching, so "protected" is true + * again the moment it is reported. + */ + suspend fun resume(sessionToken: String): ProtectionResult = operationMutex.withLock { + val session = protectionRepository.getSessionByToken(sessionToken) + ?: return@withLock ProtectionResult.NoActiveProtection + + val resumed = protectionRepository.updatePausedUntil( + token = sessionToken, + pausedUntilMillis = PAUSE_NOT_PAUSED, + updatedAtMillis = now(), + ) + + if (!resumed) { + return@withLock ProtectionResult.Failure( + stage = ProtectionStage.PERSIST, + failures = listOf( + ProtectionFailure( + reason = ProtectionFailureReason.PERSISTENCE_FAILED, + message = "Could not resume protection", + ), + ), + ) + } + + reconcileAppLocked(session.copy(pausedUntilMillis = PAUSE_NOT_PAUSED)) + } + + private suspend fun enableLocked( + componentName: String, + mode: ProtectionMode, + ): ProtectionResult { + if (componentName.isBlank()) { + return ProtectionResult.InvalidProfile( + failures = listOf( + ProtectionFailure( + reason = ProtectionFailureReason.INVALID_PROFILE, + message = "Component name must not be blank", + ), + ), + ) + } + + val existingSession = try { + protectionRepository.getSessionByComponentName(componentName) + } catch (exception: CancellationException) { + throw exception + } catch (exception: RuntimeException) { + return ProtectionResult.Failure( + stage = ProtectionStage.PERSIST, + failures = listOf(exception.asPersistenceFailure()), + ) + } + + if (existingSession != null) { + // Another app holding a session is no longer anyone's business — only this app's own + // session matters here. + if (existingSession.satisfies(componentName, mode)) { + return reconcileAppLocked(existingSession) + } + + return ProtectionResult.RecoveryRequired( + app = existingSession.asProtectedApp(now()), + failures = listOf( + ProtectionFailure( + reason = ProtectionFailureReason.INVALID_STATE, + message = "This app already has a protection session that is ${existingSession.status}", + ), + ), + ) + } + + val profile = try { + appSettingsRepository.getAppSettingsByComponentName(componentName) + } catch (exception: CancellationException) { + throw exception + } catch (exception: RuntimeException) { + return ProtectionResult.Failure( + stage = ProtectionStage.PERSIST, + failures = listOf(exception.asPersistenceFailure()), + ) + } + if (profile.isEmpty()) return ProtectionResult.EmptyProfile + + val enabledSettings = profile.filter(AppSetting::enabled) + if (enabledSettings.isEmpty()) return ProtectionResult.NoEnabledSettings + + validate(enabledSettings).takeIf(List::isNotEmpty)?.let { failures -> + return ProtectionResult.InvalidProfile(failures) + } + + if (!secureSettingsWrapper.hasWriteSecureSettingsPermission()) { + return ProtectionResult.PermissionDenied() + } + + val startedAtMillis = now() + val newKeys = mutableListOf() + val claims = mutableListOf() + val orderedSettings = enabledSettings.inDependencyOrder() + + // Acquire pass. Runs to completion before anything is written, so a rejected enable leaves + // the device exactly as it found it and needs no rollback. + orderedSettings.forEachIndexed { index, appSetting -> + val ledger = try { + protectionRepository.getProtectedKey(appSetting.settingType, appSetting.key) + } catch (exception: CancellationException) { + throw exception + } catch (exception: RuntimeException) { + return ProtectionResult.Failure( + stage = ProtectionStage.PERSIST, + failures = listOf(exception.asPersistenceFailure()), + ) + } + + when { + ledger == null -> { + // First claim: this read is the only chance anyone gets to see the true value. + when ( + val result = secureSettingsWrapper.read( + appSetting.settingType, + appSetting.key, + ) + ) { + is SettingReadResult.Success -> newKeys += ProtectedKey( + settingType = appSetting.settingType, + key = appSetting.key, + originalValue = result.value, + enforcedValue = appSetting.valueOnLaunch, + claimedAtMillis = startedAtMillis, + ) + + is SettingReadResult.Failure -> { + val failure = result.asFailure(appSetting.settingType, appSetting.key) + return if (failure.reason == ProtectionFailureReason.PERMISSION_DENIED) { + ProtectionResult.PermissionDenied(failure) + } else { + ProtectionResult.Failure( + stage = ProtectionStage.SNAPSHOT, + failures = listOf(failure), + ) + } + } + } + } + + // Someone already enforces this key with the same value: just join the claim. + ledger.enforcedValue == appSetting.valueOnLaunch -> Unit + + else -> return ProtectionResult.KeyConflict( + settingType = appSetting.settingType, + key = appSetting.key, + requestedValue = appSetting.valueOnLaunch, + enforcedValue = ledger.enforcedValue, + holderComponentNames = protectionRepository.getClaimantComponentNames( + settingType = appSetting.settingType, + key = appSetting.key, + ), + ) + } + + claims += ProtectedSetting( + settingType = appSetting.settingType, + key = appSetting.key, + protectedValue = appSetting.valueOnLaunch, + writeOrder = index, + ) + } + + val session = ProtectionSession( + token = UUID.randomUUID().toString(), + componentName = componentName, + mode = mode, + status = ProtectionSessionStatus.STARTING, + protectedSettings = claims, + createdAtMillis = startedAtMillis, + updatedAtMillis = startedAtMillis, + ) + + try { + protectionRepository.createSession(session = session, newKeys = newKeys) + } catch (exception: CancellationException) { + throw exception + } catch (exception: RuntimeException) { + return ProtectionResult.Failure( + stage = ProtectionStage.PERSIST, + failures = listOf(exception.asPersistenceFailure()), + ) + } + + // Only keys this session newly claimed may be rolled back. Reverting a shared key would + // break the app that was already relying on it. + val newKeysByIdentity = newKeys.associateBy { it.settingType to it.key } + val attemptedNewKeys = mutableListOf() + + for (claim in claims) { + newKeysByIdentity[claim.settingType to claim.key]?.let { attemptedNewKeys += it } + + val result = secureSettingsWrapper.write( + settingType = claim.settingType, + key = claim.key, + value = claim.protectedValue, + ) + if (result is SettingWriteResult.Failure) { + return handleApplyFailure( + session = session, + attemptedNewKeys = attemptedNewKeys, + failure = result.asFailure(claim.settingType, claim.key), + ) + } + } + + verifyExpectedValues(claims.map { it.expected(it.protectedValue) }) + .firstOrNull() + ?.let { verificationFailure -> + return handleApplyFailure( + session = session, + attemptedNewKeys = attemptedNewKeys, + failure = verificationFailure, + ) + } + + val activatedAtMillis = now() + val activated = try { + protectionRepository.updateStatus( + token = session.token, + status = ProtectionSessionStatus.ACTIVE, + updatedAtMillis = activatedAtMillis, + ) + } catch (exception: CancellationException) { + throw exception + } catch (_: RuntimeException) { + false + } + + if (!activated) { + return handleApplyFailure( + session = session, + attemptedNewKeys = attemptedNewKeys, + failure = ProtectionFailure( + reason = ProtectionFailureReason.PERSISTENCE_FAILED, + message = "Could not mark the protection session active", + ), + ) + } + + return ProtectionResult.Success( + session.copy( + status = ProtectionSessionStatus.ACTIVE, + updatedAtMillis = activatedAtMillis, + ).asProtectedApp(activatedAtMillis), + ) + } + + /** Turns one app's protection off, restoring only the keys nobody else still claims. */ + suspend fun restore(sessionToken: String): ProtectionResult = operationMutex.withLock { + val session = try { + protectionRepository.getSessionByToken(sessionToken) + } catch (exception: CancellationException) { + throw exception + } catch (exception: RuntimeException) { + return@withLock ProtectionResult.Failure( + stage = ProtectionStage.PERSIST, + failures = listOf(exception.asPersistenceFailure()), + ) + } ?: return@withLock ProtectionResult.NoActiveProtection + + restoreLocked(session) + } + + /** Turns every protected app off, reporting the first failure encountered. */ + suspend fun restoreAll(): ProtectionResult = operationMutex.withLock { + val sessions = try { + protectionRepository.getSessions() + } catch (exception: CancellationException) { + throw exception + } catch (exception: RuntimeException) { + return@withLock ProtectionResult.Failure( + stage = ProtectionStage.PERSIST, + failures = listOf(exception.asPersistenceFailure()), + ) + } + if (sessions.isEmpty()) return@withLock ProtectionResult.NoActiveProtection + + sessions.forEach { session -> + val result = restoreLocked(session) + if (result !is ProtectionResult.Success) return@withLock result + } + + ProtectionResult.Success(app = null) + } + + /** + * Restores a pre-v10 notification that predates persisted sessions. Refused when a modern session + * or ledger entry covers any of the profile's keys, so an old notification can never overwrite a + * key another app is actively protecting. + */ + suspend fun restoreLegacyProfile(componentName: String): ProtectionResult = operationMutex.withLock { + val profile = try { + appSettingsRepository.getAppSettingsByComponentName(componentName) + } catch (exception: CancellationException) { + throw exception + } catch (exception: RuntimeException) { + return@withLock ProtectionResult.Failure( + stage = ProtectionStage.PERSIST, + failures = listOf(exception.asPersistenceFailure()), + ) + } + if (profile.isEmpty()) return@withLock ProtectionResult.EmptyProfile + val enabledSettings = profile.filter(AppSetting::enabled) + if (enabledSettings.isEmpty()) return@withLock ProtectionResult.NoEnabledSettings + validate(enabledSettings).takeIf(List::isNotEmpty)?.let { failures -> + return@withLock ProtectionResult.InvalidProfile(failures) + } + + protectionRepository.getSessionByComponentName(componentName)?.let { session -> + return@withLock ProtectionResult.StaleSession( + expectedSessionToken = "", + activeSessionToken = session.token, + ) + } + + enabledSettings.forEach { setting -> + val ledger = protectionRepository.getProtectedKey(setting.settingType, setting.key) + if (ledger != null) { + return@withLock ProtectionResult.KeyConflict( + settingType = setting.settingType, + key = setting.key, + requestedValue = setting.valueOnRevert, + enforcedValue = ledger.enforcedValue, + holderComponentNames = protectionRepository.getClaimantComponentNames( + settingType = setting.settingType, + key = setting.key, + ), + ) + } + } + + if (!secureSettingsWrapper.hasWriteSecureSettingsPermission()) { + return@withLock ProtectionResult.PermissionDenied() + } + + val failures = enabledSettings.asReversed().mapNotNull { setting -> + when ( + val result = secureSettingsWrapper.write( + settingType = setting.settingType, + key = setting.key, + value = setting.valueOnRevert, + ) + ) { + is SettingWriteResult.Success -> null + is SettingWriteResult.Failure -> result.asFailure(setting.settingType, setting.key) + } + } + verifyExpectedValues( + enabledSettings.map { ExpectedValue(it.settingType, it.key, it.valueOnRevert) }, + ) + + if (failures.isEmpty()) { + ProtectionResult.Success(app = null) + } else { + ProtectionResult.Failure( + stage = ProtectionStage.RESTORE, + failures = failures, + rollbackSucceeded = null, + ) + } + } + + /** + * Repairs everything after a process restart: sweeps ledger rows left by an interrupted restore, + * flags sessions caught mid-transition, and re-enforces every key that has drifted. + */ + suspend fun reconcile(): ProtectionResult = operationMutex.withLock { + reconcileAllLocked() + } + + /** Reconciles one app, for the launch path that must not report ready while a value is stale. */ + suspend fun reconcile(componentName: String): ProtectionResult = operationMutex.withLock { + val session = protectionRepository.getSessionByComponentName(componentName) + ?: return@withLock ProtectionResult.NoActiveProtection + + reconcileAppLocked(session) + } + + private suspend fun reconcileAllLocked(): ProtectionResult { + val sessions = try { + protectionRepository.getSessions() + } catch (exception: CancellationException) { + throw exception + } catch (exception: RuntimeException) { + return ProtectionResult.Failure( + stage = ProtectionStage.PERSIST, + failures = listOf(exception.asPersistenceFailure()), + ) + } + + sweepOrphanKeys() + + if (sessions.isEmpty()) return ProtectionResult.NoActiveProtection + + var firstFailure: ProtectionResult? = null + + // A session stuck mid-transition means the process died during an apply or a restore. + sessions.filter { it.status == ProtectionSessionStatus.STARTING || it.status == ProtectionSessionStatus.RESTORING } + .forEach { session -> + val result = markRecoveryRequired( + session = session, + stage = ProtectionStage.REAPPLY, + failures = listOf( + ProtectionFailure( + reason = ProtectionFailureReason.INVALID_STATE, + message = "Protection was interrupted while ${session.status}", + ), + ), + ) + if (firstFailure == null) firstFailure = result + } + + if (!secureSettingsWrapper.hasWriteSecureSettingsPermission()) { + return ProtectionResult.PermissionDenied() + } + + // Drift repair walks the ledger rather than the sessions, so a key five apps share is read + // and written once instead of five times. + val keys = try { + protectionRepository.getProtectedKeys() + } catch (exception: CancellationException) { + throw exception + } catch (exception: RuntimeException) { + return ProtectionResult.Failure( + stage = ProtectionStage.PERSIST, + failures = listOf(exception.asPersistenceFailure()), + ) + } + + val nowMillis = now() + keys.forEach { key -> + val watchers = sessions.filter { session -> + session.claims(key.settingType, key.key) && + session.status == ProtectionSessionStatus.ACTIVE && + pauseStateOf(session.pausedUntilMillis, nowMillis) == ProtectionPauseState.Running + } + if (watchers.isEmpty()) return@forEach + + val failure = enforceKey(key) + if (failure != null) { + watchers.forEach { session -> + val result = markRecoveryRequired( + session = session, + stage = ProtectionStage.REAPPLY, + failures = listOf(failure), + ) + if (firstFailure == null) firstFailure = result + } + } + } + + return firstFailure ?: ProtectionResult.Success(app = null) + } + + private suspend fun reconcileAppLocked(session: ProtectionSession): ProtectionResult { + if (session.status == ProtectionSessionStatus.RECOVERY_REQUIRED) { + return ProtectionResult.RecoveryRequired(session.asProtectedApp(now()), emptyList()) + } + + if (session.status != ProtectionSessionStatus.ACTIVE) { + return markRecoveryRequired( + session = session, + stage = ProtectionStage.REAPPLY, + failures = listOf( + ProtectionFailure( + reason = ProtectionFailureReason.INVALID_STATE, + message = "Protection was interrupted while ${session.status}", + ), + ), + ) + } + + if (!secureSettingsWrapper.hasWriteSecureSettingsPermission()) { + return markRecoveryRequired( + session = session, + stage = ProtectionStage.PREFLIGHT, + failures = listOf( + ProtectionFailure(reason = ProtectionFailureReason.PERMISSION_DENIED), + ), + ) + } + + val nowMillis = now() + if (pauseStateOf(session.pausedUntilMillis, nowMillis) != ProtectionPauseState.Running) { + // Paused apps are left alone on purpose; that is what a pause is. + return ProtectionResult.Success(session.asProtectedApp(nowMillis)) + } + + session.protectedSettings.forEach { setting -> + val key = protectionRepository.getProtectedKey(setting.settingType, setting.key) + ?: return markRecoveryRequired( + session = session, + stage = ProtectionStage.REAPPLY, + failures = listOf( + ProtectionFailure( + reason = ProtectionFailureReason.INVALID_STATE, + settingType = setting.settingType, + key = setting.key, + message = "Protected key is missing from the ledger", + ), + ), + ) + + enforceKey(key)?.let { failure -> + return markRecoveryRequired( + session = session, + stage = ProtectionStage.REAPPLY, + failures = listOf(failure), + ) + } + } + + return ProtectionResult.Success(session.asProtectedApp(nowMillis)) + } + + /** + * Repairs one key after a content observer saw it change. + * + * Observers fire for a key, not for a session, so this looks the key up in the ledger rather than + * being told which app to blame. + */ + suspend fun reapply(type: SettingType, key: String): ProtectionResult = operationMutex.withLock { + val ledgerKey = try { + protectionRepository.getProtectedKey(type, key) + } catch (exception: CancellationException) { + throw exception + } catch (exception: RuntimeException) { + return@withLock ProtectionResult.Failure( + stage = ProtectionStage.PERSIST, + failures = listOf(exception.asPersistenceFailure()), + ) + } ?: return@withLock ProtectionResult.KeyNotProtected(type, key) + + val nowMillis = now() + val watchers = protectionRepository.getSessions().filter { session -> + session.claims(type, key) && + session.status == ProtectionSessionStatus.ACTIVE && + pauseStateOf(session.pausedUntilMillis, nowMillis) == ProtectionPauseState.Running + } + // Every claimant is paused or mid-transition, so this change is not ours to undo. + if (watchers.isEmpty()) return@withLock ProtectionResult.Success(app = null) + + if (!secureSettingsWrapper.hasWriteSecureSettingsPermission()) { + return@withLock ProtectionResult.PermissionDenied() + } + + val failure = enforceKey(ledgerKey) + ?: return@withLock ProtectionResult.Success(watchers.first().asProtectedApp(nowMillis)) + + var result: ProtectionResult = ProtectionResult.Failure( + stage = ProtectionStage.REAPPLY, + failures = listOf(failure), + ) + watchers.forEach { session -> + result = markRecoveryRequired( + session = session, + stage = ProtectionStage.REAPPLY, + failures = listOf(failure), + ) + } + result + } + + /** Writes [key]'s enforced value if it has drifted. Returns the failure, or null on success. */ + private suspend fun enforceKey(key: ProtectedKey): ProtectionFailure? { + when (val readResult = secureSettingsWrapper.read(key.settingType, key.key)) { + is SettingReadResult.Success -> if (readResult.value == key.enforcedValue) return null + is SettingReadResult.Failure -> return readResult.asFailure(key.settingType, key.key) + } + + return when ( + val writeResult = secureSettingsWrapper.write( + settingType = key.settingType, + key = key.key, + value = key.enforcedValue, + ) + ) { + is SettingWriteResult.Success -> null + is SettingWriteResult.Failure -> writeResult.asFailure(key.settingType, key.key) + } + } + + /** Writes back and clears ledger rows nobody claims, left behind by a death mid-restore. */ + private suspend fun sweepOrphanKeys() { + val orphans = try { + protectionRepository.getOrphanKeys() + } catch (exception: CancellationException) { + throw exception + } catch (_: RuntimeException) { + return + } + if (orphans.isEmpty()) return + if (!secureSettingsWrapper.hasWriteSecureSettingsPermission()) return + + val restored = orphans.filter { key -> + secureSettingsWrapper.write( + settingType = key.settingType, + key = key.key, + value = key.originalValue, + ) is SettingWriteResult.Success + } + + if (restored.size == orphans.size) { + runCatching { protectionRepository.deleteOrphanKeys() } + } + } + + private suspend fun restoreLocked(session: ProtectionSession): ProtectionResult { + if (!secureSettingsWrapper.hasWriteSecureSettingsPermission()) { + return markRecoveryRequired( + session = session, + stage = ProtectionStage.RESTORE, + failures = listOf( + ProtectionFailure(reason = ProtectionFailureReason.PERMISSION_DENIED), + ), + ) + } + + val markedRestoring = try { + protectionRepository.updateStatus( + token = session.token, + status = ProtectionSessionStatus.RESTORING, + updatedAtMillis = now(), + ) + } catch (exception: CancellationException) { + throw exception + } catch (_: RuntimeException) { + false + } + if (!markedRestoring) { + return ProtectionResult.Failure( + stage = ProtectionStage.PERSIST, + failures = listOf( + ProtectionFailure( + reason = ProtectionFailureReason.PERSISTENCE_FAILED, + message = "Could not mark the protection session as restoring", + ), + ), + ) + } + + // Only the keys this session is the last claimant of. Anything another app still wants is + // left enforced. + val releasedKeys = try { + protectionRepository.getKeysReleasedBy(session.token) + } catch (exception: CancellationException) { + throw exception + } catch (exception: RuntimeException) { + return markRecoveryRequired( + session = session, + stage = ProtectionStage.PERSIST, + failures = listOf(exception.asPersistenceFailure()), + ) + } + + val failures = restoreKeys(releasedKeys.inSessionWriteOrder(session)) + if (failures.isNotEmpty()) { + return markRecoveryRequired(session, ProtectionStage.RESTORE, failures) + } + + // Originals are written before the rows go away. A death in between leaves the device already + // correct plus a sweepable orphan; the other order would destroy the original first. + val cleared = try { + protectionRepository.clearSession(session.token) + } catch (exception: CancellationException) { + throw exception + } catch (_: RuntimeException) { + false + } + if (!cleared) { + return markRecoveryRequired( + session = session, + stage = ProtectionStage.PERSIST, + failures = listOf( + ProtectionFailure( + reason = ProtectionFailureReason.PERSISTENCE_FAILED, + message = "Settings were restored but the session could not be cleared", + ), + ), + ) + } + + return ProtectionResult.Success(app = null) + } + + private suspend fun handleApplyFailure( + session: ProtectionSession, + attemptedNewKeys: List, + failure: ProtectionFailure, + ): ProtectionResult { + val rollbackFailures = restoreKeys(attemptedNewKeys) + if (rollbackFailures.isNotEmpty()) { + return markRecoveryRequired( + session = session, + stage = ProtectionStage.APPLY, + failures = listOf(failure) + rollbackFailures, + ) + } + + val cleared = try { + protectionRepository.clearSession(session.token) + } catch (exception: CancellationException) { + throw exception + } catch (_: RuntimeException) { + false + } + if (!cleared) { + return markRecoveryRequired( + session = session, + stage = ProtectionStage.PERSIST, + failures = listOf( + failure, + ProtectionFailure( + reason = ProtectionFailureReason.PERSISTENCE_FAILED, + message = "Rollback succeeded but the session could not be cleared", + ), + ), + ) + } + + return ProtectionResult.Failure( + stage = ProtectionStage.APPLY, + failures = listOf(failure), + rollbackSucceeded = true, + ) + } + + private suspend fun restoreKeys(keys: List): List { + val writeFailures = keys.asReversed().mapNotNull { key -> + when ( + val result = secureSettingsWrapper.write( + settingType = key.settingType, + key = key.key, + value = key.originalValue, + ) + ) { + is SettingWriteResult.Success -> null + is SettingWriteResult.Failure -> result.asFailure(key.settingType, key.key) + } + } + + return writeFailures + + verifyExpectedValues(keys.map { ExpectedValue(it.settingType, it.key, it.originalValue) }) + } + + private suspend fun verifyExpectedValues( + expected: List, + ): List = expected.mapNotNull { entry -> + when (val result = secureSettingsWrapper.read(entry.settingType, entry.key)) { + is SettingReadResult.Success -> if (result.value == entry.value) { + null + } else { + ProtectionFailure( + reason = ProtectionFailureReason.VERIFICATION_FAILED, + settingType = entry.settingType, + key = entry.key, + expectedValue = entry.value, + actualValue = result.value, + message = "The final settings bundle did not match the expected value", + ) + } + + is SettingReadResult.Failure -> result.asFailure(entry.settingType, entry.key) + } + } + + private suspend fun markRecoveryRequired( + session: ProtectionSession, + stage: ProtectionStage, + failures: List, + ): ProtectionResult { + val reason = failures.joinToString(separator = "; ") { failure -> + listOfNotNull(failure.key, failure.reason.name, failure.message).joinToString(": ") + } + val updatedAtMillis = now() + val marked = try { + protectionRepository.updateStatus( + token = session.token, + status = ProtectionSessionStatus.RECOVERY_REQUIRED, + updatedAtMillis = updatedAtMillis, + lastError = reason, + ) + } catch (exception: CancellationException) { + throw exception + } catch (_: RuntimeException) { + false + } + + if (!marked) { + return ProtectionResult.Failure( + stage = ProtectionStage.PERSIST, + failures = failures + ProtectionFailure( + reason = ProtectionFailureReason.PERSISTENCE_FAILED, + message = "Could not persist the recovery-required state after $stage", + ), + rollbackSucceeded = false, + ) + } + + return ProtectionResult.RecoveryRequired( + app = session.copy( + status = ProtectionSessionStatus.RECOVERY_REQUIRED, + updatedAtMillis = updatedAtMillis, + lastError = reason, + ).asProtectedApp(updatedAtMillis), + failures = failures, + ) + } + + private fun validate(settings: List): List { + val failures = settings.filter { it.key.isBlank() }.map { setting -> + ProtectionFailure( + reason = ProtectionFailureReason.INVALID_KEY, + settingType = setting.settingType, + key = setting.key, + message = "Setting key must not be blank", + ) + }.toMutableList() + + settings.groupBy { it.settingType to it.key } + .filterValues { duplicates -> duplicates.size > 1 } + .forEach { (identity, duplicates) -> + failures += ProtectionFailure( + reason = ProtectionFailureReason.DUPLICATE_KEY, + settingType = identity.first, + key = identity.second, + message = if (duplicates.map { it.valueOnLaunch }.distinct().size > 1) { + "Duplicate key has conflicting protected values" + } else { + "Duplicate key must be removed from the profile" + }, + ) + } + + return failures + } + + private fun SettingReadResult.Failure.asFailure( + settingType: SettingType, + key: String, + ): ProtectionFailure = ProtectionFailure( + reason = reason, + settingType = settingType, + key = key, + message = message, + ) + + private fun SettingWriteResult.Failure.asFailure( + settingType: SettingType, + key: String, + ): ProtectionFailure = ProtectionFailure( + reason = reason, + settingType = settingType, + key = key, + expectedValue = expectedValue, + actualValue = actualValue, + message = message, + ) + + private fun RuntimeException.asPersistenceFailure(): ProtectionFailure = ProtectionFailure( + reason = ProtectionFailureReason.PERSISTENCE_FAILED, + message = message, + ) + + private fun ProtectedSetting.expected(value: String?): ExpectedValue = ExpectedValue(settingType, key, value) + + /** Restores unwind in the reverse of the order the values were applied in. */ + private fun List.inSessionWriteOrder(session: ProtectionSession): List { + val orderByIdentity = session.protectedSettings.associate { + (it.settingType to it.key) to it.writeOrder + } + return sortedBy { orderByIdentity[it.settingType to it.key] ?: Int.MAX_VALUE } + } + + /** + * Puts gate settings last. + * + * `adb_enabled` and `adb_wifi_enabled` are only writable while developer options is on, so turning + * the gate off first made the rest of the profile fail. Restores walk this list in reverse, which + * means the gate comes back on first and the dependent keys are writable again — that reversal is + * what produced Samsung's "turn on Developer options first" rejection. + * + * Ordering here rather than in the template so profiles that already exist are fixed too. + */ + private fun List.inDependencyOrder(): List = sortedBy { setting -> + if (setting.key in GATE_KEYS) 1 else 0 + } + + private fun ProtectionSession.satisfies( + requestedComponentName: String, + requestedMode: ProtectionMode, + ): Boolean { + if (status != ProtectionSessionStatus.ACTIVE || componentName != requestedComponentName) { + return false + } + // Foreground and one-shot are interchangeable in both directions. The keys are already + // claimed and the values already written, so whoever got here first owns the session and the + // other path adopts it. Accepting only foreground -> one-shot meant launching an armed app + // from Geto created a one-shot session that the coordinator then refused to adopt, leaving + // that app stuck in RECOVERY_REQUIRED until its row was cleared. + return mode == requestedMode || + (mode in INTERCHANGEABLE_MODES && requestedMode in INTERCHANGEABLE_MODES) + } + + private companion object { + /** + * Settings that other settings depend on. Writing a dependent while its gate is off is + * rejected by the platform. + */ + val GATE_KEYS = setOf("development_settings_enabled") + + /** Modes that describe the same applied state and may therefore adopt each other's session. */ + val INTERCHANGEABLE_MODES = setOf(ProtectionMode.FOREGROUND, ProtectionMode.ONE_SHOT) + } + + private data class ExpectedValue( + val settingType: SettingType, + val key: String, + val value: String?, + ) +} + +/** Convenience for callers that hold a [ProtectedApp] rather than a raw token. */ +suspend fun ProtectionController.restore(app: ProtectedApp): ProtectionResult = restore(app.sessionToken) diff --git a/domain/use-case/src/main/kotlin/com/android/geto/domain/usecase/RevertAppSettingsUseCase.kt b/domain/use-case/src/main/kotlin/com/android/geto/domain/usecase/RevertAppSettingsUseCase.kt index 6973724de..e6f670f3f 100644 --- a/domain/use-case/src/main/kotlin/com/android/geto/domain/usecase/RevertAppSettingsUseCase.kt +++ b/domain/use-case/src/main/kotlin/com/android/geto/domain/usecase/RevertAppSettingsUseCase.kt @@ -19,43 +19,36 @@ package com.android.geto.domain.usecase import com.android.geto.domain.common.dispatcher.Dispatcher import com.android.geto.domain.common.dispatcher.GetoDispatchers -import com.android.geto.domain.framework.SecureSettingsWrapper import com.android.geto.domain.model.AppSettingsResult -import com.android.geto.domain.repository.AppSettingsRepository +import com.android.geto.domain.model.ProtectionResult import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import javax.inject.Inject class RevertAppSettingsUseCase @Inject constructor( - private val appSettingsRepository: AppSettingsRepository, - private val secureSettingsWrapper: SecureSettingsWrapper, + private val protectionController: ProtectionController, @param:Dispatcher(GetoDispatchers.Default) private val defaultDispatcher: CoroutineDispatcher, ) { suspend operator fun invoke(componentName: String): AppSettingsResult = withContext(defaultDispatcher) { - val appSettings = - appSettingsRepository.getAppSettingsByComponentName(componentName = componentName) - - if (appSettings.isEmpty()) return@withContext AppSettingsResult.EmptyAppSettings - - if (appSettings.all { !it.enabled }) return@withContext AppSettingsResult.DisabledAppSettings - - try { - if (appSettings.all { - secureSettingsWrapper.canWriteSecureSettings( - settingType = it.settingType, - key = it.key, - value = it.valueOnRevert, - ) - } - ) { - AppSettingsResult.Success - } else { - AppSettingsResult.Failure - } - } catch (_: SecurityException) { - AppSettingsResult.NoPermission - } catch (_: IllegalArgumentException) { - AppSettingsResult.InvalidValues + when (protectionController.restoreLegacyProfile(componentName)) { + is ProtectionResult.Success -> AppSettingsResult.Success + + ProtectionResult.NoActiveProtection -> AppSettingsResult.EmptyAppSettings + + is ProtectionResult.PermissionDenied -> AppSettingsResult.NoPermission + + is ProtectionResult.InvalidProfile -> AppSettingsResult.InvalidValues + + ProtectionResult.EmptyProfile -> AppSettingsResult.EmptyAppSettings + + ProtectionResult.NoEnabledSettings -> AppSettingsResult.DisabledAppSettings + + is ProtectionResult.Failure, + is ProtectionResult.KeyConflict, + is ProtectionResult.KeyNotProtected, + is ProtectionResult.StaleSession, + is ProtectionResult.RecoveryRequired, + -> AppSettingsResult.Failure } } } diff --git a/domain/use-case/src/main/kotlin/com/android/geto/domain/usecase/ShizukuPermissionController.kt b/domain/use-case/src/main/kotlin/com/android/geto/domain/usecase/ShizukuPermissionController.kt new file mode 100644 index 000000000..7a8dfebe5 --- /dev/null +++ b/domain/use-case/src/main/kotlin/com/android/geto/domain/usecase/ShizukuPermissionController.kt @@ -0,0 +1,76 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.domain.usecase + +import com.android.geto.domain.framework.SecureSettingsWrapper +import com.android.geto.domain.framework.ShizukuWrapper +import com.android.geto.domain.model.ShizukuGrantResult +import com.android.geto.domain.model.ShizukuState +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Drives the Shizuku route to `WRITE_SECURE_SETTINGS`, and is the only place that decides whether a + * grant really landed. + * + * Shizuku is a one-time granter here: once the permission is held, everything else in Geto goes + * through [SecureSettingsWrapper] as usual and Shizuku is no longer involved. + */ +@Singleton +class ShizukuPermissionController @Inject constructor( + private val shizukuWrapper: ShizukuWrapper, + private val secureSettingsWrapper: SecureSettingsWrapper, +) { + val shizukuState: Flow = shizukuWrapper.state + + fun hasWriteSecureSettingsPermission(): Boolean = secureSettingsWrapper.hasWriteSecureSettingsPermission() + + fun start() { + shizukuWrapper.start() + } + + fun stop() { + shizukuWrapper.stop() + } + + fun refresh() { + shizukuWrapper.refresh() + } + + /** + * Grants `WRITE_SECURE_SETTINGS` through Shizuku and verifies the outcome against the platform. + * + * A successful binder call is not proof of anything, so success is only reported once the + * permission actually reads back as granted. When it does not, the grant landed but this + * process cannot see it yet, which is a restart rather than a failure. + */ + suspend fun grant(): ShizukuGrantResult { + if (hasWriteSecureSettingsPermission()) return ShizukuGrantResult.Success + + return when (val result = shizukuWrapper.grantWriteSecureSettings()) { + ShizukuGrantResult.Success -> if (hasWriteSecureSettingsPermission()) { + ShizukuGrantResult.Success + } else { + ShizukuGrantResult.RequiresRestart + } + + else -> result + } + } +} diff --git a/domain/use-case/src/test/kotlin/com/android/geto/domain/usecase/GetLauncherAppsActivityInfosUseCaseTest.kt b/domain/use-case/src/test/kotlin/com/android/geto/domain/usecase/GetLauncherAppsActivityInfosUseCaseTest.kt new file mode 100644 index 000000000..581fbc905 --- /dev/null +++ b/domain/use-case/src/test/kotlin/com/android/geto/domain/usecase/GetLauncherAppsActivityInfosUseCaseTest.kt @@ -0,0 +1,173 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.domain.usecase + +import com.android.geto.domain.framework.LauncherAppsWrapper +import com.android.geto.domain.model.GrantMethod +import com.android.geto.domain.model.LauncherAppsActivityInfo +import com.android.geto.domain.model.SortLauncherAppsActivityInfo +import com.android.geto.domain.model.SortOrderLauncherAppsActivityInfo +import com.android.geto.domain.model.Theme +import com.android.geto.domain.model.UserData +import com.android.geto.domain.repository.UserDataRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame + +class GetLauncherAppsActivityInfosUseCaseTest { + private val defaultUserData = UserData( + theme = Theme.FOLLOW_SYSTEM, + dynamicTheme = true, + sortLauncherAppsActivityInfo = SortLauncherAppsActivityInfo.Name, + sortOrderLauncherAppsActivityInfo = SortOrderLauncherAppsActivityInfo.Ascending, + showSystem = false, + autoRestartProtection = false, + ) + + @Test + fun filtersSystemAppsAndUsesComponentAsStableNameTieBreaker() = runTest { + val launcherApps = TestLauncherAppsWrapper( + Result.success( + listOf( + activity("pkg.z/.Main", label = "Same"), + activity("system/.Main", label = "First", isSystem = true), + activity("pkg.a/.Main", label = "Same"), + ), + ), + ) + + val result = useCase(launcherApps, defaultUserData)().first().getOrThrow() + + assertEquals( + listOf("pkg.a/.Main", "pkg.z/.Main"), + result.launcherAppsActivityInfos.map { it.componentName }, + ) + } + + @Test + fun sortsUpdateTimeDescendingAndRetainsSystemAppsWhenEnabled() = runTest { + val launcherApps = TestLauncherAppsWrapper( + Result.success( + listOf( + activity("old/.Main", label = "Old", lastUpdateTime = 1), + activity("new/.Main", label = "New", lastUpdateTime = 3, isSystem = true), + activity("middle/.Main", label = "Middle", lastUpdateTime = 2), + ), + ), + ) + val userData = defaultUserData.copy( + sortLauncherAppsActivityInfo = SortLauncherAppsActivityInfo.UpdateTime, + sortOrderLauncherAppsActivityInfo = SortOrderLauncherAppsActivityInfo.Descending, + showSystem = true, + ) + + val result = useCase(launcherApps, userData)().first().getOrThrow() + + assertEquals( + listOf("new/.Main", "middle/.Main", "old/.Main"), + result.launcherAppsActivityInfos.map { it.componentName }, + ) + } + + @Test + fun forwardsLoadFailuresAndDelegatesRetry() = runTest { + val failure = IllegalStateException("launcher unavailable") + val launcherApps = TestLauncherAppsWrapper(Result.failure(failure)) + val useCase = useCase(launcherApps, defaultUserData) + + val result = useCase().first() + useCase.refresh() + + assertSame(failure, result.exceptionOrNull()) + assertEquals(1, launcherApps.refreshCount) + } + + private fun useCase( + launcherAppsWrapper: LauncherAppsWrapper, + userData: UserData, + ) = GetLauncherAppsActivityInfosUseCase( + launcherAppsWrapper = launcherAppsWrapper, + userDataRepository = TestUserDataRepository(userData), + defaultDispatcher = Dispatchers.Unconfined, + ) + + private fun activity( + componentName: String, + label: String, + lastUpdateTime: Long = 0, + isSystem: Boolean = false, + ) = LauncherAppsActivityInfo( + componentName = componentName, + packageName = componentName.substringBefore('/'), + activityLabel = label, + firstInstallTime = 0, + lastUpdateTime = lastUpdateTime, + isSystem = isSystem, + ) +} + +private class TestLauncherAppsWrapper( + initialResult: Result>, +) : LauncherAppsWrapper { + private val results = MutableStateFlow(initialResult) + var refreshCount = 0 + private set + + override fun getActivityListFlow(): Flow>> = results + + override fun refresh() { + refreshCount++ + } +} + +private class TestUserDataRepository( + initialUserData: UserData, +) : UserDataRepository { + override val userData = MutableStateFlow(initialUserData) + override val preferencesWereReset: Flow = flowOf(false) + + override suspend fun updateTheme(theme: Theme) = Unit + + override suspend fun updateDynamicTheme(dynamicTheme: Boolean) = Unit + + override suspend fun updateSortLauncherAppsActivityInfo( + sortLauncherAppsActivityInfo: SortLauncherAppsActivityInfo, + ) = Unit + + override suspend fun updateSortOrderLauncherAppsActivityInfo( + sortOrderLauncherAppsActivityInfo: SortOrderLauncherAppsActivityInfo, + ) = Unit + + override suspend fun updateShowSystem(showSystem: Boolean) = Unit + + override suspend fun updateAutoRestartProtection(autoRestartProtection: Boolean) = Unit + + override suspend fun updateGrantMethod(grantMethod: GrantMethod) = Unit + + override suspend fun updateProtectionPausedUntil(protectionPausedUntilMillis: Long) = Unit + + override suspend fun resetUserPreferences() = Unit + + override fun acknowledgePreferencesReset() = Unit +} diff --git a/domain/use-case/src/test/kotlin/com/android/geto/domain/usecase/ProtectionControllerTest.kt b/domain/use-case/src/test/kotlin/com/android/geto/domain/usecase/ProtectionControllerTest.kt new file mode 100644 index 000000000..4025a2dc3 --- /dev/null +++ b/domain/use-case/src/test/kotlin/com/android/geto/domain/usecase/ProtectionControllerTest.kt @@ -0,0 +1,652 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.domain.usecase + +import com.android.geto.domain.framework.SecureSettingsWrapper +import com.android.geto.domain.model.AppSetting +import com.android.geto.domain.model.PAUSE_INDEFINITE +import com.android.geto.domain.model.ProtectedKey +import com.android.geto.domain.model.ProtectionMode +import com.android.geto.domain.model.ProtectionPauseDuration +import com.android.geto.domain.model.ProtectionResult +import com.android.geto.domain.model.ProtectionSession +import com.android.geto.domain.model.ProtectionSessionStatus +import com.android.geto.domain.model.SecureSetting +import com.android.geto.domain.model.SettingReadResult +import com.android.geto.domain.model.SettingType +import com.android.geto.domain.model.SettingWriteResult +import com.android.geto.domain.repository.AppSettingsRepository +import com.android.geto.domain.repository.ProtectionRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * These tests exist to protect one rule: a key's original value is captured on the first claim and + * written back only when the last claim goes away. Everything about multi-app protection depends on + * it, and getting it wrong silently destroys the user's real settings. + */ +class ProtectionControllerTest { + + private val devOptions = AppSetting( + id = 1, + enabled = true, + settingType = SettingType.GLOBAL, + componentName = BANK_A, + label = "Developer options", + key = DEV_OPTIONS, + valueOnLaunch = "0", + valueOnRevert = "1", + ) + + @Test + fun enable_foregroundAfterOneShotForSameApp_adoptsTheSessionInsteadOfWedgingIt() = runTest { + val settings = FakeSecureSettingsWrapper(mapOf(GLOBAL_DEV to "1")) + val controller = controller( + appSettings = mapOf(BANK_A to listOf(devOptions)), + secureSettings = settings, + ) + + // Launching an armed app from Geto applies it, and the coordinator then sees it come to the + // foreground and asks for the same app again. That second call used to be rejected. + controller.enable(BANK_A, ProtectionMode.ONE_SHOT) + val result = controller.enable(BANK_A, ProtectionMode.FOREGROUND) + + assertIs(result) + assertEquals(ProtectionSessionStatus.ACTIVE, result.app?.status) + assertEquals("0", settings.values[GLOBAL_DEV]) + } + + @Test + fun enable_capturesTheOriginalValueAndAppliesTheProfile() = runTest { + val settings = FakeSecureSettingsWrapper(mapOf(GLOBAL_DEV to "1")) + val controller = controller( + appSettings = mapOf(BANK_A to listOf(devOptions)), + secureSettings = settings, + ) + + val result = controller.enable(BANK_A, ProtectionMode.FOREGROUND) + + assertIs(result) + assertEquals(ProtectionSessionStatus.ACTIVE, result.app?.status) + assertEquals("0", settings.values[GLOBAL_DEV]) + } + + @Test + fun enable_secondAppSameKeySameValue_keepsTheFirstOriginalAndWritesNothingNew() = runTest { + val settings = FakeSecureSettingsWrapper(mapOf(GLOBAL_DEV to "1")) + val repository = FakeProtectionRepository() + val controller = controller( + appSettings = mapOf( + BANK_A to listOf(devOptions), + BANK_B to listOf(devOptions.copy(id = 2, componentName = BANK_B)), + ), + secureSettings = settings, + repository = repository, + ) + + controller.enable(BANK_A, ProtectionMode.FOREGROUND) + val writesAfterFirst = settings.writes.size + + val result = controller.enable(BANK_B, ProtectionMode.FOREGROUND) + + assertIs(result) + // The ledger must still hold the value from before Geto ever touched the device. + assertEquals("1", repository.ledger.getValue(GLOBAL_DEV).originalValue) + assertEquals(2, repository.getProtectedKeys().single().claimCount) + // The value was already enforced, so the second enable adds exactly one idempotent write. + assertEquals(writesAfterFirst + 1, settings.writes.size) + } + + @Test + fun restore_whileAnotherAppStillClaimsTheKey_leavesItEnforced() = runTest { + val settings = FakeSecureSettingsWrapper(mapOf(GLOBAL_DEV to "1")) + val repository = FakeProtectionRepository() + val controller = controller( + appSettings = mapOf( + BANK_A to listOf(devOptions), + BANK_B to listOf(devOptions.copy(id = 2, componentName = BANK_B)), + ), + secureSettings = settings, + repository = repository, + ) + val first = controller.enable(BANK_A, ProtectionMode.FOREGROUND) as ProtectionResult.Success + controller.enable(BANK_B, ProtectionMode.FOREGROUND) + + val result = controller.restore(first.app!!.sessionToken) + + assertIs(result) + assertEquals("0", settings.values[GLOBAL_DEV], "the other app still needs this value") + assertEquals(1, repository.getProtectedKeys().single().claimCount) + } + + @Test + fun restore_byTheLastClaimant_writesTheTrueOriginal() = runTest { + val settings = FakeSecureSettingsWrapper(mapOf(GLOBAL_DEV to "1")) + val repository = FakeProtectionRepository() + val controller = controller( + appSettings = mapOf( + BANK_A to listOf(devOptions), + BANK_B to listOf(devOptions.copy(id = 2, componentName = BANK_B)), + ), + secureSettings = settings, + repository = repository, + ) + val first = controller.enable(BANK_A, ProtectionMode.FOREGROUND) as ProtectionResult.Success + val second = controller.enable(BANK_B, ProtectionMode.FOREGROUND) as ProtectionResult.Success + + controller.restore(first.app!!.sessionToken) + controller.restore(second.app!!.sessionToken) + + assertEquals("1", settings.values[GLOBAL_DEV]) + assertTrue(repository.ledger.isEmpty(), "the ledger row should be swept with the last claim") + } + + @Test + fun enable_sameKeyDifferentValue_isRejectedWithoutWritingAnything() = runTest { + val settings = FakeSecureSettingsWrapper(mapOf(GLOBAL_DEV to "1")) + val controller = controller( + appSettings = mapOf( + BANK_A to listOf(devOptions), + // Same key, contradictory value. + BANK_B to listOf(devOptions.copy(id = 2, componentName = BANK_B, valueOnLaunch = "1")), + ), + secureSettings = settings, + ) + controller.enable(BANK_A, ProtectionMode.FOREGROUND) + val writesBefore = settings.writes.size + + val result = controller.enable(BANK_B, ProtectionMode.FOREGROUND) + + val conflict = assertIs(result) + assertEquals(DEV_OPTIONS, conflict.key) + assertEquals("0", conflict.enforcedValue) + assertEquals("1", conflict.requestedValue) + assertEquals(listOf(BANK_A), conflict.holderComponentNames) + assertEquals(writesBefore, settings.writes.size, "a rejected enable must touch nothing") + assertEquals("0", settings.values[GLOBAL_DEV]) + } + + @Test + fun enable_whenAWriteFails_rollsBackOnlyItsOwnNewlyClaimedKeys() = runTest { + val settings = FakeSecureSettingsWrapper( + initialValues = mapOf(GLOBAL_DEV to "1", SECURE_OTHER to "on"), + failOnWriteNumber = 3, + ) + val repository = FakeProtectionRepository() + val shared = devOptions + val extra = AppSetting( + id = 3, + enabled = true, + settingType = SettingType.SECURE, + componentName = BANK_B, + label = "Other", + key = OTHER_KEY, + valueOnLaunch = "off", + valueOnRevert = "on", + ) + val controller = controller( + appSettings = mapOf( + BANK_A to listOf(shared), + BANK_B to listOf(shared.copy(id = 2, componentName = BANK_B), extra), + ), + secureSettings = settings, + repository = repository, + ) + controller.enable(BANK_A, ProtectionMode.FOREGROUND) + + val result = controller.enable(BANK_B, ProtectionMode.FOREGROUND) + + assertIs(result) + // The shared key belongs to bank A too, so rolling it back would have broken A. + assertEquals("0", settings.values[GLOBAL_DEV]) + assertEquals("on", settings.values[SECURE_OTHER], "its own new key is rolled back") + assertNull(repository.sessionByComponent(BANK_B)) + } + + @Test + fun reapply_whenEveryClaimantIsPaused_doesNotWrite() = runTest { + val settings = FakeSecureSettingsWrapper(mapOf(GLOBAL_DEV to "1")) + val controller = controller( + appSettings = mapOf(BANK_A to listOf(devOptions)), + secureSettings = settings, + ) + val enabled = controller.enable(BANK_A, ProtectionMode.FOREGROUND) as ProtectionResult.Success + controller.pause(enabled.app!!.sessionToken, ProtectionPauseDuration.ONE_HOUR) + settings.values[GLOBAL_DEV] = "1" + val writesBefore = settings.writes.size + + controller.reapply(SettingType.GLOBAL, DEV_OPTIONS) + + assertEquals("1", settings.values[GLOBAL_DEV], "a paused app must not repair drift") + assertEquals(writesBefore, settings.writes.size) + } + + @Test + fun reapply_whenOneClaimantIsStillRunning_repairsTheKey() = runTest { + val settings = FakeSecureSettingsWrapper(mapOf(GLOBAL_DEV to "1")) + val controller = controller( + appSettings = mapOf( + BANK_A to listOf(devOptions), + BANK_B to listOf(devOptions.copy(id = 2, componentName = BANK_B)), + ), + secureSettings = settings, + ) + val first = controller.enable(BANK_A, ProtectionMode.FOREGROUND) as ProtectionResult.Success + controller.enable(BANK_B, ProtectionMode.FOREGROUND) + controller.pause(first.app!!.sessionToken, ProtectionPauseDuration.ONE_HOUR) + settings.values[GLOBAL_DEV] = "1" + + controller.reapply(SettingType.GLOBAL, DEV_OPTIONS) + + assertEquals("0", settings.values[GLOBAL_DEV]) + } + + @Test + fun pause_appliesToOneAppOnly() = runTest { + val repository = FakeProtectionRepository() + val controller = controller( + appSettings = mapOf( + BANK_A to listOf(devOptions), + BANK_B to listOf(devOptions.copy(id = 2, componentName = BANK_B)), + ), + secureSettings = FakeSecureSettingsWrapper(mapOf(GLOBAL_DEV to "1")), + repository = repository, + ) + val first = controller.enable(BANK_A, ProtectionMode.FOREGROUND) as ProtectionResult.Success + controller.enable(BANK_B, ProtectionMode.FOREGROUND) + + controller.pause(first.app!!.sessionToken, ProtectionPauseDuration.INDEFINITE) + + assertEquals(PAUSE_INDEFINITE, repository.sessionByComponent(BANK_A)?.pausedUntilMillis) + assertEquals(0L, repository.sessionByComponent(BANK_B)?.pausedUntilMillis) + } + + /** Resuming must repair drift, or protection would claim to be active while a value is wrong. */ + @Test + fun resume_repairsWhateverDriftedWhileItWasPaused() = runTest { + val settings = FakeSecureSettingsWrapper(mapOf(GLOBAL_DEV to "1")) + val controller = controller( + appSettings = mapOf(BANK_A to listOf(devOptions)), + secureSettings = settings, + ) + val enabled = controller.enable(BANK_A, ProtectionMode.FOREGROUND) as ProtectionResult.Success + controller.pause(enabled.app!!.sessionToken, ProtectionPauseDuration.ONE_HOUR) + settings.values[GLOBAL_DEV] = "1" + + controller.resume(enabled.app!!.sessionToken) + + assertEquals("0", settings.values[GLOBAL_DEV]) + } + + @Test + fun reconcile_sweepsALedgerRowLeftByAnInterruptedRestore() = runTest { + val settings = FakeSecureSettingsWrapper(mapOf(GLOBAL_DEV to "0")) + val repository = FakeProtectionRepository() + // A crash between writing the original back and deleting the rows leaves exactly this. + repository.ledger[GLOBAL_DEV] = ProtectedKey( + settingType = SettingType.GLOBAL, + key = DEV_OPTIONS, + originalValue = "1", + enforcedValue = "0", + claimedAtMillis = 0, + ) + val controller = controller( + appSettings = emptyMap(), + secureSettings = settings, + repository = repository, + ) + + controller.reconcile() + + assertEquals("1", settings.values[GLOBAL_DEV]) + assertTrue(repository.ledger.isEmpty()) + } + + /** + * The regression that stranded the user: a profile that stays applied disables developer options + * system-wide and takes ADB and Shizuku with it. Arming must write nothing on its own. + */ + @Test + fun armProfile_writesNothingUntilTheAppIsActuallyOpened() = runTest { + val settings = FakeSecureSettingsWrapper(mapOf(GLOBAL_DEV to "1")) + val controller = controller( + appSettings = mapOf(BANK_A to listOf(devOptions)), + secureSettings = settings, + ) + + controller.armProfile(BANK_A) + + assertTrue(settings.writes.isEmpty()) + assertEquals("1", settings.values[GLOBAL_DEV], "developer options must stay on") + } + + @Test + fun restoreApplied_putsTheOriginalBackWithoutDisarming() = runTest { + val settings = FakeSecureSettingsWrapper(mapOf(GLOBAL_DEV to "1")) + val repository = FakeProtectionRepository() + val controller = controller( + appSettings = mapOf(BANK_A to listOf(devOptions)), + secureSettings = settings, + repository = repository, + ) + controller.armProfile(BANK_A) + controller.enable(BANK_A, ProtectionMode.FOREGROUND) + assertEquals("0", settings.values[GLOBAL_DEV]) + + controller.restoreApplied(BANK_A) + + assertEquals("1", settings.values[GLOBAL_DEV], "leaving the app restores the original") + assertEquals( + setOf(BANK_A), + repository.getArmedComponentNames(), + "still armed for next time", + ) + } + + /** Nothing may outlive the process that applied it — this is the anti-stranding guarantee. */ + @Test + fun restoreEverythingApplied_clearsAnythingLeftFromAPreviousRun() = runTest { + val settings = FakeSecureSettingsWrapper(mapOf(GLOBAL_DEV to "1")) + val controller = controller( + appSettings = mapOf(BANK_A to listOf(devOptions)), + secureSettings = settings, + ) + controller.enable(BANK_A, ProtectionMode.FOREGROUND) + assertEquals("0", settings.values[GLOBAL_DEV]) + + controller.restoreEverythingApplied() + + assertEquals("1", settings.values[GLOBAL_DEV]) + } + + @Test + fun disarmProfile_restoresWhenTheProfileIsCurrentlyApplied() = runTest { + val settings = FakeSecureSettingsWrapper(mapOf(GLOBAL_DEV to "1")) + val repository = FakeProtectionRepository() + val controller = controller( + appSettings = mapOf(BANK_A to listOf(devOptions)), + secureSettings = settings, + repository = repository, + ) + controller.armProfile(BANK_A) + controller.enable(BANK_A, ProtectionMode.FOREGROUND) + + controller.disarmProfile(BANK_A) + + assertEquals("1", settings.values[GLOBAL_DEV]) + assertTrue(repository.getArmedComponentNames().isEmpty()) + } + + /** + * `adb_enabled` is only writable while developer options is on. Writing the gate off first made + * the rest of the profile fail, and the reversed restore then tried to switch the dependants back + * on while the gate was still off — which is what produced Samsung's "turn on Developer options + * first" rejection. + */ + @Test + fun enable_writesTheGateSettingLast_soDependentKeysAreStillWritable() = runTest { + val settings = FakeSecureSettingsWrapper( + mapOf(GLOBAL_DEV to "1", GLOBAL_ADB to "1"), + ) + val adb = devOptions.copy(id = 9, key = ADB_ENABLED, valueOnLaunch = "0") + val controller = controller( + // Template order puts the gate first; the controller must reorder it. + appSettings = mapOf(BANK_A to listOf(devOptions, adb)), + secureSettings = settings, + ) + + controller.enable(BANK_A, ProtectionMode.FOREGROUND) + + val written = settings.writes.map { it.second } + assertEquals( + listOf(ADB_ENABLED, DEV_OPTIONS), + written, + "the gate must be written after the keys that depend on it", + ) + } + + @Test + fun restore_writesTheGateSettingFirst_soDependentKeysAreStillWritable() = runTest { + val settings = FakeSecureSettingsWrapper( + mapOf(GLOBAL_DEV to "1", GLOBAL_ADB to "1"), + ) + val adb = devOptions.copy(id = 9, key = ADB_ENABLED, valueOnLaunch = "0") + val controller = controller( + appSettings = mapOf(BANK_A to listOf(devOptions, adb)), + secureSettings = settings, + ) + val enabled = controller.enable(BANK_A, ProtectionMode.FOREGROUND) as ProtectionResult.Success + settings.writes.clear() + + controller.restore(enabled.app!!.sessionToken) + + val written = settings.writes.map { it.second } + assertEquals( + listOf(DEV_OPTIONS, ADB_ENABLED), + written, + "the gate must be restored before the keys that depend on it", + ) + assertEquals("1", settings.values[GLOBAL_DEV]) + assertEquals("1", settings.values[GLOBAL_ADB]) + } + + @Test + fun enable_withoutPermission_isRejected() = runTest { + val controller = controller( + appSettings = mapOf(BANK_A to listOf(devOptions)), + secureSettings = FakeSecureSettingsWrapper( + initialValues = mapOf(GLOBAL_DEV to "1"), + hasPermission = false, + ), + ) + + assertIs( + controller.enable(BANK_A, ProtectionMode.FOREGROUND), + ) + } + + private fun controller( + appSettings: Map>, + secureSettings: FakeSecureSettingsWrapper, + repository: FakeProtectionRepository = FakeProtectionRepository(), + ) = ProtectionController( + appSettingsRepository = FakeAppSettingsRepository(appSettings), + protectionRepository = repository, + secureSettingsWrapper = secureSettings, + ).apply { now = { CLOCK } } + + private class FakeAppSettingsRepository( + private val byComponentName: Map>, + ) : AppSettingsRepository { + override val appSettingsFlow: Flow> = + flowOf(byComponentName.values.flatten()) + + override suspend fun upsertAppSetting(appSetting: AppSetting) = Unit + + override suspend fun upsertAppSettings(appSettings: List) = Unit + + override suspend fun deleteAppSetting(appSetting: AppSetting) = Unit + + override fun getAppSettingsFlowByComponentName(componentName: String): Flow> = flowOf(byComponentName[componentName].orEmpty()) + + override suspend fun getAppSettingsByComponentName(componentName: String): List = byComponentName[componentName].orEmpty() + } + + /** In-memory stand-in modelling the ledger and the claim rows the same way Room does. */ + private class FakeProtectionRepository : ProtectionRepository { + private val sessions = MutableStateFlow>(emptyList()) + private val armed = MutableStateFlow>(emptySet()) + val ledger = mutableMapOf, ProtectedKey>() + + override val armedComponentNamesFlow: Flow> = armed + + override suspend fun getArmedComponentNames(): Set = armed.value + + override suspend fun armProfile(componentName: String, armedAtMillis: Long) { + armed.value = armed.value + componentName + } + + override suspend fun disarmProfile(componentName: String): Boolean { + if (componentName !in armed.value) return false + armed.value = armed.value - componentName + return true + } + + override val sessionsFlow: Flow> = sessions + + fun sessionByComponent(componentName: String): ProtectionSession? = sessions.value.firstOrNull { it.componentName == componentName } + + private fun claimCount(settingType: SettingType, key: String): Int = sessions.value.count { session -> + session.protectedSettings.any { it.settingType == settingType && it.key == key } + } + + override suspend fun getSessions(): List = sessions.value + + override suspend fun getSessionByToken(token: String): ProtectionSession? = sessions.value.firstOrNull { it.token == token } + + override suspend fun getSessionByComponentName(componentName: String): ProtectionSession? = sessionByComponent(componentName) + + override suspend fun getProtectedKeys(): List = ledger.values.map { it.copy(claimCount = claimCount(it.settingType, it.key)) } + + override suspend fun getProtectedKey( + settingType: SettingType, + key: String, + ): ProtectedKey? = ledger[settingType to key] + + override suspend fun getClaimantComponentNames( + settingType: SettingType, + key: String, + ): List = sessions.value + .filter { session -> + session.protectedSettings.any { it.settingType == settingType && it.key == key } + } + .map { it.componentName } + + override suspend fun getKeysReleasedBy(token: String): List { + val session = getSessionByToken(token) ?: return emptyList() + return session.protectedSettings + .filter { claimCount(it.settingType, it.key) == 1 } + .mapNotNull { ledger[it.settingType to it.key] } + } + + override suspend fun getOrphanKeys(): List = ledger.values.filter { claimCount(it.settingType, it.key) == 0 } + + override suspend fun deleteOrphanKeys(): Int { + val orphans = ledger.filterValues { claimCount(it.settingType, it.key) == 0 } + orphans.keys.forEach(ledger::remove) + return orphans.size + } + + override suspend fun createSession( + session: ProtectionSession, + newKeys: List, + ) { + newKeys.forEach { ledger.putIfAbsent(it.settingType to it.key, it) } + sessions.value = sessions.value + session + } + + override suspend fun updateStatus( + token: String, + status: ProtectionSessionStatus, + updatedAtMillis: Long, + lastError: String?, + ): Boolean = update(token) { + it.copy(status = status, updatedAtMillis = updatedAtMillis, lastError = lastError) + } + + override suspend fun updatePausedUntil( + token: String, + pausedUntilMillis: Long, + updatedAtMillis: Long, + ): Boolean = update(token) { + it.copy(pausedUntilMillis = pausedUntilMillis, updatedAtMillis = updatedAtMillis) + } + + override suspend fun clearSession(token: String): Boolean { + if (sessions.value.none { it.token == token }) return false + sessions.value = sessions.value.filterNot { it.token == token } + deleteOrphanKeys() + return true + } + + private fun update( + token: String, + transform: (ProtectionSession) -> ProtectionSession, + ): Boolean { + if (sessions.value.none { it.token == token }) return false + sessions.value = sessions.value.map { if (it.token == token) transform(it) else it } + return true + } + } + + private class FakeSecureSettingsWrapper( + initialValues: Map, String?> = emptyMap(), + private val failOnWriteNumber: Int? = null, + private val hasPermission: Boolean = true, + ) : SecureSettingsWrapper { + val values = initialValues.toMutableMap() + val writes = mutableListOf>() + private var writeCount = 0 + + override fun hasWriteSecureSettingsPermission(): Boolean = hasPermission + + override suspend fun read( + settingType: SettingType, + key: String, + ): SettingReadResult = SettingReadResult.Success(values[settingType to key]) + + override suspend fun write( + settingType: SettingType, + key: String, + value: String?, + ): SettingWriteResult { + writeCount++ + writes += settingType to key + if (writeCount == failOnWriteNumber) { + return SettingWriteResult.Failure( + reason = com.android.geto.domain.model.ProtectionFailureReason.WRITE_REJECTED, + expectedValue = value, + actualValue = values[settingType to key], + ) + } + values[settingType to key] = value + return SettingWriteResult.Success(value) + } + + override suspend fun getSecureSettings(settingType: SettingType): List = emptyList() + } + + private companion object { + const val BANK_A = "com.bank.a/.MainActivity" + const val BANK_B = "com.bank.b/.MainActivity" + const val DEV_OPTIONS = "development_settings_enabled" + const val ADB_ENABLED = "adb_enabled" + const val OTHER_KEY = "other_key" + const val CLOCK = 1_700_000_000_000L + val GLOBAL_DEV = SettingType.GLOBAL to DEV_OPTIONS + val GLOBAL_ADB = SettingType.GLOBAL to ADB_ENABLED + val SECURE_OTHER = SettingType.SECURE to OTHER_KEY + } +} diff --git a/domain/use-case/src/test/kotlin/com/android/geto/domain/usecase/ShizukuPermissionControllerTest.kt b/domain/use-case/src/test/kotlin/com/android/geto/domain/usecase/ShizukuPermissionControllerTest.kt new file mode 100644 index 000000000..68d62c92d --- /dev/null +++ b/domain/use-case/src/test/kotlin/com/android/geto/domain/usecase/ShizukuPermissionControllerTest.kt @@ -0,0 +1,175 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.domain.usecase + +import com.android.geto.domain.framework.SecureSettingsWrapper +import com.android.geto.domain.framework.ShizukuWrapper +import com.android.geto.domain.model.SecureSetting +import com.android.geto.domain.model.SettingReadResult +import com.android.geto.domain.model.SettingType +import com.android.geto.domain.model.SettingWriteResult +import com.android.geto.domain.model.ShizukuGrantResult +import com.android.geto.domain.model.ShizukuState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class ShizukuPermissionControllerTest { + + /** + * The regression that matters most: an earlier implementation reported the permission as + * granted purely because the code path ran, without the permission ever changing. A wrapper + * claiming success must not be believed on its own. + */ + @Test + fun grant_doesNotReportSuccess_whenPermissionIsStillNotVisible() = runTest { + val secureSettings = FakeSecureSettingsWrapper(granted = false) + val shizuku = FakeShizukuWrapper(grantResult = ShizukuGrantResult.Success) + + val result = ShizukuPermissionController(shizuku, secureSettings).grant() + + assertEquals(ShizukuGrantResult.RequiresRestart, result) + } + + @Test + fun grant_reportsSuccess_onlyOncePermissionReadsBackAsGranted() = runTest { + val secureSettings = FakeSecureSettingsWrapper(granted = false) + val shizuku = FakeShizukuWrapper( + grantResult = ShizukuGrantResult.Success, + onGrant = { secureSettings.granted = true }, + ) + + val result = ShizukuPermissionController(shizuku, secureSettings).grant() + + assertEquals(ShizukuGrantResult.Success, result) + } + + @Test + fun grant_reportsFailure_whenShizukuCannotBeUsed() = runTest { + val secureSettings = FakeSecureSettingsWrapper(granted = false) + val shizuku = FakeShizukuWrapper( + grantResult = ShizukuGrantResult.Failure(ShizukuState.NotRunning), + ) + + val result = ShizukuPermissionController(shizuku, secureSettings).grant() + + assertEquals(ShizukuGrantResult.Failure(ShizukuState.NotRunning), result) + } + + @Test + fun grant_reportsError_whenTheAttemptThrewOnTheOtherSide() = runTest { + val secureSettings = FakeSecureSettingsWrapper(granted = false) + val shizuku = FakeShizukuWrapper(grantResult = ShizukuGrantResult.Error("dead binder")) + + val result = ShizukuPermissionController(shizuku, secureSettings).grant() + + assertEquals(ShizukuGrantResult.Error("dead binder"), result) + } + + @Test + fun grant_doesNotTouchShizuku_whenPermissionIsAlreadyHeld() = runTest { + val secureSettings = FakeSecureSettingsWrapper(granted = true) + val shizuku = FakeShizukuWrapper() + + val result = ShizukuPermissionController(shizuku, secureSettings).grant() + + assertEquals(ShizukuGrantResult.Success, result) + assertEquals(0, shizuku.grantAttempts) + } + + /** Repeated attempts have to keep producing a real verdict, not a cached first answer. */ + @Test + fun grant_reportsEachAttemptIndependently() = runTest { + val secureSettings = FakeSecureSettingsWrapper(granted = false) + val shizuku = FakeShizukuWrapper(grantResult = ShizukuGrantResult.Success) + val controller = ShizukuPermissionController(shizuku, secureSettings) + + assertEquals(ShizukuGrantResult.RequiresRestart, controller.grant()) + + secureSettings.granted = true + + assertEquals(ShizukuGrantResult.Success, controller.grant()) + assertEquals(1, shizuku.grantAttempts) + } + + @Test + fun observation_isForwardedToTheWrapper() { + val shizuku = FakeShizukuWrapper() + val controller = ShizukuPermissionController(shizuku, FakeSecureSettingsWrapper()) + + controller.start() + controller.refresh() + controller.stop() + + assertEquals(1, shizuku.starts) + assertEquals(1, shizuku.refreshes) + assertEquals(1, shizuku.stops) + } + + private class FakeShizukuWrapper( + private val grantResult: ShizukuGrantResult = ShizukuGrantResult.Success, + private val onGrant: () -> Unit = {}, + ) : ShizukuWrapper { + var grantAttempts = 0 + var starts = 0 + var stops = 0 + var refreshes = 0 + + private val mutableState = MutableStateFlow(ShizukuState.Unknown) + + override val state = mutableState.asStateFlow() + + override fun start() { + starts++ + } + + override fun stop() { + stops++ + } + + override fun refresh() { + refreshes++ + } + + override suspend fun grantWriteSecureSettings(): ShizukuGrantResult { + grantAttempts++ + onGrant() + return grantResult + } + } + + /** + * Deliberately not the fake in `ProtectionControllerTest`: that one fixes the permission at + * construction, and these tests need it to change underneath the controller. + */ + private class FakeSecureSettingsWrapper(var granted: Boolean = false) : SecureSettingsWrapper { + override fun hasWriteSecureSettingsPermission(): Boolean = granted + + override suspend fun read(settingType: SettingType, key: String): SettingReadResult = SettingReadResult.Success(value = null) + + override suspend fun write( + settingType: SettingType, + key: String, + value: String?, + ): SettingWriteResult = SettingWriteResult.Success(value = value) + + override suspend fun getSecureSettings(settingType: SettingType): List = emptyList() + } +} diff --git a/feature/app-settings/build.gradle.kts b/feature/app-settings/build.gradle.kts index 61fbd19cf..5a0e92e47 100644 --- a/feature/app-settings/build.gradle.kts +++ b/feature/app-settings/build.gradle.kts @@ -32,4 +32,6 @@ dependencies { implementation(projects.domain.framework) implementation(projects.domain.repository) implementation(projects.domain.useCase) -} \ No newline at end of file + implementation(projects.service) + implementation(libs.androidx.activity.compose) +} diff --git a/feature/app-settings/src/main/kotlin/com/android/geto/feature/appsettings/AppSettingsScreen.kt b/feature/app-settings/src/main/kotlin/com/android/geto/feature/appsettings/AppSettingsScreen.kt index aecb73cf0..d9b5bf03e 100644 --- a/feature/app-settings/src/main/kotlin/com/android/geto/feature/appsettings/AppSettingsScreen.kt +++ b/feature/app-settings/src/main/kotlin/com/android/geto/feature/appsettings/AppSettingsScreen.kt @@ -17,81 +17,113 @@ */ package com.android.geto.feature.appsettings +import android.Manifest import android.app.Notification import android.app.PendingIntent import android.app.PendingIntent.FLAG_IMMUTABLE import android.app.PendingIntent.FLAG_UPDATE_CURRENT +import android.content.ComponentName import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.graphics.BitmapFactory +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.VisibleForTesting import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column 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.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.selection.toggleable import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.AlertDialog import androidx.compose.material3.BottomAppBar import androidx.compose.material3.BottomAppBarDefaults import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +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.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import androidx.core.net.toUri import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.geto.broadcastreceiver.RevertSettingsBroadcastReceiver import com.android.geto.designsystem.icon.GetoIcons import com.android.geto.domain.model.AddAppSettingResult import com.android.geto.domain.model.AppSetting import com.android.geto.domain.model.AppSettingTemplate -import com.android.geto.domain.model.AppSettingsResult import com.android.geto.domain.model.GetPinShortcutResult +import com.android.geto.domain.model.ProtectedApp +import com.android.geto.domain.model.ProtectionMode +import com.android.geto.domain.model.ProtectionResult +import com.android.geto.domain.model.ProtectionSessionStatus +import com.android.geto.domain.model.ProtectionState import com.android.geto.domain.model.RequestPinShortcutResult import com.android.geto.domain.model.SecureSetting import com.android.geto.domain.model.SettingType import com.android.geto.domain.model.UpdatePinShortcutResult +import com.android.geto.domain.model.isPaused import com.android.geto.feature.appsettings.dialog.AppSettingDialog import com.android.geto.feature.appsettings.dialog.RequestPinShortcutDialog import com.android.geto.feature.appsettings.dialog.TemplateDialog import com.android.geto.feature.appsettings.dialog.UpdatePinShortcutDialog import com.android.geto.feature.appsettings.dialog.WriteSecureSettingsDialog import com.android.geto.feature.appsettings.navigation.AppSettingsRouteData +import com.android.geto.framework.launcherapps.LaunchResult import com.android.geto.framework.notificationmanager.AndroidNotificationManagerWrapper import com.android.geto.framework.notificationmanager.AndroidNotificationManagerWrapper.Companion.ACTION_REVERT_SETTINGS import com.android.geto.framework.notificationmanager.AndroidNotificationManagerWrapper.Companion.NOTIFICATION_EXTRA_COMPONENT_NAME import com.android.geto.framework.notificationmanager.AndroidNotificationManagerWrapper.Companion.NOTIFICATION_EXTRA_NOTIFICATION_ID +import com.android.geto.framework.notificationmanager.AndroidNotificationManagerWrapper.Companion.NOTIFICATION_EXTRA_SESSION_TOKEN +import com.android.geto.framework.notificationmanager.AndroidNotificationManagerWrapper.Companion.ONE_SHOT_NOTIFICATION_ID import com.android.geto.ui.local.LocalLauncherApps import com.android.geto.ui.local.LocalNotificationManager -import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.launch import com.android.geto.common.R as commonR @Composable @@ -101,13 +133,115 @@ internal fun AppSettingsRoute( appSettingsRouteData: AppSettingsRouteData, onNavigationIconClick: () -> Unit, ) { + val context = LocalContext.current + val notificationManager = LocalNotificationManager.current + val lifecycleOwner = LocalLifecycleOwner.current + var pendingNotificationAction by + rememberSaveable { mutableStateOf(null) } + var pendingNotificationRequiredChannelId by rememberSaveable { mutableStateOf(null) } + var notificationSettingsChannelId by rememberSaveable { mutableStateOf(null) } + var showNotificationPermissionDialog by rememberSaveable { mutableStateOf(false) } + + fun clearPendingNotificationAction() { + pendingNotificationAction = null + pendingNotificationRequiredChannelId = null + } + + fun executeNotificationAction(action: NotificationPermissionAction) { + when (action) { + NotificationPermissionAction.LAUNCH_ONCE -> viewModel.launchOnce() + + NotificationPermissionAction.ENABLE_PERSISTENT -> + viewModel.setForegroundProtection(enabled = true) + + NotificationPermissionAction.RESUME_PERSISTENT -> viewModel.resumeProtection() + } + } + + val notificationPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { granted -> + val pendingAction = pendingNotificationAction + val requiredChannelId = pendingNotificationRequiredChannelId + val missingChannelId = requiredChannelId?.takeUnless { + notificationManager.isNotificationChannelEnabled(it) + } + if (granted && notificationManager.areNotificationsEnabled() && missingChannelId == null) { + clearPendingNotificationAction() + pendingAction?.let(::executeNotificationAction) + } else { + notificationSettingsChannelId = missingChannelId + showNotificationPermissionDialog = true + } + } + + fun runWithNotificationPermission( + requiredChannelId: String, + action: NotificationPermissionAction, + ) { + val notificationsEnabled = notificationManager.areNotificationsEnabled() + val runtimePermissionGranted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED + val missingChannelId = requiredChannelId.takeUnless { + notificationManager.isNotificationChannelEnabled(it) + } + + when { + notificationsEnabled && runtimePermissionGranted && missingChannelId == null -> + executeNotificationAction(action) + + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !runtimePermissionGranted -> { + pendingNotificationAction = action + pendingNotificationRequiredChannelId = requiredChannelId + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + + else -> { + pendingNotificationAction = action + pendingNotificationRequiredChannelId = requiredChannelId + notificationSettingsChannelId = missingChannelId + showNotificationPermissionDialog = true + } + } + } + + DisposableEffect( + lifecycleOwner, + pendingNotificationAction, + pendingNotificationRequiredChannelId, + ) { + val observer = LifecycleEventObserver { _, event -> + val action = pendingNotificationAction + val requiredChannelId = pendingNotificationRequiredChannelId + if ( + event == Lifecycle.Event.ON_RESUME && + action != null && + requiredChannelId != null && + notificationManager.areNotificationsEnabled() && + notificationManager.isNotificationChannelEnabled(requiredChannelId) + ) { + clearPendingNotificationAction() + notificationSettingsChannelId = null + showNotificationPermissionDialog = false + executeNotificationAction(action) + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + val appSettingsUiState by viewModel.appSettingsUiState.collectAsStateWithLifecycle() val secureSettings by viewModel.secureSettings.collectAsStateWithLifecycle() - val applyAppSettingsResult by viewModel.applyAppSettingsResult.collectAsStateWithLifecycle() - - val revertAppSettingsResult by viewModel.revertAppSettingsResult.collectAsStateWithLifecycle() + val protectionActionResult by viewModel.protectionActionResult.collectAsStateWithLifecycle() + val isArmed by viewModel.isArmed.collectAsStateWithLifecycle() + val protectionState by viewModel.protectionState.collectAsStateWithLifecycle() + val isProtectionServiceRunning by + viewModel.isProtectionServiceRunning.collectAsStateWithLifecycle() val addAppSettingResult by viewModel.addAppSettingsResult.collectAsStateWithLifecycle() @@ -128,21 +262,40 @@ internal fun AppSettingsRoute( activityIcon = activityIcon, secureSettings = secureSettings, addAppSettingResult = addAppSettingResult, - applyAppSettingsResult = applyAppSettingsResult, - revertAppSettingsResult = revertAppSettingsResult, + protectionActionResult = protectionActionResult, + protectionState = protectionState, + isArmed = isArmed, + isProtectionServiceRunning = isProtectionServiceRunning, requestPinShortcutResult = requestPinShortcutResult, appSettingTemplates = appSettingTemplates, updatePinShortcutResult = updatePinShortcutResult, - onApplyAppSettings = viewModel::applyAppSettings, - onRevertAppSettings = viewModel::revertAppSettings, + onLaunchOnce = { + runWithNotificationPermission( + requiredChannelId = AndroidNotificationManagerWrapper.NOTIFICATION_CHANNEL_ID, + action = NotificationPermissionAction.LAUNCH_ONCE, + ) + }, + onSetPersistentProtection = { enabled, _ -> + // Arming writes nothing on its own, so there is no notification to ask about here; the + // service and its notification only appear once the app is actually opened. + viewModel.setForegroundProtection(enabled = enabled) + }, + onRestoreAfterLaunchFailure = viewModel::restoreProtection, + onResumeProtection = { + runWithNotificationPermission( + requiredChannelId = + AndroidNotificationManagerWrapper.PROTECTION_NOTIFICATION_CHANNEL_ID, + action = NotificationPermissionAction.RESUME_PERSISTENT, + ) + }, onCheckAppSetting = viewModel::checkAppSetting, onDeleteAppSetting = viewModel::deleteAppSetting, onAddAppSetting = viewModel::addAppSetting, + onAddAppSettingTemplate = viewModel::addAppSettingTemplate, onRequestPinShortcut = viewModel::requestPinShortcut, onGetSecureSettingsByName = viewModel::getSecureSettingsByName, - onResetApplyAppSettingsResult = viewModel::resetApplyAppSettingsResult, + onResetProtectionActionResult = viewModel::resetProtectionActionResult, onResetRequestPinShortcutResult = viewModel::resetRequestPinShortcutResult, - onResetRevertAppSettingsResult = viewModel::resetRevertAppSettingsResult, onResetAddAppSettingResult = viewModel::resetAddAppSettingResult, onNavigationIconClick = onNavigationIconClick, getPinShortcutResult = getPinShortcutResult, @@ -151,6 +304,65 @@ internal fun AppSettingsRoute( onUpdatePinShortcut = viewModel::updatePinShorcut, onResetUpdatePinShortcutResult = viewModel::resetUpdatePinShortcutResult, ) + + if (showNotificationPermissionDialog) { + AlertDialog( + onDismissRequest = { + clearPendingNotificationAction() + notificationSettingsChannelId = null + showNotificationPermissionDialog = false + }, + title = { Text(stringResource(R.string.permission)) }, + text = { Text(stringResource(R.string.notification_permission_required)) }, + confirmButton = { + TextButton( + onClick = { + showNotificationPermissionDialog = false + val channelId = notificationSettingsChannelId + notificationSettingsChannelId = null + context.startActivity(notificationSettingsIntent(context, channelId)) + }, + ) { + Text(stringResource(R.string.open_notification_settings)) + } + }, + dismissButton = { + TextButton( + onClick = { + clearPendingNotificationAction() + notificationSettingsChannelId = null + showNotificationPermissionDialog = false + }, + ) { + Text(stringResource(commonR.string.cancel)) + } + }, + ) + } +} + +private enum class NotificationPermissionAction { + LAUNCH_ONCE, + ENABLE_PERSISTENT, + RESUME_PERSISTENT, +} + +private fun notificationSettingsIntent(context: Context, channelId: String?): Intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Intent( + if (channelId != null) { + Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS + } else { + Settings.ACTION_APP_NOTIFICATION_SETTINGS + }, + ).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + channelId?.let { putExtra(Settings.EXTRA_CHANNEL_ID, it) } + } +} else { + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", context.packageName, null), + ) } @VisibleForTesting @@ -162,26 +374,30 @@ internal fun AppSettingsScreen( activityIcon: ByteArray?, secureSettings: List, addAppSettingResult: AddAppSettingResult?, - applyAppSettingsResult: AppSettingsResult?, - revertAppSettingsResult: AppSettingsResult?, + protectionActionResult: ProtectionActionResult?, + protectionState: ProtectionState, + isArmed: Boolean, + isProtectionServiceRunning: Boolean, requestPinShortcutResult: RequestPinShortcutResult?, appSettingTemplates: List, getPinShortcutResult: GetPinShortcutResult?, updatePinShortcutResult: UpdatePinShortcutResult?, - onApplyAppSettings: () -> Unit, - onRevertAppSettings: () -> Unit, + onLaunchOnce: () -> Unit, + onSetPersistentProtection: (enabled: Boolean, sessionToken: String?) -> Unit, + onRestoreAfterLaunchFailure: (expectedSessionToken: String) -> Unit, + onResumeProtection: () -> Unit, onCheckAppSetting: (appSetting: AppSetting) -> Unit, onDeleteAppSetting: (appSetting: AppSetting) -> Unit, onAddAppSetting: (AppSetting) -> Unit, + onAddAppSettingTemplate: (AppSettingTemplate) -> Unit, onRequestPinShortcut: ( icon: ByteArray?, shortLabel: String, longLabel: String, ) -> Unit, onGetSecureSettingsByName: (settingType: SettingType, text: String) -> Unit, - onResetApplyAppSettingsResult: () -> Unit, + onResetProtectionActionResult: () -> Unit, onResetRequestPinShortcutResult: () -> Unit, - onResetRevertAppSettingsResult: () -> Unit, onResetAddAppSettingResult: () -> Unit, onNavigationIconClick: () -> Unit, onGetPinShortcut: () -> Unit, @@ -193,26 +409,28 @@ internal fun AppSettingsScreen( ) -> Unit, onResetUpdatePinShortcutResult: () -> Unit, ) { - var showAppSettingDialog by remember { mutableStateOf(false) } + var showAppSettingDialog by rememberSaveable { mutableStateOf(false) } - var showTemplateDialog by remember { mutableStateOf(false) } + var showTemplateDialog by rememberSaveable { mutableStateOf(false) } - var showWriteSecureSettingsDialog by remember { mutableStateOf(false) } + var showWriteSecureSettingsDialog by rememberSaveable { mutableStateOf(false) } + var keyConflict by remember { mutableStateOf(null) } val snackbarHostState = remember { SnackbarHostState() } + val coroutineScope = rememberCoroutineScope() + val deletedMessage = stringResource(R.string.setting_deleted) + val undoLabel = stringResource(R.string.undo) AppSettingsLaunchedEffects( appSettingsRouteData = appSettingsRouteData, snackbarHostState = snackbarHostState, activityIcon = activityIcon, addAppSettingResult = addAppSettingResult, - applyAppSettingsResult = applyAppSettingsResult, - revertAppSettingsResult = revertAppSettingsResult, + protectionActionResult = protectionActionResult, requestPinShortcutResult = requestPinShortcutResult, getPinShortcutResult = getPinShortcutResult, updatePinShortcutResult = updatePinShortcutResult, - onResetApplyAppSettingsResult = onResetApplyAppSettingsResult, - onResetRevertAppSettingsResult = onResetRevertAppSettingsResult, + onResetProtectionActionResult = onResetProtectionActionResult, onResetRequestPinShortcutResult = onResetRequestPinShortcutResult, onResetAddAppSettingResult = onResetAddAppSettingResult, onShowWriteSecureSettingsDialog = { @@ -220,8 +438,16 @@ internal fun AppSettingsScreen( }, onResetGetPinShortcutResult = onResetGetPinShortcutResult, onResetUpdatePinShortcutResult = onResetUpdatePinShortcutResult, + onKeyConflict = { keyConflict = it }, + onRestoreAfterLaunchFailure = onRestoreAfterLaunchFailure, ) + // Scoped to this component: another app being protected is not this screen's business. + val thisApp = protectionState.forComponentName(appSettingsRouteData.componentName) + // "Applied right now" — true only while this app is actually in the foreground. + val isCurrentlyApplied = thisApp != null + val protectionBusy = thisApp?.isBusy == true + Scaffold( topBar = { AppSettingsTopAppBar( @@ -231,7 +457,15 @@ internal fun AppSettingsScreen( }, bottomBar = { AppSettingsBottomAppBar( - onRefreshIconClick = onRevertAppSettings, + restoreEnabled = isCurrentlyApplied && !protectionBusy, + // Editing is only unsafe while the values are actually applied. + editingEnabled = !isCurrentlyApplied && !protectionBusy, + launchEnabled = !protectionBusy, + onRefreshIconClick = { + thisApp?.sessionToken?.let { token -> + onSetPersistentProtection(false, token) + } + }, onSettingsIconClick = { showAppSettingDialog = true }, @@ -239,35 +473,75 @@ internal fun AppSettingsScreen( onSettingsSuggestIconClick = { showTemplateDialog = true }, - onFloatingActionButtonClick = onApplyAppSettings, + onFloatingActionButtonClick = onLaunchOnce, ) }, snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, ) { innerPadding -> - Box( + Column( modifier = modifier .fillMaxSize() .padding(innerPadding), ) { - when (appSettingsUiState) { - AppSettingsUiState.Loading -> { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + ProtectionStatusItem( + app = thisApp, + // The switch reflects whether the profile is *armed*, which persists. Reading it from + // the session made it snap straight back off: a session exists only while the app is + // in the foreground, and arming deliberately creates none. + checked = isArmed, + isServiceRunning = isProtectionServiceRunning, + enabled = !protectionBusy, + onCheckedChange = { checked -> onSetPersistentProtection(checked, null) }, + ) + + if ( + isArmed && + thisApp?.status == ProtectionSessionStatus.ACTIVE && + !isProtectionServiceRunning + ) { + TextButton( + modifier = Modifier.align(Alignment.End), + onClick = onResumeProtection, + ) { + Text(stringResource(R.string.resume_background_protection)) } + } - is AppSettingsUiState.Success -> { - if (appSettingsUiState.appSettings.isNotEmpty()) { - Success( - appSettingsUiState = appSettingsUiState, - onCheckAppSetting = onCheckAppSetting, - onDeleteAppSettingsItem = onDeleteAppSetting, - ) - } else { - Empty( - title = stringResource(R.string.no_settings_found), - subtitle = stringResource(R.string.add_your_first_settings), - ) + Box(modifier = Modifier.weight(1f)) { + when (appSettingsUiState) { + AppSettingsUiState.Loading -> { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + + is AppSettingsUiState.Success -> { + if (appSettingsUiState.appSettings.isNotEmpty()) { + Success( + appSettingsUiState = appSettingsUiState, + editingEnabled = !isCurrentlyApplied, + onCheckAppSetting = onCheckAppSetting, + onDeleteAppSettingsItem = onDeleteAppSetting, + onUndoDelete = { appSetting -> + coroutineScope.launch { + if ( + snackbarHostState.showSnackbar( + message = deletedMessage, + actionLabel = undoLabel, + withDismissAction = true, + ) == SnackbarResult.ActionPerformed + ) { + onCheckAppSetting(appSetting) + } + } + }, + ) + } else { + Empty( + title = stringResource(R.string.no_settings_found), + subtitle = stringResource(R.string.add_your_first_settings), + ) + } } } } @@ -289,8 +563,7 @@ internal fun AppSettingsScreen( if (showTemplateDialog) { TemplateDialog( appSettingTemplates = appSettingTemplates, - componentName = appSettingsRouteData.componentName, - onAddAppSetting = onAddAppSetting, + onAddTemplate = onAddAppSettingTemplate, onDismissRequest = { showTemplateDialog = false }, @@ -305,6 +578,35 @@ internal fun AppSettingsScreen( ) } + // Sharing a key with another app is normal; only a contradictory value lands here, and it is + // reported before anything was written, so nothing needs undoing. + keyConflict?.let { conflict -> + val holder = conflict.holderComponentNames.firstOrNull() + val holderLabel = holder + ?.let { ComponentName.unflattenFromString(it)?.packageName ?: it } + ?: stringResource(R.string.another_app) + AlertDialog( + onDismissRequest = { keyConflict = null }, + title = { Text(stringResource(R.string.key_conflict_title)) }, + text = { + Text( + stringResource( + R.string.key_conflict_message, + holderLabel, + conflict.key, + conflict.enforcedValue, + conflict.requestedValue, + ), + ) + }, + confirmButton = { + TextButton(onClick = { keyConflict = null }) { + Text(stringResource(R.string.got_it)) + } + }, + ) + } + when (getPinShortcutResult) { GetPinShortcutResult.RequestPinShortcut -> { RequestPinShortcutDialog( @@ -327,25 +629,24 @@ internal fun AppSettingsScreen( } } -@OptIn(FlowPreview::class) @Composable private fun AppSettingsLaunchedEffects( appSettingsRouteData: AppSettingsRouteData, snackbarHostState: SnackbarHostState, activityIcon: ByteArray?, addAppSettingResult: AddAppSettingResult?, - applyAppSettingsResult: AppSettingsResult?, - revertAppSettingsResult: AppSettingsResult?, + protectionActionResult: ProtectionActionResult?, requestPinShortcutResult: RequestPinShortcutResult?, getPinShortcutResult: GetPinShortcutResult?, updatePinShortcutResult: UpdatePinShortcutResult?, - onResetApplyAppSettingsResult: () -> Unit, - onResetRevertAppSettingsResult: () -> Unit, + onResetProtectionActionResult: () -> Unit, onResetRequestPinShortcutResult: () -> Unit, onResetAddAppSettingResult: () -> Unit, onShowWriteSecureSettingsDialog: () -> Unit, onResetGetPinShortcutResult: () -> Unit, onResetUpdatePinShortcutResult: () -> Unit, + onKeyConflict: (ProtectionResult.KeyConflict) -> Unit, + onRestoreAfterLaunchFailure: (expectedSessionToken: String) -> Unit, ) { val context = LocalContext.current @@ -383,100 +684,132 @@ private fun AppSettingsLaunchedEffects( val appSettingAddSuccess = stringResource(R.string.app_setting_added_successfully) val appSettingAddFailed = stringResource(R.string.app_setting_already_exists) + val protectionActive = stringResource(R.string.protection_active) + val protectionInactive = stringResource(R.string.protection_inactive) + val launchFailed = stringResource(R.string.launch_failed) + val rollbackFailed = stringResource(R.string.settings_rollback_failed) + + LaunchedEffect(protectionActionResult) { + val actionResult = protectionActionResult ?: return@LaunchedEffect + + // Consume the result before anything below suspends. Every branch here shows a snackbar, + // which suspends for its whole duration — clearing afterwards left the result live, so a + // rotation in that window re-ran this effect and launched the target app a second time. + onResetProtectionActionResult() + + when (val result = actionResult.result) { + is ProtectionResult.Success -> when (actionResult.action) { + ProtectionAction.LAUNCH_ONCE -> { + val activeApp = result.app + if (activeApp?.mode == ProtectionMode.ONE_SHOT) { + androidNotificationManagerWrapper.notify( + id = ONE_SHOT_NOTIFICATION_ID, + notification = getNotification( + context = context, + notificationId = ONE_SHOT_NOTIFICATION_ID, + sessionToken = activeApp.sessionToken, + componentName = appSettingsRouteData.componentName, + icon = activityIcon, + contentTitle = getoSettings, + contentText = applySuccess, + ), + ) + } - LaunchedEffect(key1 = applyAppSettingsResult) { - when (applyAppSettingsResult) { - AppSettingsResult.DisabledAppSettings -> { - snackbarHostState.showSnackbar(message = appSettingsDisabled) - - onResetApplyAppSettingsResult() - } - - AppSettingsResult.EmptyAppSettings -> { - snackbarHostState.showSnackbar(message = emptyAppSettingsList) - - onResetApplyAppSettingsResult() - } - - AppSettingsResult.Failure -> { - snackbarHostState.showSnackbar(message = applyFailure) - - onResetApplyAppSettingsResult() - } - - AppSettingsResult.NoPermission -> { - onShowWriteSecureSettingsDialog() - - onResetApplyAppSettingsResult() - } - - AppSettingsResult.Success -> { - val notificationId = appSettingsRouteData.componentName.hashCode() - - androidNotificationManagerWrapper.notify( - id = notificationId, - notification = getNotification( - context = context, - notificationId = notificationId, - componentName = appSettingsRouteData.componentName, - icon = activityIcon, - contentTitle = getoSettings, - contentText = applySuccess, - ), - ) + if ( + androidLauncherAppsWrapper.startMainActivity( + componentName = appSettingsRouteData.componentName, + ) !is LaunchResult.Success + ) { + if (activeApp?.mode == ProtectionMode.ONE_SHOT) { + onRestoreAfterLaunchFailure(activeApp.sessionToken) + } + snackbarHostState.showSnackbar(message = launchFailed) + } + } - androidLauncherAppsWrapper.startMainActivity(componentName = appSettingsRouteData.componentName) + ProtectionAction.ENABLE_PERSISTENT -> { + androidNotificationManagerWrapper.cancel(ONE_SHOT_NOTIFICATION_ID) + snackbarHostState.showSnackbar(message = protectionActive) + } - onResetApplyAppSettingsResult() + ProtectionAction.RESTORE -> { + androidNotificationManagerWrapper.cancel(ONE_SHOT_NOTIFICATION_ID) + snackbarHostState.showSnackbar(message = revertSuccess) + } } - AppSettingsResult.InvalidValues -> { - snackbarHostState.showSnackbar(message = invalidValues) - - onResetApplyAppSettingsResult() + ProtectionResult.EmptyProfile -> { + snackbarHostState.showSnackbar(message = emptyAppSettingsList) } - null -> Unit - } - } - - LaunchedEffect(key1 = revertAppSettingsResult) { - when (revertAppSettingsResult) { - AppSettingsResult.DisabledAppSettings -> { + ProtectionResult.NoEnabledSettings -> { snackbarHostState.showSnackbar(message = appSettingsDisabled) } - AppSettingsResult.EmptyAppSettings -> { - snackbarHostState.showSnackbar(message = emptyAppSettingsList) + is ProtectionResult.PermissionDenied -> onShowWriteSecureSettingsDialog() - onResetRevertAppSettingsResult() + is ProtectionResult.InvalidProfile -> { + snackbarHostState.showSnackbar(message = invalidValues) } - AppSettingsResult.Failure -> { - snackbarHostState.showSnackbar(message = revertFailure) + is ProtectionResult.KeyConflict -> onKeyConflict(result) - onResetRevertAppSettingsResult() + is ProtectionResult.RecoveryRequired -> { + if ( + result.failures.any { failure -> + failure.reason == com.android.geto.domain.model.ProtectionFailureReason.PERMISSION_DENIED + } + ) { + onShowWriteSecureSettingsDialog() + } + if ( + actionResult.action == ProtectionAction.RESTORE && + result.app.mode == ProtectionMode.ONE_SHOT + ) { + androidNotificationManagerWrapper.notify( + id = ONE_SHOT_NOTIFICATION_ID, + notification = getNotification( + context = context, + notificationId = ONE_SHOT_NOTIFICATION_ID, + sessionToken = result.app.sessionToken, + componentName = result.app.componentName, + icon = activityIcon, + contentTitle = getoSettings, + contentText = rollbackFailed, + ), + ) + } + snackbarHostState.showSnackbar(message = rollbackFailed) } - AppSettingsResult.NoPermission -> { - onShowWriteSecureSettingsDialog() - - onResetRevertAppSettingsResult() + ProtectionResult.NoActiveProtection -> { + if (actionResult.action == ProtectionAction.RESTORE) { + snackbarHostState.showSnackbar(message = protectionInactive) + } } - AppSettingsResult.Success -> { - snackbarHostState.showSnackbar(message = revertSuccess) - - onResetRevertAppSettingsResult() + is ProtectionResult.Failure, + is ProtectionResult.KeyNotProtected, + -> { + snackbarHostState.showSnackbar( + message = if (actionResult.action == ProtectionAction.RESTORE) { + revertFailure + } else { + applyFailure + }, + ) } - AppSettingsResult.InvalidValues -> { - snackbarHostState.showSnackbar(message = invalidValues) - - onResetRevertAppSettingsResult() + else -> { + snackbarHostState.showSnackbar( + message = if (actionResult.action == ProtectionAction.RESTORE) { + revertFailure + } else { + applyFailure + }, + ) } - - null -> Unit } } @@ -565,13 +898,17 @@ private fun AppSettingsTopAppBar( TopAppBar( modifier = modifier, title = { - Text(text = title, maxLines = 1) + Text( + text = title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) }, navigationIcon = { IconButton(onClick = onNavigationIconClick) { Icon( imageVector = GetoIcons.Back, - contentDescription = null, + contentDescription = stringResource(R.string.back), ) } }, @@ -580,6 +917,9 @@ private fun AppSettingsTopAppBar( @Composable private fun AppSettingsBottomAppBar( + restoreEnabled: Boolean, + editingEnabled: Boolean, + launchEnabled: Boolean, onRefreshIconClick: () -> Unit, onSettingsIconClick: () -> Unit, onShortcutIconClick: () -> Unit, @@ -589,6 +929,8 @@ private fun AppSettingsBottomAppBar( BottomAppBar( actions = { AppSettingsBottomAppBarActions( + restoreEnabled = restoreEnabled, + editingEnabled = editingEnabled, onRefreshIconClick = onRefreshIconClick, onSettingsIconClick = onSettingsIconClick, onShortcutIconClick = onShortcutIconClick, @@ -597,6 +939,7 @@ private fun AppSettingsBottomAppBar( }, floatingActionButton = { AppSettingsFloatingActionButton( + enabled = launchEnabled, onClick = onFloatingActionButtonClick, ) }, @@ -604,37 +947,51 @@ private fun AppSettingsBottomAppBar( } @Composable -private fun AppSettingsFloatingActionButton(onClick: () -> Unit) { - FloatingActionButton( +private fun AppSettingsFloatingActionButton( + enabled: Boolean, + onClick: () -> Unit, +) { + FilledIconButton( + modifier = Modifier.size(56.dp), + enabled = enabled, onClick = onClick, - containerColor = BottomAppBarDefaults.bottomAppBarFabColor, - elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation(), + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = BottomAppBarDefaults.bottomAppBarFabColor, + ), ) { Icon( imageVector = GetoIcons.ArrowForward, - contentDescription = null, + contentDescription = stringResource(R.string.launch_once), ) } } @Composable private fun AppSettingsBottomAppBarActions( + restoreEnabled: Boolean, + editingEnabled: Boolean, onRefreshIconClick: () -> Unit, onSettingsIconClick: () -> Unit, onShortcutIconClick: () -> Unit, onSettingsSuggestIconClick: () -> Unit, ) { - IconButton(onClick = onRefreshIconClick) { + IconButton( + enabled = restoreEnabled, + onClick = onRefreshIconClick, + ) { Icon( imageVector = GetoIcons.Refresh, - contentDescription = null, + contentDescription = stringResource(R.string.restore_settings), ) } - IconButton(onClick = onSettingsIconClick) { + IconButton( + enabled = editingEnabled, + onClick = onSettingsIconClick, + ) { Icon( GetoIcons.Settings, - contentDescription = null, + contentDescription = stringResource(R.string.add_setting), ) } @@ -643,16 +1000,17 @@ private fun AppSettingsBottomAppBarActions( ) { Icon( GetoIcons.Shortcut, - contentDescription = null, + contentDescription = stringResource(R.string.manage_shortcut), ) } IconButton( + enabled = editingEnabled, onClick = onSettingsSuggestIconClick, ) { Icon( imageVector = GetoIcons.SettingsSuggest, - contentDescription = null, + contentDescription = stringResource(R.string.add_template), ) } } @@ -689,13 +1047,16 @@ private fun Empty( private fun Success( modifier: Modifier = Modifier, appSettingsUiState: AppSettingsUiState.Success, + editingEnabled: Boolean, onCheckAppSetting: (AppSetting) -> Unit, onDeleteAppSettingsItem: (AppSetting) -> Unit, + onUndoDelete: (AppSetting) -> Unit, ) { LazyColumn(modifier = modifier) { items(items = appSettingsUiState.appSettings, key = { it.id }) { appSettings -> AppSettingItem( appSetting = appSettings, + enabled = editingEnabled, onCheckedChange = { check -> onCheckAppSetting( appSettings.copy(enabled = check), @@ -703,6 +1064,7 @@ private fun Success( }, onDeleteClick = { onDeleteAppSettingsItem(appSettings) + onUndoDelete(appSettings) }, ) } @@ -713,11 +1075,19 @@ private fun Success( private fun LazyItemScope.AppSettingItem( modifier: Modifier = Modifier, appSetting: AppSetting, + enabled: Boolean, onCheckedChange: (Boolean) -> Unit, onDeleteClick: () -> Unit, ) { ListItem( - modifier = modifier.animateItem(), + modifier = modifier + .animateItem() + .toggleable( + value = appSetting.enabled, + enabled = enabled, + role = Role.Checkbox, + onValueChange = onCheckedChange, + ), headlineContent = { Text( text = appSetting.label, @@ -736,23 +1106,93 @@ private fun LazyItemScope.AppSettingItem( leadingContent = { Checkbox( checked = appSetting.enabled, - onCheckedChange = onCheckedChange, + onCheckedChange = null, ) }, trailingContent = { - IconButton(onClick = onDeleteClick) { + IconButton( + enabled = enabled, + onClick = onDeleteClick, + ) { Icon( imageVector = Icons.Default.Delete, - contentDescription = null, + contentDescription = stringResource( + R.string.delete_named_setting, + appSetting.label, + ), ) } }, ) } +@Composable +private fun ProtectionStatusItem( + app: ProtectedApp?, + checked: Boolean, + isServiceRunning: Boolean, + enabled: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + // Every branch describes THIS app only. Previously a different app's Starting/Restoring/ + // RecoveryRequired state leaked in here and made an unrelated screen look broken. + val status = when { + // Armed but not applied is the normal resting state: waiting for the app to be opened. + app == null && checked -> stringResource(R.string.protection_waiting) + + app == null -> stringResource(R.string.protection_inactive) + + app.status == ProtectionSessionStatus.STARTING -> + stringResource(R.string.protection_starting) + + app.status == ProtectionSessionStatus.RESTORING -> + stringResource(R.string.protection_restoring) + + app.status == ProtectionSessionStatus.RECOVERY_REQUIRED -> + stringResource(R.string.protection_recovery_required) + + app.pause.isPaused -> stringResource(R.string.protection_paused_status) + + app.mode == ProtectionMode.ONE_SHOT -> stringResource(R.string.one_shot_active) + + else -> stringResource(R.string.protection_active) + } + val supportingText = when { + app == null -> stringResource(R.string.keep_profile_active_summary) + + app.mode == ProtectionMode.FOREGROUND && + app.status == ProtectionSessionStatus.ACTIVE && + !isServiceRunning -> stringResource(R.string.background_protection_stopped) + + else -> stringResource(R.string.turn_off_to_edit) + } + + ListItem( + modifier = Modifier + .fillMaxWidth() + .toggleable( + value = checked, + enabled = enabled, + role = Role.Switch, + onValueChange = onCheckedChange, + ), + headlineContent = { Text(stringResource(R.string.keep_profile_active)) }, + overlineContent = { Text(status) }, + supportingContent = { Text(supportingText) }, + trailingContent = { + Switch( + checked = checked, + enabled = enabled, + onCheckedChange = null, + ) + }, + ) +} + private fun getNotification( context: Context, notificationId: Int, + sessionToken: String, componentName: String, icon: ByteArray?, contentTitle: String, @@ -760,16 +1200,26 @@ private fun getNotification( ): Notification { val revertIntent = Intent(context, RevertSettingsBroadcastReceiver::class.java).apply { action = ACTION_REVERT_SETTINGS + data = "geto://restore/${Uri.encode(sessionToken)}".toUri() putExtra(NOTIFICATION_EXTRA_COMPONENT_NAME, componentName) putExtra(NOTIFICATION_EXTRA_NOTIFICATION_ID, notificationId) + putExtra(NOTIFICATION_EXTRA_SESSION_TOKEN, sessionToken) } val revertPendingIntent = PendingIntent.getBroadcast( context, - 0, + sessionToken.hashCode(), revertIntent, FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE, ) + val contentPendingIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)?.let { + PendingIntent.getActivity( + context, + notificationId, + it, + FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE, + ) + } return NotificationCompat.Builder( context, @@ -790,6 +1240,10 @@ private fun getNotification( setContentTitle(contentTitle) setContentText(contentText) setPriority(NotificationCompat.PRIORITY_DEFAULT) + setCategory(NotificationCompat.CATEGORY_STATUS) + setOnlyAlertOnce(true) + setOngoing(true) + contentPendingIntent?.let(::setContentIntent) addAction( com.android.geto.framework.notificationmanager.R.drawable.baseline_settings_24, context.getString(com.android.geto.framework.notificationmanager.R.string.revert), diff --git a/feature/app-settings/src/main/kotlin/com/android/geto/feature/appsettings/AppSettingsViewModel.kt b/feature/app-settings/src/main/kotlin/com/android/geto/feature/appsettings/AppSettingsViewModel.kt index 583f4ddae..06de9dc4a 100644 --- a/feature/app-settings/src/main/kotlin/com/android/geto/feature/appsettings/AppSettingsViewModel.kt +++ b/feature/app-settings/src/main/kotlin/com/android/geto/feature/appsettings/AppSettingsViewModel.kt @@ -27,30 +27,36 @@ import com.android.geto.domain.framework.ShortcutManagerCompatWrapper import com.android.geto.domain.model.AddAppSettingResult import com.android.geto.domain.model.AppSetting import com.android.geto.domain.model.AppSettingTemplate -import com.android.geto.domain.model.AppSettingsResult import com.android.geto.domain.model.GetPinShortcutResult +import com.android.geto.domain.model.ProtectionMode +import com.android.geto.domain.model.ProtectionResult +import com.android.geto.domain.model.ProtectionState import com.android.geto.domain.model.RequestPinShortcutResult import com.android.geto.domain.model.SecureSetting import com.android.geto.domain.model.SettingType import com.android.geto.domain.model.UpdatePinShortcutResult import com.android.geto.domain.repository.AppSettingsRepository +import com.android.geto.domain.usecase.AddAppSettingTemplateUseCase import com.android.geto.domain.usecase.AddAppSettingUseCase -import com.android.geto.domain.usecase.ApplyAppSettingsUseCase import com.android.geto.domain.usecase.GetPinShortcutUseCase import com.android.geto.domain.usecase.GetSecureSettingsByNameUseCase +import com.android.geto.domain.usecase.ProtectionController import com.android.geto.domain.usecase.RequestPinShortcutUseCase -import com.android.geto.domain.usecase.RevertAppSettingsUseCase import com.android.geto.domain.usecase.UpdatePinShortcutUseCase import com.android.geto.feature.appsettings.navigation.AppSettingsRouteData +import com.android.geto.service.ProtectionService +import com.android.geto.service.ProtectionServiceManager import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject @HiltViewModel @@ -58,15 +64,16 @@ class AppSettingsViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val appSettingsRepository: AppSettingsRepository, private val packageManagerWrapper: PackageManagerWrapper, - private val applyAppSettingsUseCase: ApplyAppSettingsUseCase, - private val revertAppSettingsUseCase: RevertAppSettingsUseCase, + private val protectionController: ProtectionController, private val requestPinShortcutUseCase: RequestPinShortcutUseCase, private val addAppSettingUseCase: AddAppSettingUseCase, + private val addAppSettingTemplateUseCase: AddAppSettingTemplateUseCase, private val assetManagerWrapper: AssetManagerWrapper, private val getSecureSettingsByNameUseCase: GetSecureSettingsByNameUseCase, private val getPinShortcutUseCase: GetPinShortcutUseCase, private val shortcutManagerCompatWrapper: ShortcutManagerCompatWrapper, private val updatePinShortcutUseCase: UpdatePinShortcutUseCase, + private val protectionServiceManager: ProtectionServiceManager, ) : ViewModel() { private val appSettingsRouteData = savedStateHandle.toRoute() @@ -87,11 +94,21 @@ class AppSettingsViewModel @Inject constructor( private val _addAppSettingsResult = MutableStateFlow(null) val addAppSettingsResult = _addAppSettingsResult.asStateFlow() - private val _applyAppSettingsResult = MutableStateFlow(null) - val applyAppSettingsResult = _applyAppSettingsResult.asStateFlow() + private val _protectionActionResult = MutableStateFlow(null) + val protectionActionResult = _protectionActionResult.asStateFlow() + private val protectionActionInProgress = AtomicBoolean(false) - private val _revertAppSettingsResult = MutableStateFlow(null) - val revertAppSettingsResult = _revertAppSettingsResult.asStateFlow() + val protectionState = protectionController.state.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = ProtectionState.Empty, + ) + + val isProtectionServiceRunning = ProtectionService.isRunning.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = false, + ) private val _requestPinShortcutResult = MutableStateFlow(null) val requestPinShortcutResult = _requestPinShortcutResult.asStateFlow() @@ -119,12 +136,78 @@ class AppSettingsViewModel @Inject constructor( private val _updatePinShortcutResult = MutableStateFlow(null) val updatePinShortcutResult = _updatePinShortcutResult.asStateFlow() - fun applyAppSettings() { + /** + * Applies the profile and only then launches the app, so it cannot read a setting before the new + * value exists. Launching from the home screen has no such guarantee. + * + * An armed app is applied in [ProtectionMode.FOREGROUND] rather than [ProtectionMode.ONE_SHOT]: + * the coordinator is about to see this app come to the foreground and claim it, and a one-shot + * session it could not adopt used to leave the app stuck needing recovery. + */ + fun launchOnce() { + launchProtectionAction(ProtectionAction.LAUNCH_ONCE) { + val armed = componentName in protectionController.armedComponentNames.first() + val mode = if (armed) ProtectionMode.FOREGROUND else ProtectionMode.ONE_SHOT + protectionController.enable(componentName, mode) + } + } + + val isArmed = protectionController.armedComponentNames + .map { componentName in it } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = false, + ) + + /** + * Arms or disarms the profile. Arming no longer writes anything: the settings are applied when the + * app actually comes to the foreground and put back when it leaves. + */ + fun setForegroundProtection(enabled: Boolean) { + viewModelScope.launch { + if (enabled) { + protectionController.armProfile(componentName) + } else { + protectionController.disarmProfile(componentName) + } + } + } + + fun restoreProtection(sessionToken: String? = null) { + launchProtectionAction(ProtectionAction.RESTORE) { + sessionToken?.let { protectionController.restore(it) } + ?: ProtectionResult.NoActiveProtection + } + } + + private fun launchProtectionAction( + action: ProtectionAction, + operation: suspend () -> ProtectionResult, + ) { + if (!protectionActionInProgress.compareAndSet(false, true)) return + viewModelScope.launch { - _applyAppSettingsResult.update { applyAppSettingsUseCase(componentName = componentName) } + try { + val result = operation() + val app = (result as? ProtectionResult.Success)?.app + if (app?.mode == ProtectionMode.FOREGROUND) { + protectionServiceManager.onProtectionEnabled() + } + _protectionActionResult.value = ProtectionActionResult( + action = action, + result = result, + ) + } finally { + protectionActionInProgress.set(false) + } } } + fun resumeProtection() { + protectionServiceManager.onProtectionEnabled() + } + fun checkAppSetting(appSetting: AppSetting) { viewModelScope.launch { appSettingsRepository.upsertAppSetting(appSetting) @@ -145,15 +228,20 @@ class AppSettingsViewModel @Inject constructor( } } - fun getActivityIcon() { + fun addAppSettingTemplate(appSettingTemplate: AppSettingTemplate) { viewModelScope.launch { - _activityIcon.update { packageManagerWrapper.getActivityIcon(componentName = componentName) } + _addAppSettingsResult.update { + addAppSettingTemplateUseCase( + componentName = componentName, + template = appSettingTemplate, + ) + } } } - fun revertAppSettings() { + fun getActivityIcon() { viewModelScope.launch { - _revertAppSettingsResult.update { revertAppSettingsUseCase(componentName = componentName) } + _activityIcon.update { packageManagerWrapper.getActivityIcon(componentName = componentName) } } } @@ -220,18 +308,14 @@ class AppSettingsViewModel @Inject constructor( } } - fun resetApplyAppSettingsResult() { - _applyAppSettingsResult.update { null } + fun resetProtectionActionResult() { + _protectionActionResult.value = null } fun resetRequestPinShortcutResult() { _requestPinShortcutResult.update { null } } - fun resetRevertAppSettingsResult() { - _revertAppSettingsResult.update { null } - } - fun resetAddAppSettingResult() { _addAppSettingsResult.update { null } } @@ -244,3 +328,14 @@ class AppSettingsViewModel @Inject constructor( _updatePinShortcutResult.update { null } } } + +enum class ProtectionAction { + LAUNCH_ONCE, + ENABLE_PERSISTENT, + RESTORE, +} + +data class ProtectionActionResult( + val action: ProtectionAction, + val result: ProtectionResult, +) diff --git a/feature/app-settings/src/main/kotlin/com/android/geto/feature/appsettings/dialog/AppSettingDialog.kt b/feature/app-settings/src/main/kotlin/com/android/geto/feature/appsettings/dialog/AppSettingDialog.kt index fd68ee46a..5e88db5b2 100644 --- a/feature/app-settings/src/main/kotlin/com/android/geto/feature/appsettings/dialog/AppSettingDialog.kt +++ b/feature/app-settings/src/main/kotlin/com/android/geto/feature/appsettings/dialog/AppSettingDialog.kt @@ -44,7 +44,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment @@ -80,27 +80,23 @@ internal fun AppSettingDialog( text: String, ) -> Unit, ) { - var selectedRadioOptionIndex by remember { mutableIntStateOf(0) } + var selectedRadioOptionIndex by rememberSaveable { mutableIntStateOf(0) } - var label by remember { mutableStateOf("") } + var label by rememberSaveable { mutableStateOf("") } - var key by remember { mutableStateOf("") } + var key by rememberSaveable { mutableStateOf("") } - var valueOnLaunch by remember { mutableStateOf("") } + var valueOnLaunch by rememberSaveable { mutableStateOf("") } - var valueOnRevert by remember { mutableStateOf("") } + var valueOnRevert by rememberSaveable { mutableStateOf("") } - var showLabelError by remember { mutableStateOf(false) } + var showLabelError by rememberSaveable { mutableStateOf(false) } - var showKeyError by remember { mutableStateOf(false) } + var showKeyError by rememberSaveable { mutableStateOf(false) } - var showKeyNotFoundError by remember { mutableStateOf(false) } + var showValueOnLaunchError by rememberSaveable { mutableStateOf(false) } - var showValueOnLaunchError by remember { mutableStateOf(false) } - - var showValueOnRevertError by remember { mutableStateOf(false) } - - var secureSettingsExpanded by remember { mutableStateOf(false) } + var secureSettingsExpanded by rememberSaveable { mutableStateOf(false) } LaunchedEffect(key1 = Unit) { snapshotFlow { key } @@ -156,12 +152,10 @@ internal fun AppSettingDialog( secureSettings = secureSettings, secureSettingsExpanded = secureSettingsExpanded, showKeyError = showKeyError, - showKeyNotFoundError = showKeyNotFoundError, + showKeyNotFoundError = false, showLabelError = showLabelError, showValueOnLaunchError = showValueOnLaunchError, - showValueOnRevertError = showValueOnRevertError, valueOnLaunch = valueOnLaunch, - valueOnRevert = valueOnRevert, onUpdateKey = { key = it }, @@ -186,18 +180,11 @@ internal fun AppSettingDialog( showKeyError = key.isBlank() - showKeyNotFoundError = - key.isNotBlank() && !secureSettings.mapNotNull { it.name }.contains(key) - showValueOnLaunchError = valueOnLaunch.isBlank() - showValueOnRevertError = valueOnRevert.isBlank() - if (!showLabelError && - !showKeyNotFoundError && !showKeyError && - !showValueOnLaunchError && - !showValueOnRevertError + !showValueOnLaunchError ) { onAddAppSetting( AppSetting( @@ -270,9 +257,7 @@ private fun AppSettingDialogTextFields( showKeyNotFoundError: Boolean, showLabelError: Boolean, showValueOnLaunchError: Boolean, - showValueOnRevertError: Boolean, valueOnLaunch: String, - valueOnRevert: String, onUpdateKey: (String) -> Unit, onUpdateLabel: (String) -> Unit, onUpdateSecureSettingsExpanded: (Boolean) -> Unit, @@ -283,8 +268,6 @@ private fun AppSettingDialogTextFields( val valueOnLaunchIsBlank = stringResource(id = R.string.setting_value_on_launch_is_blank) - val valueOnRevertIsBlank = stringResource(id = R.string.setting_value_on_revert_is_blank) - OutlinedTextField( modifier = Modifier .fillMaxWidth() @@ -339,30 +322,7 @@ private fun AppSettingDialogTextFields( null }, singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), - ) - - Spacer(modifier = Modifier.height(5.dp)) - - OutlinedTextField( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 10.dp), - value = valueOnRevert, - onValueChange = onUpdateValueOnRevert, - label = { - Text(text = stringResource(R.string.setting_value_on_revert)) - }, - isError = showValueOnRevertError, - supportingText = if (showValueOnRevertError) { - { - Text(text = valueOnRevertIsBlank) - } - } else { - null - }, - singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), ) } diff --git a/feature/app-settings/src/main/kotlin/com/android/geto/feature/appsettings/dialog/ShortcutDialog.kt b/feature/app-settings/src/main/kotlin/com/android/geto/feature/appsettings/dialog/ShortcutDialog.kt index f65b893d2..c8a11dda1 100644 --- a/feature/app-settings/src/main/kotlin/com/android/geto/feature/appsettings/dialog/ShortcutDialog.kt +++ b/feature/app-settings/src/main/kotlin/com/android/geto/feature/appsettings/dialog/ShortcutDialog.kt @@ -35,7 +35,7 @@ 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 @@ -59,13 +59,13 @@ internal fun RequestPinShortcutDialog( longLabel: String, ) -> Unit, ) { - var shortLabel by remember { mutableStateOf("") } + var shortLabel by rememberSaveable { mutableStateOf("") } - var showShortLabelError by remember { mutableStateOf(false) } + var showShortLabelError by rememberSaveable { mutableStateOf(false) } - var longLabel by remember { mutableStateOf("") } + var longLabel by rememberSaveable { mutableStateOf("") } - var showLongLabelError by remember { mutableStateOf(false) } + var showLongLabelError by rememberSaveable { mutableStateOf(false) } DialogContainer( modifier = modifier.verticalScroll(rememberScrollState()), @@ -89,7 +89,7 @@ internal fun RequestPinShortcutDialog( .size(50.dp) .align(Alignment.CenterHorizontally), model = icon, - contentDescription = null, + contentDescription = stringResource(R.string.app_icon), ) Spacer(modifier = Modifier.height(10.dp)) @@ -143,13 +143,13 @@ internal fun UpdatePinShortcutDialog( longLabel: String, ) -> Unit, ) { - var shortLabel by remember { mutableStateOf(getoShortcutInfoCompat.shortLabel) } + var shortLabel by rememberSaveable { mutableStateOf(getoShortcutInfoCompat.shortLabel) } - var showShortLabelError by remember { mutableStateOf(false) } + var showShortLabelError by rememberSaveable { mutableStateOf(false) } - var longLabel by remember { mutableStateOf(getoShortcutInfoCompat.longLabel) } + var longLabel by rememberSaveable { mutableStateOf(getoShortcutInfoCompat.longLabel) } - var showLongLabelError by remember { mutableStateOf(false) } + var showLongLabelError by rememberSaveable { mutableStateOf(false) } DialogContainer( modifier = modifier.verticalScroll(rememberScrollState()), @@ -173,7 +173,7 @@ internal fun UpdatePinShortcutDialog( .size(50.dp) .align(Alignment.CenterHorizontally), model = icon, - contentDescription = null, + contentDescription = stringResource(R.string.app_icon), ) Spacer(modifier = Modifier.height(10.dp)) diff --git a/feature/app-settings/src/main/kotlin/com/android/geto/feature/appsettings/dialog/TemplateDialog.kt b/feature/app-settings/src/main/kotlin/com/android/geto/feature/appsettings/dialog/TemplateDialog.kt index 1e2760771..30be80cb5 100644 --- a/feature/app-settings/src/main/kotlin/com/android/geto/feature/appsettings/dialog/TemplateDialog.kt +++ b/feature/app-settings/src/main/kotlin/com/android/geto/feature/appsettings/dialog/TemplateDialog.kt @@ -22,33 +22,39 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +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.material3.AlertDialog import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme 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.saveable.rememberSaveable +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.unit.dp import com.android.geto.designsystem.component.DialogContainer import com.android.geto.designsystem.icon.GetoIcons -import com.android.geto.domain.model.AppSetting import com.android.geto.domain.model.AppSettingTemplate import com.android.geto.feature.appsettings.R -import com.android.geto.feature.appsettings.getSettingTypeTitle @Composable internal fun TemplateDialog( modifier: Modifier = Modifier, appSettingTemplates: List, - componentName: String, - onAddAppSetting: (AppSetting) -> Unit, + onAddTemplate: (AppSettingTemplate) -> Unit, onDismissRequest: () -> Unit, ) { + var templateAwaitingConfirmationId by rememberSaveable { mutableStateOf(null) } + DialogContainer( modifier = modifier, onDismissRequest = onDismissRequest, @@ -56,6 +62,7 @@ internal fun TemplateDialog( Column( modifier = Modifier .fillMaxWidth() + .heightIn(max = 560.dp) .padding(10.dp), ) { Text( @@ -64,27 +71,64 @@ internal fun TemplateDialog( style = MaterialTheme.typography.titleLarge, ) - LazyColumn(modifier = Modifier.fillMaxWidth()) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f, fill = false), + ) { items(appSettingTemplates) { appSettingTemplate -> AppSettingTemplateItem( appSettingTemplate = appSettingTemplate, - componentName = componentName, - onAddAppSetting = onAddAppSetting, - onDismissRequest = onDismissRequest, + onAddTemplate = { template -> + if (template.warning == null) { + onAddTemplate(template) + } else { + templateAwaitingConfirmationId = template.id + } + }, ) } } + + TextButton( + modifier = Modifier.align(Alignment.End), + onClick = onDismissRequest, + ) { + Text(stringResource(R.string.close)) + } } } + + templateAwaitingConfirmationId?.let { templateId -> + val template = appSettingTemplates.firstOrNull { it.id == templateId } ?: return@let + AlertDialog( + onDismissRequest = { templateAwaitingConfirmationId = null }, + title = { Text(template.label) }, + text = { Text(template.warning.orEmpty()) }, + confirmButton = { + TextButton( + onClick = { + onAddTemplate(template) + templateAwaitingConfirmationId = null + }, + ) { + Text(stringResource(com.android.geto.common.R.string.add)) + } + }, + dismissButton = { + TextButton(onClick = { templateAwaitingConfirmationId = null }) { + Text(stringResource(com.android.geto.common.R.string.cancel)) + } + }, + ) + } } @Composable private fun AppSettingTemplateItem( modifier: Modifier = Modifier, appSettingTemplate: AppSettingTemplate, - componentName: String, - onAddAppSetting: (AppSetting) -> Unit, - onDismissRequest: () -> Unit, + onAddTemplate: (AppSettingTemplate) -> Unit, ) { Row( modifier = modifier.padding(10.dp), @@ -99,38 +143,29 @@ private fun AppSettingTemplateItem( Spacer(modifier = Modifier.height(5.dp)) Text( - text = appSettingTemplate.settingType.getSettingTypeTitle(), + text = appSettingTemplate.description, style = MaterialTheme.typography.bodySmall, ) Spacer(modifier = Modifier.height(5.dp)) Text( - text = appSettingTemplate.key, + text = appSettingTemplate.entries.joinToString { it.key }, style = MaterialTheme.typography.bodySmall, ) } IconButton( onClick = { - onAddAppSetting( - AppSetting( - enabled = true, - settingType = appSettingTemplate.settingType, - componentName = componentName, - label = appSettingTemplate.label, - key = appSettingTemplate.key, - valueOnLaunch = appSettingTemplate.valueOnLaunch, - valueOnRevert = appSettingTemplate.valueOnRevert, - ), - ) - - onDismissRequest() + onAddTemplate(appSettingTemplate) }, ) { Icon( imageVector = GetoIcons.Add, - contentDescription = null, + contentDescription = stringResource( + R.string.add_named_template, + appSettingTemplate.label, + ), ) } } diff --git a/feature/app-settings/src/main/kotlin/com/android/geto/feature/appsettings/dialog/WriteSecureSettingsDialog.kt b/feature/app-settings/src/main/kotlin/com/android/geto/feature/appsettings/dialog/WriteSecureSettingsDialog.kt index 59e0b9e22..243ea81f7 100644 --- a/feature/app-settings/src/main/kotlin/com/android/geto/feature/appsettings/dialog/WriteSecureSettingsDialog.kt +++ b/feature/app-settings/src/main/kotlin/com/android/geto/feature/appsettings/dialog/WriteSecureSettingsDialog.kt @@ -17,6 +17,8 @@ */ package com.android.geto.feature.appsettings.dialog +import android.content.ClipData +import android.content.ClipboardManager import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -30,6 +32,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.dp @@ -41,6 +44,9 @@ internal fun WriteSecureSettingsDialog( modifier: Modifier = Modifier, onDismissRequest: () -> Unit, ) { + val context = LocalContext.current + val command = "pm grant ${context.packageName} android.permission.WRITE_SECURE_SETTINGS" + DialogContainer( modifier = modifier.verticalScroll(rememberScrollState()), content = { @@ -63,7 +69,7 @@ internal fun WriteSecureSettingsDialog( SelectionContainer { Text( modifier = Modifier.padding(15.dp), - text = "pm grant com.android.geto android.permission.WRITE_SECURE_SETTINGS", + text = command, style = MaterialTheme.typography.bodyMedium.copy( fontStyle = FontStyle.Italic, ), @@ -74,8 +80,16 @@ internal fun WriteSecureSettingsDialog( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, ) { + TextButton( + onClick = { + context.getSystemService(ClipboardManager::class.java) + .setPrimaryClip(ClipData.newPlainText("ADB command", command)) + }, + ) { + Text(text = stringResource(R.string.copy)) + } TextButton(onClick = onDismissRequest) { - Text(text = "Okay") + Text(text = stringResource(R.string.okay)) } } } diff --git a/feature/app-settings/src/main/res/values/strings.xml b/feature/app-settings/src/main/res/values/strings.xml index 72dc15e6c..c363e51e8 100644 --- a/feature/app-settings/src/main/res/values/strings.xml +++ b/feature/app-settings/src/main/res/values/strings.xml @@ -16,10 +16,10 @@ ~ --> - Please enable atleast one setting + Please enable at least one setting No settings found Geto Settings - Settings apply success + Settings applied Settings apply failed Settings revert failed Settings reverted @@ -30,6 +30,7 @@ Setting value on launch Setting value on revert Templates + Close Add Shortcut Update Shortcut Short label @@ -53,4 +54,46 @@ App setting added successfully Please grant the WRITE_SECURE_SETTINGS permission via ADB Copy the command below and run it via ADB to grant the Write Secure Settings permission - \ No newline at end of file + Copy + Okay + Back + Restore settings + Add setting + Manage shortcut + Add template + Add %1$s template + Launch once + Delete setting + Delete %1$s setting + App icon + Apply while this app is open + Geto applies these values when you open this app and puts your originals back when you leave. + Protection active + Starting protection… + Restoring original settings… + Protection needs attention + Protection off + Turn off protection before editing this profile. + Switch protected app? + Switch profile + Restore the original settings for %1$s, then switch protection to %2$s? + Turn off protection + Retry restore + Allow notifications before applying settings so Geto can always offer recovery. + Notification settings + The target app could not be opened. Geto is restoring the original settings. + The target app could not be opened, and some original settings still need recovery. Use Restore below. + Some original values could not be restored. Open Geto and retry. + Setting deleted + Undo + Settings applied until restored + Another app profile is active + The profile is still applied, but background monitoring stopped. + Resume monitoring + Another app + Setting already in use + %1$s already protects %2$s with the value %3$s. This profile wants %4$s. Turn off %1$s, or change this setting to match. + Got it + Paused + Waiting — applies when you open this app + diff --git a/feature/apps/build.gradle.kts b/feature/apps/build.gradle.kts index 6fa65016b..5e8a6724d 100644 --- a/feature/apps/build.gradle.kts +++ b/feature/apps/build.gradle.kts @@ -31,4 +31,9 @@ dependencies { implementation(projects.domain.framework) implementation(projects.domain.repository) implementation(projects.domain.useCase) + implementation(libs.androidx.activity.compose) + + testImplementation(kotlin("test")) + testImplementation(libs.junit4) + testImplementation(libs.kotlinx.coroutines.test) } diff --git a/feature/apps/src/main/kotlin/com/android/geto/feature/apps/AppsScreen.kt b/feature/apps/src/main/kotlin/com/android/geto/feature/apps/AppsScreen.kt index 4be9d190c..a649d8d03 100644 --- a/feature/apps/src/main/kotlin/com/android/geto/feature/apps/AppsScreen.kt +++ b/feature/apps/src/main/kotlin/com/android/geto/feature/apps/AppsScreen.kt @@ -17,10 +17,13 @@ */ package com.android.geto.feature.apps +import androidx.activity.compose.ReportDrawnWhen import androidx.annotation.VisibleForTesting 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -34,39 +37,47 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SearchBar import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.rememberSearchBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.ImageLoader import coil.compose.AsyncImage import com.android.geto.designsystem.icon.GetoIcons +import com.android.geto.domain.model.LauncherAppIcon import com.android.geto.domain.model.LauncherAppsActivityInfo import com.android.geto.domain.model.LauncherAppsActivityInfoData import com.android.geto.domain.model.SortLauncherAppsActivityInfo import com.android.geto.domain.model.SortOrderLauncherAppsActivityInfo import com.android.geto.feature.apps.dialog.SortLauncherAppsActivityInfoDialog -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import kotlin.time.Duration.Companion.milliseconds @Composable internal fun AppsRoute( @@ -79,11 +90,15 @@ internal fun AppsRoute( ) { val appListUiState by viewModel.appsUiState.collectAsStateWithLifecycle() + ReportDrawnWhen { appListUiState !is AppsUiState.Loading } + AppsScreen( modifier = modifier, appsUiState = appListUiState, + imageLoader = viewModel.imageLoader, onClickApp = onClickApp, onSearch = viewModel::search, + onRetry = viewModel::retry, onUpdateSortLauncherAppsActivityInfo = viewModel::updateSortLauncherAppsActivityInfo, onUpdateSortOrderLauncherAppsActivityInfo = viewModel::updateSortOrderLauncherAppsActivityInfo, onUpdateShowSystem = viewModel::updateShowSystem, @@ -96,11 +111,13 @@ internal fun AppsRoute( internal fun AppsScreen( modifier: Modifier = Modifier, appsUiState: AppsUiState, + imageLoader: ImageLoader, onClickApp: ( componentName: String, activityLabel: String, ) -> Unit, onSearch: (String) -> Unit, + onRetry: () -> Unit, onUpdateSortLauncherAppsActivityInfo: (SortLauncherAppsActivityInfo) -> Unit, onUpdateSortOrderLauncherAppsActivityInfo: (SortOrderLauncherAppsActivityInfo) -> Unit, onUpdateShowSystem: (Boolean) -> Unit, @@ -112,55 +129,80 @@ internal fun AppsScreen( } is AppsUiState.Success -> { - Success( - modifier = modifier, + AppsContent( launcherAppsActivityInfoData = appsUiState.launcherAppsActivityInfoData, + searchQuery = appsUiState.searchQuery, + appTags = appsUiState.appTags, + imageLoader = imageLoader, onClickApp = onClickApp, onSearch = onSearch, + onRetry = onRetry, + showLoadError = false, onUpdateSortLauncherAppsActivityInfo = onUpdateSortLauncherAppsActivityInfo, onUpdateSortOrderLauncherAppsActivityInfo = onUpdateSortOrderLauncherAppsActivityInfo, onUpdateShowSystem = onUpdateShowSystem, ) } + + is AppsUiState.Error -> { + val previousData = appsUiState.previousData + if (previousData == null) { + InitialLoadError(onRetry = onRetry) + } else { + AppsContent( + launcherAppsActivityInfoData = previousData, + searchQuery = appsUiState.searchQuery, + appTags = appsUiState.appTags, + imageLoader = imageLoader, + onClickApp = onClickApp, + onSearch = onSearch, + onRetry = onRetry, + showLoadError = true, + onUpdateSortLauncherAppsActivityInfo = onUpdateSortLauncherAppsActivityInfo, + onUpdateSortOrderLauncherAppsActivityInfo = onUpdateSortOrderLauncherAppsActivityInfo, + onUpdateShowSystem = onUpdateShowSystem, + ) + } + } } } } -@OptIn(FlowPreview::class, ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun Success( - modifier: Modifier = Modifier, +private fun AppsContent( launcherAppsActivityInfoData: LauncherAppsActivityInfoData, + searchQuery: String, + appTags: AppTags, + imageLoader: ImageLoader, onClickApp: ( componentName: String, activityLabel: String, ) -> Unit, onSearch: (String) -> Unit, + onRetry: () -> Unit, + showLoadError: Boolean, onUpdateSortLauncherAppsActivityInfo: (SortLauncherAppsActivityInfo) -> Unit, onUpdateSortOrderLauncherAppsActivityInfo: (SortOrderLauncherAppsActivityInfo) -> Unit, onUpdateShowSystem: (Boolean) -> Unit, ) { val searchBarState = rememberSearchBarState() - - val textFieldState = rememberTextFieldState() - + val textFieldState = rememberTextFieldState(initialText = searchQuery) val scope = rememberCoroutineScope() + var showSortLauncherAppsActivityInfoDialog by rememberSaveable { androidx.compose.runtime.mutableStateOf(false) } - var showSortLauncherAppsActivityInfoDialog by remember { mutableStateOf(false) } - - LaunchedEffect(key1 = textFieldState) { - snapshotFlow { textFieldState.text }.debounce(500.milliseconds) + LaunchedEffect(textFieldState) { + snapshotFlow { textFieldState.text } + .map { it.toString() } .distinctUntilChanged() - .onEach { - onSearch(it.toString()) - }.collect() + .collect(onSearch) } - Column(modifier = modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxSize()) { SearchBar( - modifier = modifier + modifier = Modifier .fillMaxWidth() - .padding(5.dp), + .padding(horizontal = 8.dp, vertical = 4.dp), state = searchBarState, inputField = { SearchBarDefaults.InputField( @@ -173,38 +215,65 @@ private fun Success( ) }, trailingIcon = { - IconButton( - onClick = { - showSortLauncherAppsActivityInfoDialog = true - }, - ) { + IconButton(onClick = { showSortLauncherAppsActivityInfoDialog = true }) { Icon( imageVector = GetoIcons.Sort, - contentDescription = null, + contentDescription = stringResource(R.string.sort_and_filter), ) } }, onSearch = { - scope.launch { - searchBarState.animateToCollapsed() - } - }, - placeholder = { - Text(text = stringResource(R.string.search)) + scope.launch { searchBarState.animateToCollapsed() } }, + placeholder = { Text(text = stringResource(R.string.search)) }, ) }, ) - LazyVerticalGrid( - columns = GridCells.Adaptive(300.dp), - modifier = Modifier.fillMaxSize(), - ) { - items(items = launcherAppsActivityInfoData.launcherAppsActivityInfos) { launcherAppsActivityInfo -> - AppItem( - launcherAppsActivityInfo = launcherAppsActivityInfo, - onClickApp = onClickApp, - ) + if (showLoadError) { + ListItem( + modifier = Modifier.semantics { + liveRegion = LiveRegionMode.Polite + }, + headlineContent = { Text(text = stringResource(R.string.apps_refresh_failed)) }, + supportingContent = { Text(text = stringResource(R.string.showing_previous_apps)) }, + trailingContent = { + TextButton(onClick = onRetry) { + Text(text = stringResource(R.string.retry)) + } + }, + ) + } + + val activities = launcherAppsActivityInfoData.launcherAppsActivityInfos + if (activities.isEmpty()) { + EmptyState( + modifier = Modifier.weight(1f), + hasSearchQuery = searchQuery.isNotBlank(), + ) + } else { + LazyVerticalGrid( + columns = GridCells.Adaptive(300.dp), + modifier = Modifier.weight(1f), + ) { + items( + items = activities, + key = { it.componentName }, + contentType = { "launcher-app" }, + ) { launcherAppsActivityInfo -> + AppItem( + launcherAppsActivityInfo = launcherAppsActivityInfo, + settingCount = appTags.settingCountByComponentName[ + launcherAppsActivityInfo.componentName, + ] ?: 0, + isArmed = launcherAppsActivityInfo.componentName in + appTags.armedComponentNames, + isProtected = launcherAppsActivityInfo.componentName in + appTags.protectedComponentNames, + imageLoader = imageLoader, + onClickApp = onClickApp, + ) + } } } } @@ -214,9 +283,7 @@ private fun Success( sortLauncherAppsActivityInfo = launcherAppsActivityInfoData.userData.sortLauncherAppsActivityInfo, sortOrderLauncherAppsActivityInfo = launcherAppsActivityInfoData.userData.sortOrderLauncherAppsActivityInfo, showSystem = launcherAppsActivityInfoData.userData.showSystem, - onDismissRequest = { - showSortLauncherAppsActivityInfoDialog = false - }, + onDismissRequest = { showSortLauncherAppsActivityInfoDialog = false }, onUpdateSortLauncherAppsActivityInfo = onUpdateSortLauncherAppsActivityInfo, onUpdateSortOrderLauncherAppsActivityInfo = onUpdateSortOrderLauncherAppsActivityInfo, onUpdateShowSystem = onUpdateShowSystem, @@ -224,39 +291,160 @@ private fun Success( } } +@Composable +private fun InitialLoadError(onRetry: () -> Unit) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + modifier = Modifier.padding(top = 48.dp), + text = stringResource(R.string.apps_load_failed), + style = MaterialTheme.typography.titleMedium, + ) + TextButton(onClick = onRetry) { + Text(text = stringResource(R.string.retry)) + } + } +} + +@Composable +private fun EmptyState( + modifier: Modifier = Modifier, + hasSearchQuery: Boolean, +) { + Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Text( + modifier = Modifier.padding(24.dp), + text = stringResource(if (hasSearchQuery) R.string.no_search_results else R.string.no_apps), + style = MaterialTheme.typography.bodyLarge, + ) + } +} + @Composable private fun AppItem( modifier: Modifier = Modifier, launcherAppsActivityInfo: LauncherAppsActivityInfo, + settingCount: Int, + isArmed: Boolean, + isProtected: Boolean, + imageLoader: ImageLoader, onClickApp: ( componentName: String, activityLabel: String, ) -> Unit, ) { + val fallbackIcon = rememberVectorPainter(GetoIcons.Android) ListItem( - modifier = modifier - .clickable { - onClickApp( - launcherAppsActivityInfo.componentName, - launcherAppsActivityInfo.activityLabel, - ) - }, + modifier = modifier.clickable { + onClickApp( + launcherAppsActivityInfo.componentName, + launcherAppsActivityInfo.activityLabel, + ) + }, headlineContent = { Text( text = launcherAppsActivityInfo.activityLabel, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) }, supportingContent = { Text( text = launcherAppsActivityInfo.packageName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) }, leadingContent = { AsyncImage( modifier = Modifier.size(50.dp), - model = launcherAppsActivityInfo.activityIcon, + model = LauncherAppIcon( + componentName = launcherAppsActivityInfo.componentName, + lastUpdateTime = launcherAppsActivityInfo.lastUpdateTime, + ), + imageLoader = imageLoader, + placeholder = fallbackIcon, + error = fallbackIcon, + fallback = fallbackIcon, contentDescription = null, ) }, + trailingContent = { + AppTagsBadge( + settingCount = settingCount, + isArmed = isArmed, + isProtected = isProtected, + ) + }, ) } + +/** + * Two distinct signals: a quiet tag for apps that merely have settings saved, and a stronger one for + * the apps Geto is actively protecting right now. + */ +@Composable +private fun AppTagsBadge(settingCount: Int, isArmed: Boolean, isProtected: Boolean) { + if (settingCount == 0 && !isArmed && !isProtected) return + + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (settingCount > 0) { + Tag( + text = settingCount.toString(), + contentDescription = pluralStringResource( + R.plurals.settings_configured, + settingCount, + settingCount, + ), + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Applied right now outranks merely armed, so only the strongest state is shown. + if (isProtected) { + Tag( + text = stringResource(R.string.tag_active), + contentDescription = stringResource(R.string.tag_active_description), + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ) + } else if (isArmed) { + Tag( + text = stringResource(R.string.tag_armed), + contentDescription = stringResource(R.string.tag_armed_description), + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } +} + +@Composable +private fun Tag( + text: String, + contentDescription: String, + containerColor: Color, + contentColor: Color, +) { + Surface( + shape = MaterialTheme.shapes.small, + color = containerColor, + contentColor = contentColor, + ) { + Text( + modifier = Modifier + .semantics { this.contentDescription = contentDescription } + .padding(horizontal = 8.dp, vertical = 2.dp), + text = text, + style = MaterialTheme.typography.labelSmall, + ) + } +} diff --git a/feature/apps/src/main/kotlin/com/android/geto/feature/apps/AppsUiState.kt b/feature/apps/src/main/kotlin/com/android/geto/feature/apps/AppsUiState.kt index c4f180aab..16f4a5fad 100644 --- a/feature/apps/src/main/kotlin/com/android/geto/feature/apps/AppsUiState.kt +++ b/feature/apps/src/main/kotlin/com/android/geto/feature/apps/AppsUiState.kt @@ -20,7 +20,29 @@ package com.android.geto.feature.apps import com.android.geto.domain.model.LauncherAppsActivityInfoData sealed interface AppsUiState { - data class Success(val launcherAppsActivityInfoData: LauncherAppsActivityInfoData) : AppsUiState + data class Success( + val launcherAppsActivityInfoData: LauncherAppsActivityInfoData, + val searchQuery: String, + val appTags: AppTags = AppTags(), + ) : AppsUiState + + data class Error( + val previousData: LauncherAppsActivityInfoData?, + val searchQuery: String, + val appTags: AppTags = AppTags(), + ) : AppsUiState data object Loading : AppsUiState } + +/** + * What to show beside each app in the list: how many Geto settings it has saved, and whether it is + * protected right now. Keyed by component name, matching the grid's own key. + */ +data class AppTags( + val settingCountByComponentName: Map = emptyMap(), + /** Set up to apply when opened, whether or not it is applied at this moment. */ + val armedComponentNames: Set = emptySet(), + /** Applied right now, i.e. this app is in the foreground. */ + val protectedComponentNames: Set = emptySet(), +) diff --git a/feature/apps/src/main/kotlin/com/android/geto/feature/apps/AppsViewModel.kt b/feature/apps/src/main/kotlin/com/android/geto/feature/apps/AppsViewModel.kt index 61416d32d..c54ce9168 100644 --- a/feature/apps/src/main/kotlin/com/android/geto/feature/apps/AppsViewModel.kt +++ b/feature/apps/src/main/kotlin/com/android/geto/feature/apps/AppsViewModel.kt @@ -19,35 +19,109 @@ package com.android.geto.feature.apps import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import coil.ImageLoader +import com.android.geto.domain.model.LauncherAppsActivityInfoData import com.android.geto.domain.model.SortLauncherAppsActivityInfo import com.android.geto.domain.model.SortOrderLauncherAppsActivityInfo +import com.android.geto.domain.repository.AppSettingsRepository import com.android.geto.domain.repository.UserDataRepository import com.android.geto.domain.usecase.GetLauncherAppsActivityInfosUseCase +import com.android.geto.domain.usecase.ProtectionController import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel +@OptIn(ExperimentalCoroutinesApi::class) class AppsViewModel @Inject constructor( - getLauncherAppsActivityInfosUseCase: GetLauncherAppsActivityInfosUseCase, + private val getLauncherAppsActivityInfosUseCase: GetLauncherAppsActivityInfosUseCase, private val userDataRepository: UserDataRepository, + private val appSettingsRepository: AppSettingsRepository, + private val protectionController: ProtectionController, + val imageLoader: ImageLoader, ) : ViewModel() { - private var _textFlow = MutableStateFlow(null) + private val searchText = MutableStateFlow("") + private var lastSuccessfulData: LauncherAppsActivityInfoData? = null - val appsUiState = - getLauncherAppsActivityInfosUseCase(textFlow = _textFlow).map(AppsUiState::Success).stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(5000), - AppsUiState.Loading, + private val debouncedSearchText = searchText + .flatMapLatest { text -> + if (text.isBlank()) { + flowOf("") + } else { + flow { + delay(SEARCH_DEBOUNCE_MILLIS) + emit(text.trim()) + } + } + } + + /** Settings counts and live protection, folded into one object so the list stays a plain map. */ + private val appTags = combine( + appSettingsRepository.appSettingsFlow, + protectionController.armedComponentNames, + protectionController.state, + ) { appSettings, armed, protectionState -> + AppTags( + settingCountByComponentName = appSettings.groupingBy { it.componentName }.eachCount(), + armedComponentNames = armed, + protectedComponentNames = protectionState.apps.mapTo(mutableSetOf()) { + it.componentName + }, ) + }.distinctUntilChanged() + + val appsUiState = combine( + getLauncherAppsActivityInfosUseCase(), + debouncedSearchText, + appTags, + ) { result, query, tags -> + result.fold( + onSuccess = { data -> + lastSuccessfulData = data + AppsUiState.Success( + launcherAppsActivityInfoData = data.filteredBy(query), + searchQuery = query, + appTags = tags, + ) + }, + onFailure = { + AppsUiState.Error( + previousData = lastSuccessfulData?.filteredBy(query), + searchQuery = query, + appTags = tags, + ) + }, + ) + }.catch { + emit( + AppsUiState.Error( + previousData = lastSuccessfulData?.filteredBy(searchText.value), + searchQuery = searchText.value, + ), + ) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + AppsUiState.Loading, + ) fun search(text: String) { - _textFlow.update { text } + searchText.value = text + } + + fun retry() { + getLauncherAppsActivityInfosUseCase.refresh() } fun updateSortLauncherAppsActivityInfo(sortLauncherAppsActivityInfo: SortLauncherAppsActivityInfo) { @@ -69,4 +143,19 @@ class AppsViewModel @Inject constructor( userDataRepository.updateShowSystem(showSystem = showSystem) } } + + private fun LauncherAppsActivityInfoData.filteredBy(query: String): LauncherAppsActivityInfoData { + if (query.isBlank()) return this + + return copy( + launcherAppsActivityInfos = launcherAppsActivityInfos.filter { activityInfo -> + activityInfo.activityLabel.contains(query, ignoreCase = true) || + activityInfo.packageName.contains(query, ignoreCase = true) + }, + ) + } + + private companion object { + const val SEARCH_DEBOUNCE_MILLIS = 250L + } } diff --git a/feature/apps/src/main/kotlin/com/android/geto/feature/apps/dialog/SortLauncherAppsActivityInfoDialog.kt b/feature/apps/src/main/kotlin/com/android/geto/feature/apps/dialog/SortLauncherAppsActivityInfoDialog.kt index 398a3c2b1..37547103e 100644 --- a/feature/apps/src/main/kotlin/com/android/geto/feature/apps/dialog/SortLauncherAppsActivityInfoDialog.kt +++ b/feature/apps/src/main/kotlin/com/android/geto/feature/apps/dialog/SortLauncherAppsActivityInfoDialog.kt @@ -17,7 +17,6 @@ */ package com.android.geto.feature.apps.dialog -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -26,6 +25,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SegmentedButton @@ -38,11 +38,12 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf 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.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import com.android.geto.designsystem.component.DialogContainer import com.android.geto.designsystem.theme.supportsDynamicTheming @@ -62,19 +63,19 @@ internal fun SortLauncherAppsActivityInfoDialog( onUpdateSortOrderLauncherAppsActivityInfo: (SortOrderLauncherAppsActivityInfo) -> Unit, onUpdateShowSystem: (Boolean) -> Unit, ) { - var selectedSortLauncherAppsActivityInfoIndex by remember { + var selectedSortLauncherAppsActivityInfoIndex by rememberSaveable { mutableIntStateOf( SortLauncherAppsActivityInfo.entries.indexOf(sortLauncherAppsActivityInfo), ) } - var selectedSortOrderLauncherAppsActivityInfoIndex by remember { + var selectedSortOrderLauncherAppsActivityInfoIndex by rememberSaveable { mutableIntStateOf( SortOrderLauncherAppsActivityInfo.entries.indexOf(sortOrderLauncherAppsActivityInfo), ) } - var selectedShowSystem by remember { mutableStateOf(showSystem) } + var selectedShowSystem by rememberSaveable { mutableStateOf(showSystem) } DialogContainer( modifier = modifier.verticalScroll(rememberScrollState()), @@ -87,7 +88,7 @@ internal fun SortLauncherAppsActivityInfoDialog( ) { Text( modifier = Modifier.padding(10.dp), - text = stringResource(R.string.sort), + text = stringResource(R.string.sort_and_filter), style = MaterialTheme.typography.titleLarge, ) @@ -133,6 +134,12 @@ private fun SortLauncherAppsActivityInfoDialogSelection( onUpdateSelectedSortLauncherAppsActivityInfoIndex: (Int) -> Unit, onUpdateSelectedSortOrderLauncherAppsActivityInfoIndex: (Int) -> Unit, ) { + Text( + modifier = Modifier.padding(bottom = 6.dp), + text = stringResource(R.string.sort_by), + style = MaterialTheme.typography.labelLarge, + ) + SingleChoiceSegmentedButtonRow { SortLauncherAppsActivityInfo.entries.forEachIndexed { index, sortLauncherAppsActivityInfo -> SegmentedButton( @@ -150,6 +157,12 @@ private fun SortLauncherAppsActivityInfoDialogSelection( Spacer(modifier = Modifier.height(10.dp)) + Text( + modifier = Modifier.padding(bottom = 6.dp), + text = stringResource(R.string.order), + style = MaterialTheme.typography.labelLarge, + ) + SingleChoiceSegmentedButtonRow { SortOrderLauncherAppsActivityInfo.entries.forEachIndexed { index, sortOrderLauncherAppsActivityInfo -> SegmentedButton( @@ -226,9 +239,11 @@ private fun ShowSystemSetting( Row( modifier = modifier - .clickable { - onUpdateShowSystem(!showSystem) - } + .toggleable( + value = showSystem, + role = Role.Switch, + onValueChange = onUpdateShowSystem, + ) .fillMaxWidth() .padding(10.dp), verticalAlignment = Alignment.CenterVertically, @@ -249,7 +264,7 @@ private fun ShowSystemSetting( Switch( checked = showSystem, - onCheckedChange = onUpdateShowSystem, + onCheckedChange = null, ) } } diff --git a/feature/apps/src/main/res/values/strings.xml b/feature/apps/src/main/res/values/strings.xml index 954ba56c1..78a267a26 100644 --- a/feature/apps/src/main/res/values/strings.xml +++ b/feature/apps/src/main/res/values/strings.xml @@ -19,6 +19,8 @@ Search Sort Sort Order + Sort by + Order Name Update Install @@ -26,4 +28,19 @@ Descending Show System Show system applications - \ No newline at end of file + Sort and filter + Couldn\'t load installed apps + Couldn\'t refresh the app list + Showing the last available list + Retry + No apps to show + No matching apps + ACTIVE + Protected right now + + %1$d setting configured + %1$d settings configured + + ON + Applies when you open this app + diff --git a/feature/apps/src/test/kotlin/com/android/geto/feature/apps/AppsTestDoubles.kt b/feature/apps/src/test/kotlin/com/android/geto/feature/apps/AppsTestDoubles.kt new file mode 100644 index 000000000..8b2f41a65 --- /dev/null +++ b/feature/apps/src/test/kotlin/com/android/geto/feature/apps/AppsTestDoubles.kt @@ -0,0 +1,82 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.feature.apps + +import com.android.geto.domain.framework.SecureSettingsWrapper +import com.android.geto.domain.model.AppSetting +import com.android.geto.domain.model.ProtectedKey +import com.android.geto.domain.model.ProtectionSession +import com.android.geto.domain.model.ProtectionSessionStatus +import com.android.geto.domain.model.SecureSetting +import com.android.geto.domain.model.SettingReadResult +import com.android.geto.domain.model.SettingType +import com.android.geto.domain.model.SettingWriteResult +import com.android.geto.domain.repository.AppSettingsRepository +import com.android.geto.domain.repository.ProtectionRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +/** + * The apps list only reads these to decide which tags to show, so nothing here needs behaviour — + * only enough shape to construct the view model. + */ +internal object EmptyAppSettingsRepository : AppSettingsRepository { + override val appSettingsFlow: Flow> = flowOf(emptyList()) + override suspend fun upsertAppSetting(appSetting: AppSetting) = Unit + override suspend fun upsertAppSettings(appSettings: List) = Unit + override suspend fun deleteAppSetting(appSetting: AppSetting) = Unit + override fun getAppSettingsFlowByComponentName(componentName: String): Flow> = flowOf(emptyList()) + override suspend fun getAppSettingsByComponentName(componentName: String): List = emptyList() +} + +internal object EmptyProtectionRepository : ProtectionRepository { + override val armedComponentNamesFlow: Flow> = flowOf(emptySet()) + override suspend fun getArmedComponentNames(): Set = emptySet() + override suspend fun armProfile(componentName: String, armedAtMillis: Long) = Unit + override suspend fun disarmProfile(componentName: String): Boolean = false + override val sessionsFlow: Flow> = flowOf(emptyList()) + override suspend fun getSessions(): List = emptyList() + override suspend fun getSessionByToken(token: String): ProtectionSession? = null + override suspend fun getSessionByComponentName(componentName: String): ProtectionSession? = null + override suspend fun getProtectedKeys(): List = emptyList() + override suspend fun getProtectedKey(settingType: SettingType, key: String): ProtectedKey? = null + override suspend fun getClaimantComponentNames(settingType: SettingType, key: String): List = emptyList() + override suspend fun getKeysReleasedBy(token: String): List = emptyList() + override suspend fun getOrphanKeys(): List = emptyList() + override suspend fun deleteOrphanKeys(): Int = 0 + override suspend fun createSession(session: ProtectionSession, newKeys: List) = Unit + override suspend fun updateStatus( + token: String, + status: ProtectionSessionStatus, + updatedAtMillis: Long, + lastError: String?, + ): Boolean = false + override suspend fun updatePausedUntil( + token: String, + pausedUntilMillis: Long, + updatedAtMillis: Long, + ): Boolean = false + override suspend fun clearSession(token: String): Boolean = false +} + +internal object UnusedSecureSettingsWrapper : SecureSettingsWrapper { + override fun hasWriteSecureSettingsPermission(): Boolean = false + override suspend fun read(settingType: SettingType, key: String): SettingReadResult = SettingReadResult.Success(null) + override suspend fun write(settingType: SettingType, key: String, value: String?): SettingWriteResult = SettingWriteResult.Success(value) + override suspend fun getSecureSettings(settingType: SettingType): List = emptyList() +} diff --git a/feature/apps/src/test/kotlin/com/android/geto/feature/apps/AppsViewModelTest.kt b/feature/apps/src/test/kotlin/com/android/geto/feature/apps/AppsViewModelTest.kt new file mode 100644 index 000000000..0e569c42c --- /dev/null +++ b/feature/apps/src/test/kotlin/com/android/geto/feature/apps/AppsViewModelTest.kt @@ -0,0 +1,220 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.feature.apps + +import coil.ImageLoader +import com.android.geto.domain.framework.LauncherAppsWrapper +import com.android.geto.domain.model.GrantMethod +import com.android.geto.domain.model.LauncherAppsActivityInfo +import com.android.geto.domain.model.SortLauncherAppsActivityInfo +import com.android.geto.domain.model.SortOrderLauncherAppsActivityInfo +import com.android.geto.domain.model.Theme +import com.android.geto.domain.model.UserData +import com.android.geto.domain.repository.UserDataRepository +import com.android.geto.domain.usecase.GetLauncherAppsActivityInfosUseCase +import com.android.geto.domain.usecase.ProtectionController +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import java.lang.reflect.Proxy +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull + +@OptIn(ExperimentalCoroutinesApi::class) +class AppsViewModelTest { + private val testDispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun nonEmptySearchIsDebouncedAndClearingIsImmediate() = runTest(testDispatcher) { + val launcherApps = ViewModelTestLauncherAppsWrapper( + Result.success(listOf(activity("Alpha"), activity("Beta"))), + ) + val viewModel = viewModel(launcherApps) + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.appsUiState.collect() + } + runCurrent() + + viewModel.search("alpha") + advanceTimeBy(249) + assertEquals(2, viewModel.successState().launcherAppsActivityInfoData.launcherAppsActivityInfos.size) + + advanceTimeBy(1) + runCurrent() + assertEquals( + listOf("Alpha"), + viewModel.successState().launcherAppsActivityInfoData.launcherAppsActivityInfos.map { + it.activityLabel + }, + ) + + viewModel.search("") + runCurrent() + assertEquals(2, viewModel.successState().launcherAppsActivityInfoData.launcherAppsActivityInfos.size) + } + + @Test + fun refreshFailureKeepsLastSuccessfulListAndRetryDelegates() = runTest(testDispatcher) { + val launcherApps = ViewModelTestLauncherAppsWrapper( + Result.success(listOf(activity("Alpha"), activity("Beta"))), + ) + val viewModel = viewModel(launcherApps) + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.appsUiState.collect() + } + runCurrent() + + launcherApps.results.value = Result.failure(IllegalStateException("failed")) + runCurrent() + viewModel.retry() + + val error = assertIs(viewModel.appsUiState.value) + assertEquals(2, assertNotNull(error.previousData).launcherAppsActivityInfos.size) + assertEquals(1, launcherApps.refreshCount) + } + + private fun viewModel( + launcherAppsWrapper: LauncherAppsWrapper, + ): AppsViewModel { + val userDataRepository = ViewModelTestUserDataRepository() + return AppsViewModel( + getLauncherAppsActivityInfosUseCase = GetLauncherAppsActivityInfosUseCase( + launcherAppsWrapper = launcherAppsWrapper, + userDataRepository = userDataRepository, + defaultDispatcher = testDispatcher, + ), + userDataRepository = userDataRepository, + appSettingsRepository = EmptyAppSettingsRepository, + protectionController = ProtectionController( + appSettingsRepository = EmptyAppSettingsRepository, + protectionRepository = EmptyProtectionRepository, + secureSettingsWrapper = UnusedSecureSettingsWrapper, + ), + imageLoader = unusedImageLoader(), + ) + } + + private fun AppsViewModel.successState(): AppsUiState.Success = assertIs(appsUiState.value) + + private fun activity(label: String) = LauncherAppsActivityInfo( + componentName = "package.$label/.Main", + packageName = "package.$label", + activityLabel = label, + firstInstallTime = 0, + lastUpdateTime = 0, + isSystem = false, + ) + + private fun unusedImageLoader(): ImageLoader = Proxy.newProxyInstance( + ImageLoader::class.java.classLoader, + arrayOf(ImageLoader::class.java), + ) { _, _, _ -> error("The image loader is not used by AppsViewModel") } as ImageLoader +} + +private class ViewModelTestLauncherAppsWrapper( + initialResult: Result>, +) : LauncherAppsWrapper { + val results = MutableStateFlow(initialResult) + var refreshCount = 0 + private set + + override fun getActivityListFlow(): Flow>> = results + + override fun refresh() { + refreshCount++ + } +} + +private class ViewModelTestUserDataRepository : UserDataRepository { + override val userData = MutableStateFlow( + UserData( + theme = Theme.FOLLOW_SYSTEM, + dynamicTheme = true, + sortLauncherAppsActivityInfo = SortLauncherAppsActivityInfo.Name, + sortOrderLauncherAppsActivityInfo = SortOrderLauncherAppsActivityInfo.Ascending, + showSystem = false, + autoRestartProtection = false, + ), + ) + override val preferencesWereReset: Flow = flowOf(false) + + override suspend fun updateTheme(theme: Theme) = Unit + + override suspend fun updateDynamicTheme(dynamicTheme: Boolean) = Unit + + override suspend fun updateSortLauncherAppsActivityInfo( + sortLauncherAppsActivityInfo: SortLauncherAppsActivityInfo, + ) { + userData.value = userData.value.copy( + sortLauncherAppsActivityInfo = sortLauncherAppsActivityInfo, + ) + } + + override suspend fun updateSortOrderLauncherAppsActivityInfo( + sortOrderLauncherAppsActivityInfo: SortOrderLauncherAppsActivityInfo, + ) { + userData.value = userData.value.copy( + sortOrderLauncherAppsActivityInfo = sortOrderLauncherAppsActivityInfo, + ) + } + + override suspend fun updateShowSystem(showSystem: Boolean) { + userData.value = userData.value.copy(showSystem = showSystem) + } + + override suspend fun updateAutoRestartProtection(autoRestartProtection: Boolean) = Unit + + override suspend fun updateGrantMethod(grantMethod: GrantMethod) { + userData.value = userData.value.copy(grantMethod = grantMethod) + } + + override suspend fun updateProtectionPausedUntil(protectionPausedUntilMillis: Long) { + userData.value = userData.value.copy( + protectionPausedUntilMillis = protectionPausedUntilMillis, + ) + } + + override suspend fun resetUserPreferences() = Unit + + override fun acknowledgePreferencesReset() = Unit +} diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index 298d006f8..f0e64dab6 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -27,5 +27,6 @@ android { dependencies { implementation(projects.domain.repository) + implementation(projects.domain.useCase) implementation(projects.service) -} \ No newline at end of file +} diff --git a/feature/settings/src/main/kotlin/com/android/geto/feature/settings/SettingsScreen.kt b/feature/settings/src/main/kotlin/com/android/geto/feature/settings/SettingsScreen.kt index 027ddb5a7..17e292c9f 100644 --- a/feature/settings/src/main/kotlin/com/android/geto/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/kotlin/com/android/geto/feature/settings/SettingsScreen.kt @@ -17,9 +17,16 @@ */ package com.android.geto.feature.settings +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context import android.content.Intent +import android.net.Uri +import android.provider.Settings +import android.text.format.DateFormat import androidx.annotation.VisibleForTesting 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 @@ -29,46 +36,146 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf 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.unit.dp -import androidx.core.content.ContextCompat import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.geto.designsystem.theme.supportsDynamicTheming +import com.android.geto.domain.model.GrantMethod +import com.android.geto.domain.model.ProtectionFailureReason +import com.android.geto.domain.model.ProtectionMode +import com.android.geto.domain.model.ProtectionPauseDuration +import com.android.geto.domain.model.ProtectionPauseState +import com.android.geto.domain.model.ProtectionSessionStatus +import com.android.geto.domain.model.ShizukuGrantResult +import com.android.geto.domain.model.ShizukuState import com.android.geto.domain.model.Theme import com.android.geto.domain.model.UserData +import com.android.geto.domain.model.isPaused +import com.android.geto.feature.settings.dialog.GrantMethodDialog +import com.android.geto.feature.settings.dialog.PauseProtectionDialog import com.android.geto.feature.settings.dialog.ThemeDialog -import com.android.geto.service.SettingsObserverService +import com.android.geto.framework.notificationmanager.AndroidNotificationManagerWrapper +import com.android.geto.service.ArmedAppStatus +import com.android.geto.service.ProtectedAppStatus +import com.android.geto.service.ProtectionServiceState +import com.android.geto.ui.local.LocalNotificationManager +import java.util.Date @Composable internal fun SettingsRoute( modifier: Modifier = Modifier, viewModel: SettingsViewModel = hiltViewModel(), + snackbarHostState: SnackbarHostState, ) { val settingsUiState by viewModel.settingsUiState.collectAsStateWithLifecycle() - val isServiceRunning by viewModel.isServiceRunning.collectAsStateWithLifecycle() + val protectionState by viewModel.protectionState.collectAsStateWithLifecycle() + val armedApps by viewModel.armedApps.collectAsStateWithLifecycle() + val preferencesWereReset by viewModel.preferencesWereReset.collectAsStateWithLifecycle() + val isAppSwitchDetectorEnabled by + viewModel.isAppSwitchDetectorEnabled.collectAsStateWithLifecycle() + val shizukuState by viewModel.shizukuState.collectAsStateWithLifecycle() + val hasWriteSecureSettings by viewModel.hasWriteSecureSettings.collectAsStateWithLifecycle() + val isGranting by viewModel.isGranting.collectAsStateWithLifecycle() + val notificationManager = LocalNotificationManager.current + val lifecycleOwner = LocalLifecycleOwner.current + val context = LocalContext.current + var serviceNotificationsEnabled by remember { + mutableStateOf(notificationManager.protectionServiceNotificationsEnabled()) + } + var protectionAlertsEnabled by remember { + mutableStateOf(notificationManager.protectionAlertsEnabled()) + } + + DisposableEffect(lifecycleOwner, notificationManager) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + serviceNotificationsEnabled = + notificationManager.protectionServiceNotificationsEnabled() + protectionAlertsEnabled = notificationManager.protectionAlertsEnabled() + // The user may have started Shizuku, or run the ADB command, while we were away. + viewModel.refreshPermissionState() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + // Balanced by construction, so the reference count in the wrapper cannot drift. + DisposableEffect(viewModel) { + viewModel.startObservingShizuku() + onDispose { viewModel.stopObservingShizuku() } + } + + LaunchedEffect(viewModel, snackbarHostState) { + viewModel.grantResults.collect { result -> + snackbarHostState.showSnackbar(message = context.grantResultMessage(result)) + } + } + + LaunchedEffect(viewModel, snackbarHostState) { + viewModel.protectionResults.collect { message -> + snackbarHostState.showSnackbar(message = context.protectionMessage(message)) + } + } SettingsScreen( modifier = modifier, settingsUiState = settingsUiState, + protectionState = protectionState, + armedApps = armedApps, isServiceRunning = isServiceRunning, + serviceNotificationsEnabled = serviceNotificationsEnabled, + protectionAlertsEnabled = protectionAlertsEnabled, + preferencesWereReset = preferencesWereReset, + isAppSwitchDetectorEnabled = isAppSwitchDetectorEnabled, + shizukuState = shizukuState, + hasWriteSecureSettings = hasWriteSecureSettings, + isGranting = isGranting, + onDisarmApp = viewModel::disarmApp, + onPauseProtection = viewModel::pauseProtection, + onResumeFromPause = viewModel::resumeProtection, onUpdateTheme = viewModel::updateTheme, onUpdateDynamicTheme = viewModel::updateDynamicTheme, + onUpdateAutoRestartProtection = viewModel::updateAutoRestartProtection, + onUpdateGrantMethod = viewModel::updateGrantMethod, + onGrantWithShizuku = viewModel::grantWithShizuku, + onRetry = viewModel::retry, + onResetPreferences = viewModel::resetPreferences, + onAcknowledgePreferencesReset = viewModel::acknowledgePreferencesReset, + onResumeProtection = viewModel::resumeProtection, + onTurnOffProtection = viewModel::turnOffAndRestoreProtection, ) } @@ -77,85 +184,240 @@ internal fun SettingsRoute( internal fun SettingsScreen( modifier: Modifier = Modifier, settingsUiState: SettingsUiState, + protectionState: ProtectionServiceState, + armedApps: List, isServiceRunning: Boolean, + serviceNotificationsEnabled: Boolean, + protectionAlertsEnabled: Boolean, + preferencesWereReset: Boolean, + isAppSwitchDetectorEnabled: Boolean, + shizukuState: ShizukuState, + hasWriteSecureSettings: Boolean, + isGranting: Boolean, + onDisarmApp: (String) -> Unit, + onPauseProtection: (String, ProtectionPauseDuration) -> Unit, + onResumeFromPause: (String) -> Unit, onUpdateTheme: (Theme) -> Unit, onUpdateDynamicTheme: (Boolean) -> Unit, + onUpdateAutoRestartProtection: (Boolean) -> Unit, + onUpdateGrantMethod: (GrantMethod) -> Unit, + onGrantWithShizuku: () -> Unit, + onRetry: () -> Unit, + onResetPreferences: () -> Unit, + onAcknowledgePreferencesReset: () -> Unit, + onResumeProtection: () -> Unit, + onTurnOffProtection: (String) -> Unit, ) { - Box( - modifier = modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()), - ) { + Box(modifier = modifier.fillMaxSize()) { when (settingsUiState) { SettingsUiState.Loading -> { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } + is SettingsUiState.Error -> { + SettingsError( + modifier = Modifier.align(Alignment.Center), + message = settingsUiState.message, + onRetry = onRetry, + onResetPreferences = onResetPreferences, + ) + } + is SettingsUiState.Success -> { Success( + modifier = Modifier.verticalScroll(rememberScrollState()), userData = settingsUiState.userData, + protectionState = protectionState, + armedApps = armedApps, isServiceRunning = isServiceRunning, + serviceNotificationsEnabled = serviceNotificationsEnabled, + protectionAlertsEnabled = protectionAlertsEnabled, + preferencesWereReset = preferencesWereReset, + isAppSwitchDetectorEnabled = isAppSwitchDetectorEnabled, + shizukuState = shizukuState, + hasWriteSecureSettings = hasWriteSecureSettings, + isGranting = isGranting, + onDisarmApp = onDisarmApp, + onPauseProtection = onPauseProtection, + onResumeFromPause = onResumeFromPause, onUpdateDynamicTheme = onUpdateDynamicTheme, onUpdateTheme = onUpdateTheme, + onUpdateAutoRestartProtection = onUpdateAutoRestartProtection, + onUpdateGrantMethod = onUpdateGrantMethod, + onGrantWithShizuku = onGrantWithShizuku, + onAcknowledgePreferencesReset = onAcknowledgePreferencesReset, + onResumeProtection = onResumeProtection, + onTurnOffProtection = onTurnOffProtection, ) } } } } +@Composable +private fun SettingsError( + modifier: Modifier = Modifier, + message: String?, + onRetry: () -> Unit, + onResetPreferences: () -> Unit, +) { + Column( + modifier = modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.could_not_load_settings), + style = MaterialTheme.typography.titleMedium, + ) + message?.takeIf(String::isNotBlank)?.let { + Text(text = it, style = MaterialTheme.typography.bodySmall) + } + Button(onClick = onRetry) { + Text(stringResource(R.string.retry)) + } + TextButton(onClick = onResetPreferences) { + Text(stringResource(R.string.reset_preferences)) + } + } +} + @Composable private fun Success( modifier: Modifier = Modifier, userData: UserData, + protectionState: ProtectionServiceState, + armedApps: List, isServiceRunning: Boolean, + serviceNotificationsEnabled: Boolean, + protectionAlertsEnabled: Boolean, + preferencesWereReset: Boolean, + isAppSwitchDetectorEnabled: Boolean, + shizukuState: ShizukuState, + hasWriteSecureSettings: Boolean, + isGranting: Boolean, + onDisarmApp: (String) -> Unit, + onPauseProtection: (String, ProtectionPauseDuration) -> Unit, + onResumeFromPause: (String) -> Unit, onUpdateDynamicTheme: (Boolean) -> Unit, onUpdateTheme: (Theme) -> Unit, + onUpdateAutoRestartProtection: (Boolean) -> Unit, + onUpdateGrantMethod: (GrantMethod) -> Unit, + onGrantWithShizuku: () -> Unit, + onAcknowledgePreferencesReset: () -> Unit, + onResumeProtection: () -> Unit, + onTurnOffProtection: (String) -> Unit, ) { val context = LocalContext.current + var showThemeDialog by rememberSaveable { mutableStateOf(false) } + var selectedTheme by rememberSaveable(userData.theme) { + mutableIntStateOf(Theme.entries.indexOf(userData.theme)) + } + // Non-null while the pause dialog is open; holds which app it will pause. + var pauseTargetToken by rememberSaveable { mutableStateOf(null) } + var selectedPauseDuration by rememberSaveable { mutableIntStateOf(0) } + val writeSettingsCommand = + "pm grant ${context.packageName} android.permission.WRITE_SECURE_SETTINGS" - var showThemeDialog by remember { mutableStateOf(false) } + Column(modifier = modifier.fillMaxSize()) { + if (preferencesWereReset) { + MessageCard( + title = stringResource(R.string.preferences_recovered), + text = stringResource(R.string.preferences_recovered_description), + action = stringResource(R.string.dismiss), + onAction = onAcknowledgePreferencesReset, + ) + } - var selectedTheme by remember { - mutableIntStateOf( - Theme.entries.indexOf( - userData.theme, - ), + // Any app blocked on the permission is worth a prominent card, since one fix unblocks them all. + if ( + (protectionState as? ProtectionServiceState.Running)?.recoveryApps.orEmpty().any { + it.message?.contains(ProtectionFailureReason.PERMISSION_DENIED.name) == true + } + ) { + MessageCard( + title = stringResource(R.string.write_settings_permission_required), + text = stringResource(R.string.write_settings_permission_required_description), + action = stringResource(R.string.copy_adb_command), + onAction = { + context.getSystemService(ClipboardManager::class.java).setPrimaryClip( + ClipData.newPlainText("ADB command", writeSettingsCommand), + ) + }, + isWarning = true, + ) + } + + if (!serviceNotificationsEnabled || !protectionAlertsEnabled) { + MessageCard( + title = stringResource(R.string.notifications_disabled), + text = stringResource(R.string.notifications_disabled_description), + action = stringResource(R.string.open_settings), + onAction = { + context.startActivity(notificationSettingsIntent(context.packageName)) + }, + isWarning = true, + ) + } + + PermissionSection( + grantMethod = userData.grantMethod, + hasWriteSecureSettings = hasWriteSecureSettings, + shizukuState = shizukuState, + isGranting = isGranting, + writeSettingsCommand = writeSettingsCommand, + onUpdateGrantMethod = onUpdateGrantMethod, + onGrantWithShizuku = onGrantWithShizuku, ) - } - Column(modifier = modifier.fillMaxSize()) { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + AppSwitchingSection(isEnabled = isAppSwitchDetectorEnabled) + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + ProtectedAppsSection( + armedApps = armedApps, + isServiceRunning = isServiceRunning, + serviceNotificationsEnabled = serviceNotificationsEnabled, + onDisarmApp = onDisarmApp, + onPauseRequested = { token -> + pauseTargetToken = token + selectedPauseDuration = 0 + }, + onResumeFromPause = onResumeFromPause, + onTurnOffProtection = onTurnOffProtection, + onResumeProtection = onResumeProtection, + ) + ToggleSetting( + title = stringResource(R.string.restart_protection_automatically), + subtitle = stringResource(R.string.restart_protection_automatically_description), + checked = userData.autoRestartProtection, + onCheckedChange = onUpdateAutoRestartProtection, + ) + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + SectionTitle(stringResource(R.string.appearance)) DynamicThemeSetting( dynamicTheme = userData.dynamicTheme, onUpdateDynamicTheme = onUpdateDynamicTheme, ) - - Spacer(modifier = Modifier.height(8.dp)) - - SettingsColumn( - title = stringResource(R.string.theme), - subtitle = userData.theme.getTitle(), - onClick = { + ListItem( + modifier = Modifier.clickableWithRole { + selectedTheme = Theme.entries.indexOf(userData.theme) showThemeDialog = true }, + headlineContent = { Text(stringResource(R.string.theme)) }, + supportingContent = { Text(userData.theme.getTitle()) }, ) + } - Spacer(modifier = Modifier.height(8.dp)) - - SettingsColumn( - title = stringResource(R.string.settings_observer_service), - subtitle = if (isServiceRunning) { - stringResource(R.string.stop_service) - } else { - stringResource(R.string.start_service) - }, - onClick = { - val intent = Intent(context, SettingsObserverService::class.java) - - if (isServiceRunning) { - context.stopService(intent) - } else { - ContextCompat.startForegroundService(context, intent) - } + pauseTargetToken?.let { token -> + PauseProtectionDialog( + onDismissRequest = { pauseTargetToken = null }, + selected = selectedPauseDuration, + onSelect = { selectedPauseDuration = it }, + onPauseClick = { + onPauseProtection(token, ProtectionPauseDuration.entries[selectedPauseDuration]) + pauseTargetToken = null }, ) } @@ -163,87 +425,556 @@ private fun Success( if (showThemeDialog) { ThemeDialog( onDismissRequest = { + selectedTheme = Theme.entries.indexOf(userData.theme) showThemeDialog = false }, selected = selectedTheme, onSelect = { selectedTheme = it }, onChangeClick = { onUpdateTheme(Theme.entries[selectedTheme]) - showThemeDialog = false }, ) } } +/** + * Whether Geto can see app switches at all. + * + * Nothing is applied without this, so an armed profile that silently does nothing is the most + * confusing failure the app has. It gets its own section rather than a footnote. + */ @Composable -private fun DynamicThemeSetting( - modifier: Modifier = Modifier, - dynamicTheme: Boolean, - onUpdateDynamicTheme: (Boolean) -> Unit, -) { - if (supportsDynamicTheming()) { +private fun AppSwitchingSection(isEnabled: Boolean) { + val context = LocalContext.current + + SectionTitle(stringResource(R.string.app_switching)) + + ListItem( + headlineContent = { + Text( + stringResource( + if (isEnabled) { + R.string.app_switch_detector_enabled + } else { + R.string.app_switch_detector_disabled + }, + ), + ) + }, + supportingContent = { Text(stringResource(R.string.app_switch_detector_description)) }, + ) + + if (!isEnabled) { Row( - modifier = modifier - .clickable { - onUpdateDynamicTheme(!dynamicTheme) - } + modifier = Modifier .fillMaxWidth() - .padding(10.dp), - verticalAlignment = Alignment.CenterVertically, + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.End, ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(R.string.dynamic_theme), - style = MaterialTheme.typography.bodyLarge, - ) + Button( + onClick = { + runCatching { + context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) + } + }, + ) { + Text(stringResource(R.string.turn_on_app_switching)) + } + } + } +} - Spacer(modifier = Modifier.height(4.dp)) +/** + * Every protected app, one row each. + * + * Several apps can be protected at once, so a single "protection status" line could never say which + * app it meant. Each row names its app, its own state, and carries its own actions. + */ +@Composable +private fun ProtectedAppsSection( + armedApps: List, + isServiceRunning: Boolean, + serviceNotificationsEnabled: Boolean, + onDisarmApp: (String) -> Unit, + onPauseRequested: (String) -> Unit, + onResumeFromPause: (String) -> Unit, + onTurnOffProtection: (String) -> Unit, + onResumeProtection: () -> Unit, +) { + SectionTitle(stringResource(R.string.protection)) - Text( - text = stringResource(R.string.available_on_android_12), - style = MaterialTheme.typography.bodySmall, - ) + if (armedApps.isEmpty()) { + ListItem( + headlineContent = { Text(stringResource(R.string.no_apps_protected)) }, + supportingContent = { Text(stringResource(R.string.no_apps_protected_description)) }, + ) + return + } + + armedApps.forEach { armed -> + val applied = armed.applied + + if (applied == null) { + // Armed and waiting. This is the state the screen could never show before, which is why + // it always claimed nothing was protected. + ListItem( + overlineContent = { Text(stringResource(R.string.status_waiting)) }, + headlineContent = { Text(armed.label) }, + supportingContent = { Text(stringResource(R.string.status_waiting_description)) }, + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = { onDisarmApp(armed.componentName) }) { + Text(stringResource(R.string.turn_off)) + } } + } else { + ProtectedAppItem( + app = applied, + isServiceRunning = isServiceRunning, + serviceNotificationsEnabled = serviceNotificationsEnabled, + onPauseRequested = onPauseRequested, + onResumeFromPause = onResumeFromPause, + onTurnOffProtection = onTurnOffProtection, + onResumeProtection = onResumeProtection, + ) + } + } +} - Switch( - checked = dynamicTheme, - onCheckedChange = onUpdateDynamicTheme, +@Composable +private fun ProtectedAppItem( + app: ProtectedAppStatus, + isServiceRunning: Boolean, + serviceNotificationsEnabled: Boolean, + onPauseRequested: (String) -> Unit, + onResumeFromPause: (String) -> Unit, + onTurnOffProtection: (String) -> Unit, + onResumeProtection: () -> Unit, +) { + // Only a persistent profile depends on the service; a one-shot does not. + val serviceStopped = app.mode == ProtectionMode.FOREGROUND && + app.status == ProtectionSessionStatus.ACTIVE && + !isServiceRunning + + ListItem( + overlineContent = { Text(app.statusText(serviceStopped)) }, + headlineContent = { Text(app.label) }, + supportingContent = { + Text( + pluralStringResource( + R.plurals.watching_settings, + app.settingCount, + app.settingCount, + ), + ) + }, + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = { onTurnOffProtection(app.token) }) { + Text( + stringResource( + when { + app.status == ProtectionSessionStatus.RECOVERY_REQUIRED -> + R.string.retry_restore + + app.mode == ProtectionMode.ONE_SHOT -> R.string.restore_now + + else -> R.string.turn_off_and_restore + }, + ), ) } + + when { + serviceStopped -> Button( + enabled = serviceNotificationsEnabled, + onClick = onResumeProtection, + ) { + Text(stringResource(R.string.resume)) + } + + app.pause.isPaused -> Button(onClick = { onResumeFromPause(app.token) }) { + Text(stringResource(R.string.resume_protection)) + } + + app.mode == ProtectionMode.FOREGROUND && + app.status == ProtectionSessionStatus.ACTIVE -> + TextButton(onClick = { onPauseRequested(app.token) }) { + Text(stringResource(R.string.pause_protection)) + } + } } } @Composable -private fun SettingsColumn( - modifier: Modifier = Modifier, +private fun ProtectedAppStatus.statusText(serviceStopped: Boolean): String = when { + status == ProtectionSessionStatus.RECOVERY_REQUIRED -> + stringResource(R.string.status_needs_attention) + + status == ProtectionSessionStatus.STARTING -> stringResource(R.string.status_starting) + + status == ProtectionSessionStatus.RESTORING -> stringResource(R.string.status_restoring) + + serviceStopped -> stringResource(R.string.status_service_stopped) + + pause is ProtectionPauseState.PausedUntil -> stringResource( + R.string.status_paused_until, + DateFormat.getTimeFormat(LocalContext.current) + .format(Date((pause as ProtectionPauseState.PausedUntil).untilMillis)), + ) + + pause == ProtectionPauseState.PausedIndefinitely -> stringResource(R.string.status_paused) + + mode == ProtectionMode.ONE_SHOT -> stringResource(R.string.status_one_shot) + + else -> stringResource(R.string.status_protected) +} + +/** + * How the user gets `WRITE_SECURE_SETTINGS` onto Geto. The method is a stored preference, so it + * stays visible once the permission is held; only the instructions for acting on it are hidden. + */ +@Composable +private fun PermissionSection( + grantMethod: GrantMethod, + hasWriteSecureSettings: Boolean, + shizukuState: ShizukuState, + isGranting: Boolean, + writeSettingsCommand: String, + onUpdateGrantMethod: (GrantMethod) -> Unit, + onGrantWithShizuku: () -> Unit, +) { + var showGrantMethodDialog by rememberSaveable { mutableStateOf(false) } + var selectedGrantMethod by rememberSaveable(grantMethod) { + mutableIntStateOf(GrantMethod.entries.indexOf(grantMethod)) + } + + SectionTitle(stringResource(R.string.permission)) + + ListItem( + headlineContent = { Text(stringResource(R.string.write_secure_settings)) }, + supportingContent = { + Text( + stringResource( + if (hasWriteSecureSettings) { + R.string.write_secure_settings_granted + } else { + R.string.write_secure_settings_not_granted + }, + ), + ) + }, + ) + + ListItem( + modifier = Modifier.clickableWithRole { + selectedGrantMethod = GrantMethod.entries.indexOf(grantMethod) + showGrantMethodDialog = true + }, + headlineContent = { Text(stringResource(R.string.grant_method)) }, + supportingContent = { Text(grantMethod.getTitle()) }, + ) + + // Each method always renders something. Gating the whole block on the permission meant that once + // it was granted, switching method changed nothing on screen and looked broken. + when (grantMethod) { + GrantMethod.ADB -> if (hasWriteSecureSettings) { + ListItem( + supportingContent = { Text(stringResource(R.string.nothing_to_grant)) }, + headlineContent = { Text(stringResource(R.string.grant_method_adb)) }, + ) + } else { + AdbGrant(writeSettingsCommand = writeSettingsCommand) + } + + GrantMethod.SHIZUKU -> ShizukuGrant( + shizukuState = shizukuState, + isGranting = isGranting, + canGrant = !hasWriteSecureSettings, + onGrantWithShizuku = onGrantWithShizuku, + ) + } + + if (showGrantMethodDialog) { + GrantMethodDialog( + onDismissRequest = { + selectedGrantMethod = GrantMethod.entries.indexOf(grantMethod) + showGrantMethodDialog = false + }, + selected = selectedGrantMethod, + onSelect = { selectedGrantMethod = it }, + onChangeClick = { + onUpdateGrantMethod(GrantMethod.entries[selectedGrantMethod]) + showGrantMethodDialog = false + }, + ) + } +} + +@Composable +private fun AdbGrant(writeSettingsCommand: String) { + val context = LocalContext.current + + MessageCard( + title = stringResource(R.string.adb_grant_instructions), + text = writeSettingsCommand, + action = stringResource(R.string.copy_adb_command), + onAction = { + context.getSystemService(ClipboardManager::class.java).setPrimaryClip( + ClipData.newPlainText("ADB command", writeSettingsCommand), + ) + }, + ) +} + +@Composable +private fun ShizukuGrant( + shizukuState: ShizukuState, + isGranting: Boolean, + canGrant: Boolean, + onGrantWithShizuku: () -> Unit, +) { + val context = LocalContext.current + val shizukuIntent = remember(context, shizukuState) { + context.packageManager.getLaunchIntentForPackage(SHIZUKU_PACKAGE_NAME) + } + + ListItem( + headlineContent = { Text(stringResource(R.string.shizuku)) }, + supportingContent = { + Text( + if (canGrant) { + shizukuState.getDescription() + } else { + stringResource(R.string.nothing_to_grant) + }, + ) + }, + ) + + // Nothing left to grant, so no button — but the status above still shows, which is what makes + // choosing this method visibly do something. + if (!canGrant) return + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.End, + ) { + when (shizukuState) { + // Nothing in-app can fix this one; the supporting text already says what to do. + ShizukuState.NotInstalled -> Unit + + ShizukuState.NotRunning, + ShizukuState.OutdatedVersion, + ShizukuState.PermissionDenied, + -> Button( + enabled = shizukuIntent != null, + onClick = { shizukuIntent?.let(context::startActivity) }, + ) { + Text(stringResource(R.string.open_shizuku)) + } + + ShizukuState.Unknown, + ShizukuState.PermissionRequired, + ShizukuState.Ready, + -> Button( + enabled = !isGranting && shizukuState != ShizukuState.Unknown, + onClick = onGrantWithShizuku, + ) { + Text( + stringResource( + if (isGranting) R.string.granting else R.string.grant_with_shizuku, + ), + ) + } + } + } +} + +@Composable +private fun MessageCard( title: String, - subtitle: String, - onClick: () -> Unit, + text: String, + action: String, + onAction: () -> Unit, + isWarning: Boolean = false, ) { - Column( - modifier = modifier + Card( + modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick) - .padding(10.dp), + .padding(horizontal = 16.dp, vertical = 8.dp), + colors = if (isWarning) { + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer) + } else { + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer) + }, ) { - Text( - text = title, - style = MaterialTheme.typography.bodyLarge, - ) + Column(modifier = Modifier.padding(16.dp)) { + Text(text = title, style = MaterialTheme.typography.titleSmall) + Spacer(modifier = Modifier.height(4.dp)) + Text(text = text, style = MaterialTheme.typography.bodyMedium) + TextButton( + modifier = Modifier.align(Alignment.End), + onClick = onAction, + ) { + Text(action) + } + } + } +} - Spacer(modifier = Modifier.height(8.dp)) +@Composable +private fun SectionTitle(title: String) { + Text( + modifier = Modifier.padding(start = 16.dp, top = 12.dp, end = 16.dp, bottom = 4.dp), + text = title, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelLarge, + ) +} - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall, +@Composable +private fun ToggleSetting( + title: String, + subtitle: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + ListItem( + modifier = Modifier.toggleable( + value = checked, + role = Role.Switch, + onValueChange = onCheckedChange, + ), + headlineContent = { Text(title) }, + supportingContent = { Text(subtitle) }, + trailingContent = { + Switch( + checked = checked, + onCheckedChange = null, + ) + }, + ) +} + +@Composable +private fun DynamicThemeSetting( + dynamicTheme: Boolean, + onUpdateDynamicTheme: (Boolean) -> Unit, +) { + if (supportsDynamicTheming()) { + ToggleSetting( + title = stringResource(R.string.dynamic_theme), + subtitle = stringResource(R.string.available_on_android_12), + checked = dynamicTheme, + onCheckedChange = onUpdateDynamicTheme, ) } } +private fun Context.protectionMessage(message: ProtectionMessage): String = when (message) { + ProtectionMessage.RESTORED -> getString(R.string.protection_restored) + + ProtectionMessage.ALREADY_INACTIVE -> getString(R.string.protection_already_inactive) + + ProtectionMessage.RESTORE_FAILED -> getString(R.string.protection_restore_failed) + + ProtectionMessage.RESTORE_PERMISSION_DENIED -> + getString(R.string.protection_restore_permission_denied) +} + +private fun Modifier.clickableWithRole(onClick: () -> Unit): Modifier = clickable( + role = Role.Button, + onClick = onClick, +) + +private fun AndroidNotificationManagerWrapper.protectionServiceNotificationsEnabled(): Boolean = areNotificationsEnabled() && + isNotificationChannelEnabled( + AndroidNotificationManagerWrapper.PROTECTION_NOTIFICATION_CHANNEL_ID, + ) + +private fun AndroidNotificationManagerWrapper.protectionAlertsEnabled(): Boolean = areNotificationsEnabled() && + isNotificationChannelEnabled( + AndroidNotificationManagerWrapper.PROTECTION_ALERT_CHANNEL_ID, + ) + +private fun notificationSettingsIntent(packageName: String): Intent = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, packageName) + } +} else { + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", packageName, null), + ) +} + @Composable internal fun Theme.getTitle() = when (this) { Theme.FOLLOW_SYSTEM -> stringResource(R.string.follow_system) Theme.LIGHT -> stringResource(R.string.light) Theme.DARK -> stringResource(R.string.dark) } + +@Composable +internal fun ProtectionPauseDuration.getTitle() = when (this) { + ProtectionPauseDuration.TEN_MINUTES -> stringResource(R.string.pause_ten_minutes) + ProtectionPauseDuration.THIRTY_MINUTES -> stringResource(R.string.pause_thirty_minutes) + ProtectionPauseDuration.ONE_HOUR -> stringResource(R.string.pause_one_hour) + ProtectionPauseDuration.INDEFINITE -> stringResource(R.string.pause_indefinite) +} + +@Composable +internal fun GrantMethod.getTitle() = when (this) { + GrantMethod.ADB -> stringResource(R.string.grant_method_adb) + GrantMethod.SHIZUKU -> stringResource(R.string.grant_method_shizuku) +} + +@Composable +internal fun GrantMethod.getDescription() = when (this) { + GrantMethod.ADB -> stringResource(R.string.grant_method_adb_description) + GrantMethod.SHIZUKU -> stringResource(R.string.grant_method_shizuku_description) +} + +@Composable +private fun ShizukuState.getDescription() = when (this) { + ShizukuState.Unknown -> stringResource(R.string.shizuku_checking) + ShizukuState.NotInstalled -> stringResource(R.string.shizuku_not_installed) + ShizukuState.NotRunning -> stringResource(R.string.shizuku_not_running) + ShizukuState.OutdatedVersion -> stringResource(R.string.shizuku_outdated) + ShizukuState.PermissionRequired -> stringResource(R.string.shizuku_permission_required) + ShizukuState.PermissionDenied -> stringResource(R.string.shizuku_permission_denied) + ShizukuState.Ready -> stringResource(R.string.shizuku_ready) +} + +private fun Context.grantResultMessage(result: ShizukuGrantResult): String = when (result) { + ShizukuGrantResult.Success -> getString(R.string.grant_succeeded) + + ShizukuGrantResult.RequiresRestart -> getString(R.string.grant_requires_restart) + + // The Shizuku status row right above the button already spells out the state, so repeating it + // in the snackbar would only be noise. + is ShizukuGrantResult.Failure -> getString(R.string.grant_failed) + + is ShizukuGrantResult.Error -> + result.message + ?.let { getString(R.string.grant_failed_with_reason, it) } + ?: getString(R.string.grant_failed) +} + +private const val SHIZUKU_PACKAGE_NAME = "moe.shizuku.privileged.api" diff --git a/feature/settings/src/main/kotlin/com/android/geto/feature/settings/SettingsUiState.kt b/feature/settings/src/main/kotlin/com/android/geto/feature/settings/SettingsUiState.kt index e16e722da..eaf138366 100644 --- a/feature/settings/src/main/kotlin/com/android/geto/feature/settings/SettingsUiState.kt +++ b/feature/settings/src/main/kotlin/com/android/geto/feature/settings/SettingsUiState.kt @@ -21,5 +21,8 @@ import com.android.geto.domain.model.UserData sealed interface SettingsUiState { data object Loading : SettingsUiState + + data class Error(val message: String?) : SettingsUiState + data class Success(val userData: UserData) : SettingsUiState } diff --git a/feature/settings/src/main/kotlin/com/android/geto/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/kotlin/com/android/geto/feature/settings/SettingsViewModel.kt index 0d490f618..209fb8b98 100644 --- a/feature/settings/src/main/kotlin/com/android/geto/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/kotlin/com/android/geto/feature/settings/SettingsViewModel.kt @@ -19,32 +19,164 @@ package com.android.geto.feature.settings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.android.geto.domain.model.GrantMethod +import com.android.geto.domain.model.ProtectionPauseDuration +import com.android.geto.domain.model.ProtectionPauseState +import com.android.geto.domain.model.ProtectionResult +import com.android.geto.domain.model.ShizukuGrantResult +import com.android.geto.domain.model.ShizukuState import com.android.geto.domain.model.Theme +import com.android.geto.domain.model.UserData import com.android.geto.domain.repository.UserDataRepository -import com.android.geto.service.SettingsObserverService +import com.android.geto.domain.usecase.ProtectionController +import com.android.geto.domain.usecase.ShizukuPermissionController +import com.android.geto.framework.notificationmanager.AndroidNotificationManagerWrapper +import com.android.geto.framework.notificationmanager.AndroidNotificationManagerWrapper.Companion.ONE_SHOT_NOTIFICATION_ID +import com.android.geto.service.ArmedAppStatus +import com.android.geto.service.ForegroundProtectionCoordinator +import com.android.geto.service.ProtectionService +import com.android.geto.service.ProtectionServiceManager +import com.android.geto.service.ProtectionServiceRuntime +import com.android.geto.service.ProtectionServiceState import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel +@OptIn(ExperimentalCoroutinesApi::class) class SettingsViewModel @Inject constructor( private val userDataRepository: UserDataRepository, + private val protectionController: ProtectionController, + private val protectionServiceManager: ProtectionServiceManager, + private val notificationManager: AndroidNotificationManagerWrapper, + private val shizukuPermissionController: ShizukuPermissionController, + private val foregroundProtectionCoordinator: ForegroundProtectionCoordinator, + protectionServiceRuntime: ProtectionServiceRuntime, ) : ViewModel() { - val settingsUiState = userDataRepository.userData.map(SettingsUiState::Success).stateIn( + private val retryCount = MutableStateFlow(0) + + val settingsUiState = retryCount.flatMapLatest { + userDataRepository.userData + .map(SettingsUiState::Success) + .catch { emit(SettingsUiState.Error(it.message)) } + }.stateIn( scope = viewModelScope, started = WhileSubscribed(5_000), initialValue = SettingsUiState.Loading, ) - val isServiceRunning = SettingsObserverService.isRunning.stateIn( + val isServiceRunning = ProtectionService.isRunning.stateIn( + scope = viewModelScope, + started = WhileSubscribed(5_000), + initialValue = false, + ) + + /** + * Armed apps, not sessions. The old source could only ever be empty on this screen, because a + * session exists solely while its app is in the foreground — and Geto is the foreground app + * whenever the user is reading this. + */ + val armedApps = protectionServiceRuntime.armedApps.stateIn( + scope = viewModelScope, + started = WhileSubscribed(5_000), + initialValue = emptyList(), + ) + + val protectionState = protectionServiceRuntime.state.stateIn( + scope = viewModelScope, + started = WhileSubscribed(5_000), + initialValue = ProtectionServiceState.Loading, + ) + + val preferencesWereReset = userDataRepository.preferencesWereReset.stateIn( scope = viewModelScope, started = WhileSubscribed(5_000), initialValue = false, ) + private val protectionMessages = Channel(Channel.BUFFERED) + + val protectionResults = protectionMessages.receiveAsFlow() + + val shizukuState = shizukuPermissionController.shizukuState.stateIn( + scope = viewModelScope, + started = WhileSubscribed(5_000), + initialValue = ShizukuState.Unknown, + ) + + /** + * Without the detector nothing is ever applied, so this has to be visible rather than leaving the + * user to wonder why an armed profile does nothing. + */ + private val _isAppSwitchDetectorEnabled = + MutableStateFlow(foregroundProtectionCoordinator.isDetectorEnabled) + + val isAppSwitchDetectorEnabled = _isAppSwitchDetectorEnabled.asStateFlow() + + private val _hasWriteSecureSettings = + MutableStateFlow(shizukuPermissionController.hasWriteSecureSettingsPermission()) + + val hasWriteSecureSettings = _hasWriteSecureSettings.asStateFlow() + + private val _isGranting = MutableStateFlow(false) + + val isGranting = _isGranting.asStateFlow() + + /** + * One-shot grant outcomes. A channel rather than a state holder, so granting twice with the + * same outcome still reaches the user both times instead of being conflated away. + */ + private val grantResultChannel = Channel(Channel.BUFFERED) + + val grantResults = grantResultChannel.receiveAsFlow() + + fun startObservingShizuku() { + shizukuPermissionController.start() + } + + fun stopObservingShizuku() { + shizukuPermissionController.stop() + } + + /** Re-reads both permission states, e.g. after returning from the Shizuku app. */ + fun refreshPermissionState() { + _isAppSwitchDetectorEnabled.value = foregroundProtectionCoordinator.isDetectorEnabled + shizukuPermissionController.refresh() + _hasWriteSecureSettings.value = shizukuPermissionController.hasWriteSecureSettingsPermission() + } + + fun grantWithShizuku() { + if (_isGranting.value) return + + viewModelScope.launch { + _isGranting.value = true + try { + val result = shizukuPermissionController.grant() + _hasWriteSecureSettings.value = + shizukuPermissionController.hasWriteSecureSettingsPermission() + grantResultChannel.send(result) + } finally { + _isGranting.value = false + } + } + } + + fun updateGrantMethod(grantMethod: GrantMethod) { + viewModelScope.launch { + userDataRepository.updateGrantMethod(grantMethod = grantMethod) + } + } + fun updateTheme(theme: Theme) { viewModelScope.launch { userDataRepository.updateTheme(theme = theme) @@ -56,4 +188,81 @@ class SettingsViewModel @Inject constructor( userDataRepository.updateDynamicTheme(dynamicTheme = dynamicTheme) } } + + fun updateAutoRestartProtection(autoRestartProtection: Boolean) { + viewModelScope.launch { + userDataRepository.updateAutoRestartProtection(autoRestartProtection) + } + } + + fun retry() { + retryCount.value += 1 + } + + fun resetPreferences() { + viewModelScope.launch { + userDataRepository.resetUserPreferences() + retry() + } + } + + fun acknowledgePreferencesReset() { + userDataRepository.acknowledgePreferencesReset() + } + + fun resumeProtection() { + protectionServiceManager.onProtectionEnabled() + } + + fun pauseProtection(sessionToken: String, duration: ProtectionPauseDuration) { + viewModelScope.launch { + protectionController.pause(sessionToken = sessionToken, duration = duration) + } + } + + fun resumeProtection(sessionToken: String) { + viewModelScope.launch { + protectionController.resume(sessionToken) + } + } + + /** Disarms an app, restoring its originals if it happens to be applied right now. */ + fun disarmApp(componentName: String) { + viewModelScope.launch { + val result = protectionController.disarmProfile(componentName) + protectionMessages.send(result.asProtectionMessage()) + } + } + + /** + * A restore can genuinely fail — a revoked permission, a ROM that rejects the write — and the + * old code dropped that on the floor, so the button looked broken. The outcome goes to a + * snackbar instead. + */ + + fun turnOffAndRestoreProtection(sessionToken: String) { + viewModelScope.launch { + val result = protectionController.restore(sessionToken) + if (result is ProtectionResult.Success) { + notificationManager.cancel(ONE_SHOT_NOTIFICATION_ID) + } + protectionMessages.send(result.asProtectionMessage()) + } + } +} + +/** What to tell the user about a protection action. */ +enum class ProtectionMessage { + RESTORED, + RESTORE_PERMISSION_DENIED, + RESTORE_FAILED, + ALREADY_INACTIVE, +} + +private fun ProtectionResult.asProtectionMessage(): ProtectionMessage = when (this) { + is ProtectionResult.Success -> ProtectionMessage.RESTORED + ProtectionResult.NoActiveProtection -> ProtectionMessage.ALREADY_INACTIVE + is ProtectionResult.StaleSession -> ProtectionMessage.ALREADY_INACTIVE + is ProtectionResult.PermissionDenied -> ProtectionMessage.RESTORE_PERMISSION_DENIED + else -> ProtectionMessage.RESTORE_FAILED } diff --git a/feature/settings/src/main/kotlin/com/android/geto/feature/settings/dialog/GrantMethodDialog.kt b/feature/settings/src/main/kotlin/com/android/geto/feature/settings/dialog/GrantMethodDialog.kt new file mode 100644 index 000000000..727504665 --- /dev/null +++ b/feature/settings/src/main/kotlin/com/android/geto/feature/settings/dialog/GrantMethodDialog.kt @@ -0,0 +1,154 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.feature.settings.dialog + +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.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +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.android.geto.designsystem.component.DialogContainer +import com.android.geto.domain.model.GrantMethod +import com.android.geto.feature.settings.R +import com.android.geto.feature.settings.getDescription +import com.android.geto.feature.settings.getTitle + +@Composable +internal fun GrantMethodDialog( + modifier: Modifier = Modifier, + onDismissRequest: () -> Unit, + selected: Int, + onSelect: (Int) -> Unit, + onChangeClick: () -> Unit, +) { + DialogContainer( + modifier = modifier.verticalScroll(rememberScrollState()), + onDismissRequest = onDismissRequest, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + ) { + Text( + modifier = Modifier.padding(10.dp), + text = stringResource(id = R.string.grant_method), + style = MaterialTheme.typography.titleLarge, + ) + + GrantMethodRadioButtonGroup( + selected = selected, + onSelect = onSelect, + ) + + GrantMethodDialogButtons( + onCancelClick = onDismissRequest, + onChangeClick = onChangeClick, + ) + } + } +} + +@Composable +private fun GrantMethodRadioButtonGroup( + modifier: Modifier = Modifier, + selected: Int, + onSelect: (Int) -> Unit, +) { + Column( + modifier = modifier + .fillMaxWidth() + .selectableGroup(), + ) { + GrantMethod.entries.forEachIndexed { index, grantMethod -> + Row( + Modifier + .fillMaxWidth() + .selectable( + selected = index == selected, + role = Role.RadioButton, + enabled = true, + onClick = { + onSelect(index) + }, + ) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = index == selected, + onClick = null, + ) + + Column(modifier = Modifier.padding(start = 10.dp)) { + Text( + text = grantMethod.getTitle(), + style = MaterialTheme.typography.bodyLarge, + ) + + Text( + text = grantMethod.getDescription(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +@Composable +private fun GrantMethodDialogButtons( + modifier: Modifier = Modifier, + onCancelClick: () -> Unit, + onChangeClick: () -> Unit, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(10.dp), + horizontalArrangement = Arrangement.End, + ) { + TextButton( + onClick = onCancelClick, + modifier = Modifier.padding(5.dp), + ) { + Text(text = stringResource(id = R.string.cancel)) + } + TextButton( + onClick = onChangeClick, + modifier = Modifier.padding(5.dp), + ) { + Text(text = stringResource(id = R.string.change)) + } + } +} diff --git a/feature/settings/src/main/kotlin/com/android/geto/feature/settings/dialog/PauseProtectionDialog.kt b/feature/settings/src/main/kotlin/com/android/geto/feature/settings/dialog/PauseProtectionDialog.kt new file mode 100644 index 000000000..3e59b5b91 --- /dev/null +++ b/feature/settings/src/main/kotlin/com/android/geto/feature/settings/dialog/PauseProtectionDialog.kt @@ -0,0 +1,155 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.feature.settings.dialog + +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +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.android.geto.designsystem.component.DialogContainer +import com.android.geto.domain.model.ProtectionPauseDuration +import com.android.geto.feature.settings.R +import com.android.geto.feature.settings.getTitle + +@Composable +internal fun PauseProtectionDialog( + modifier: Modifier = Modifier, + onDismissRequest: () -> Unit, + selected: Int, + onSelect: (Int) -> Unit, + onPauseClick: () -> Unit, +) { + DialogContainer( + modifier = modifier.verticalScroll(rememberScrollState()), + onDismissRequest = onDismissRequest, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + ) { + Text( + modifier = Modifier.padding(10.dp), + text = stringResource(id = R.string.pause_protection), + style = MaterialTheme.typography.titleLarge, + ) + + Text( + modifier = Modifier.padding(horizontal = 10.dp), + text = stringResource(id = R.string.pause_protection_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + PauseDurationRadioButtonGroup( + selected = selected, + onSelect = onSelect, + ) + + PauseDialogButtons( + onCancelClick = onDismissRequest, + onPauseClick = onPauseClick, + ) + } + } +} + +@Composable +private fun PauseDurationRadioButtonGroup( + modifier: Modifier = Modifier, + selected: Int, + onSelect: (Int) -> Unit, +) { + Column( + modifier = modifier + .fillMaxWidth() + .selectableGroup(), + ) { + ProtectionPauseDuration.entries.forEachIndexed { index, duration -> + Row( + Modifier + .fillMaxWidth() + .height(56.dp) + .selectable( + selected = index == selected, + role = Role.RadioButton, + enabled = true, + onClick = { + onSelect(index) + }, + ) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = index == selected, + onClick = null, + ) + + Text( + text = duration.getTitle(), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 10.dp), + ) + } + } + } +} + +@Composable +private fun PauseDialogButtons( + modifier: Modifier = Modifier, + onCancelClick: () -> Unit, + onPauseClick: () -> Unit, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(10.dp), + horizontalArrangement = Arrangement.End, + ) { + TextButton( + onClick = onCancelClick, + modifier = Modifier.padding(5.dp), + ) { + Text(text = stringResource(id = R.string.cancel)) + } + TextButton( + onClick = onPauseClick, + modifier = Modifier.padding(5.dp), + ) { + Text(text = stringResource(id = R.string.pause)) + } + } +} diff --git a/feature/settings/src/main/kotlin/com/android/geto/feature/settings/navigation/SettingsNavigation.kt b/feature/settings/src/main/kotlin/com/android/geto/feature/settings/navigation/SettingsNavigation.kt index 6176d612f..3465f42f0 100644 --- a/feature/settings/src/main/kotlin/com/android/geto/feature/settings/navigation/SettingsNavigation.kt +++ b/feature/settings/src/main/kotlin/com/android/geto/feature/settings/navigation/SettingsNavigation.kt @@ -17,6 +17,7 @@ */ package com.android.geto.feature.settings.navigation +import androidx.compose.material3.SnackbarHostState import androidx.navigation.NavController import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavGraphBuilder @@ -33,8 +34,8 @@ fun NavController.navigateToSettings() { } } -fun NavGraphBuilder.settingsScreen() { +fun NavGraphBuilder.settingsScreen(snackbarHostState: SnackbarHostState) { composable { - SettingsRoute() + SettingsRoute(snackbarHostState = snackbarHostState) } } diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml index 20ac032aa..9fbf4980f 100644 --- a/feature/settings/src/main/res/values/strings.xml +++ b/feature/settings/src/main/res/values/strings.xml @@ -25,7 +25,94 @@ Follow System Light Dark - Start service - Stop service - Settings Observer Service - \ No newline at end of file + Protection + Protection status + Checking protection… + No active profile + Active for %1$s + %1$s is selected, but background protection is stopped + One-time settings are active for %1$s and are waiting to be restored + Recovery required + Recovery required for %1$s + Restart protection automatically + Restart an active profile after an unexpected stop, reboot, or app update. No polling or wake lock is used. + Appearance + Notifications are disabled + Protection cannot reliably show its status or recovery alerts until notifications are enabled. + Open settings + Preferences were recovered + Unreadable display and list preferences were reset to safe defaults. Your app profiles were not removed. + Dismiss + Could not load settings + Retry + Reset preferences + Resume + Turn off & restore + Retry restore + Restore now + Write Settings permission required + Restore cannot finish until Geto has WRITE_SECURE_SETTINGS again. Copy the ADB command, run it from a connected computer, then retry restore. + Copy ADB command + + Permission + Write Secure Settings + Granted — Geto can change your settings + Not granted — Geto cannot change your settings yet + Grant method + ADB + Run one command from a computer connected over ADB. + Shizuku + Let the Shizuku app grant the permission on device. No computer needed. + Run this from a connected computer + Shizuku + Checking Shizuku… + Shizuku is not installed. Install it, start it, then come back here. + Shizuku is installed but not running. Open Shizuku and start the service. + This version of Shizuku is too old. Update it, then come back here. + Shizuku is running. Geto needs your permission in Shizuku. + Geto was denied in Shizuku. Allow it from Shizuku\'s authorized apps list, then come back here. + Shizuku is ready. Geto is authorized. + Grant with Shizuku + Granting… + Open Shizuku + Write Secure Settings granted + Granted, but Geto has to be restarted before it can use the permission + Could not grant the permission through Shizuku + Could not grant the permission: %1$s + + Pause + Pause protection + Geto stops applying this profile for a while. Your original settings stay in place until you resume it. + Resume protection + 10 minutes + 30 minutes + 1 hour + Until I turn it back on + No apps set up + Turn on \"Apply while this app is open\" for an app and it will appear here. + Protected + Paused + Paused until %1$s + Needs attention + Starting… + Restoring… + Selected, but background protection is stopped + One-time settings applied + + %1$d setting + %1$d settings + + Original settings restored + That app is no longer protected + Could not restore the original settings + Geto needs Write Secure Settings again before it can restore + App switching + On — Geto can see when you open a protected app + Off — armed profiles will not do anything + Geto applies a profile when its app opens and puts your settings back when you leave. It needs permission to see which app is in front. It only reads the app\'s name, never screen content. + Turn on + Already granted — nothing to do here + Waiting + Applies when you open this app + Turn off + diff --git a/framework/asset-manager/src/main/assets/AppSettingTemplates.json b/framework/asset-manager/src/main/assets/AppSettingTemplates.json index dd6f766e2..657899e65 100644 --- a/framework/asset-manager/src/main/assets/AppSettingTemplates.json +++ b/framework/asset-manager/src/main/assets/AppSettingTemplates.json @@ -1,30 +1,67 @@ [ { - "settingType": "GLOBAL", - "label": "Hide Developer Options", - "key": "development_settings_enabled", - "valueOnLaunch": "0", - "valueOnRevert": "1" + "id": "developer_options_only", + "label": "Developer options only (keeps Shizuku)", + "description": "Hides Developer options from the app while leaving USB and wireless debugging on, so Shizuku and ADB keep running.", + "entries": [ + { + "settingType": "GLOBAL", + "label": "Developer options", + "key": "development_settings_enabled", + "valueOnLaunch": "0", + "valueOnRevert": "0" + } + ] }, { - "settingType": "GLOBAL", - "label": "Hide USB Debugging", - "key": "adb_enabled", - "valueOnLaunch": "0", - "valueOnRevert": "1" + "id": "common_developer_checks", + "label": "Common developer checks", + "description": "Disables Developer options, USB debugging, and wireless debugging. Warning: turning USB debugging off stops ADB and kills Shizuku until you restart it.", + "entries": [ + { + "settingType": "GLOBAL", + "label": "USB debugging", + "key": "adb_enabled", + "valueOnLaunch": "0", + "valueOnRevert": "0" + }, + { + "settingType": "GLOBAL", + "label": "Wireless debugging", + "key": "adb_wifi_enabled", + "valueOnLaunch": "0", + "valueOnRevert": "0" + }, + { + "settingType": "GLOBAL", + "label": "Developer options", + "key": "development_settings_enabled", + "valueOnLaunch": "0", + "valueOnRevert": "0" + } + ], + "warning": "USB debugging off stops adbd, which terminates Shizuku. Use \"Developer options only\" if you need Shizuku to keep working." }, { - "settingType": "GLOBAL", - "label": "Hide USB Wireless Debugging", - "key": "adb_wifi_enabled", - "valueOnLaunch": "0", - "valueOnRevert": "1" - }, - { - "settingType": "GLOBAL", - "label": "Hide Accessibility Services", - "key": "accessibility_enabled", - "valueOnLaunch": "0", - "valueOnRevert": "1" + "id": "accessibility_off", + "label": "Accessibility services off", + "description": "Temporarily disables accessibility and clears the enabled accessibility-service list.", + "warning": "This can interrupt screen readers and other assistive technology. Restore the profile as soon as the target app closes.", + "entries": [ + { + "settingType": "SECURE", + "label": "Accessibility", + "key": "accessibility_enabled", + "valueOnLaunch": "0", + "valueOnRevert": "0" + }, + { + "settingType": "SECURE", + "label": "Enabled accessibility services", + "key": "enabled_accessibility_services", + "valueOnLaunch": "", + "valueOnRevert": "" + } + ] } ] diff --git a/framework/asset-manager/src/main/kotlin/com/android/geto/framework/assetmanager/DefaultAssetManagerWrapper.kt b/framework/asset-manager/src/main/kotlin/com/android/geto/framework/assetmanager/DefaultAssetManagerWrapper.kt index 0a4b22781..7800f473d 100644 --- a/framework/asset-manager/src/main/kotlin/com/android/geto/framework/assetmanager/DefaultAssetManagerWrapper.kt +++ b/framework/asset-manager/src/main/kotlin/com/android/geto/framework/assetmanager/DefaultAssetManagerWrapper.kt @@ -23,7 +23,6 @@ import com.android.geto.domain.common.dispatcher.GetoDispatchers.IO import com.android.geto.domain.framework.AssetManagerWrapper import com.android.geto.domain.model.AppSettingTemplate import com.google.gson.Gson -import com.google.gson.JsonSyntaxException import com.google.gson.reflect.TypeToken import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher @@ -50,8 +49,15 @@ internal class DefaultAssetManagerWrapper @Inject constructor( } try { - Gson().fromJson(jsonString, appSettingsType) ?: emptyList() - } catch (_: JsonSyntaxException) { + val templates: List = + Gson().fromJson(jsonString, appSettingsType) ?: emptyList() + templates.filter { template -> + template.id.isNotBlank() && + template.label.isNotBlank() && + template.entries.isNotEmpty() && + template.entries.all { entry -> entry.key.isNotBlank() } + } + } catch (_: RuntimeException) { emptyList() } } diff --git a/framework/foreground-app/.gitignore b/framework/foreground-app/.gitignore new file mode 100644 index 000000000..796b96d1c --- /dev/null +++ b/framework/foreground-app/.gitignore @@ -0,0 +1 @@ +/build diff --git a/framework/foreground-app/build.gradle.kts b/framework/foreground-app/build.gradle.kts new file mode 100644 index 000000000..f2f29c94f --- /dev/null +++ b/framework/foreground-app/build.gradle.kts @@ -0,0 +1,31 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +plugins { + alias(libs.plugins.com.android.geto.library) + alias(libs.plugins.com.android.geto.hilt) +} + +android { + namespace = "com.android.geto.framework.foregroundapp" +} + +dependencies { + implementation(projects.domain.common) + implementation(projects.domain.framework) +} diff --git a/framework/foreground-app/src/main/AndroidManifest.xml b/framework/foreground-app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..b11eaf1a9 --- /dev/null +++ b/framework/foreground-app/src/main/AndroidManifest.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + diff --git a/framework/foreground-app/src/main/kotlin/com/android/geto/framework/foregroundapp/DefaultForegroundAppWrapper.kt b/framework/foreground-app/src/main/kotlin/com/android/geto/framework/foregroundapp/DefaultForegroundAppWrapper.kt new file mode 100644 index 000000000..8beaceeb1 --- /dev/null +++ b/framework/foreground-app/src/main/kotlin/com/android/geto/framework/foregroundapp/DefaultForegroundAppWrapper.kt @@ -0,0 +1,84 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.framework.foregroundapp + +import android.accessibilityservice.AccessibilityServiceInfo +import android.content.ComponentName +import android.content.Context +import android.view.accessibility.AccessibilityManager +import com.android.geto.domain.framework.ForegroundAppWrapper +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Holds the current foreground package for the rest of the app. + * + * The accessibility service is constructed by the system rather than by Hilt, so it publishes here + * instead of owning the state itself. That also means the state survives the service being torn down + * and rebound. + */ +@Singleton +class DefaultForegroundAppWrapper @Inject constructor( + @param:ApplicationContext private val context: Context, +) : ForegroundAppWrapper { + + private val _foregroundPackage = MutableStateFlow(null) + + /** + * A window-state event fires for every dialog and screen inside an app, so the same package + * arrives dozens of times per session. StateFlow only emits on a changed value, which collapses + * all of those to nothing and keeps collectors from redoing their work. + */ + override val foregroundPackage = _foregroundPackage.asStateFlow() + + private val _isRunning = MutableStateFlow(false) + + override val isRunning = _isRunning.asStateFlow() + + /** + * Matched on [AccessibilityServiceInfo.getId], which is the flattened component name and the + * documented stable identifier. An earlier version compared through `resolveInfo`, which is not + * always populated — the service was genuinely bound and this still reported it as off. + */ + override fun isEnabled(): Boolean { + val manager = context.getSystemService(AccessibilityManager::class.java) ?: return false + val expected = ComponentName(context, GetoAccessibilityService::class.java) + val expectedIds = setOf(expected.flattenToString(), expected.flattenToShortString()) + + return runCatching { + manager.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK) + .any { it.id in expectedIds } + }.getOrDefault(false) + } + + internal fun onForegroundPackage(packageName: String?) { + _foregroundPackage.value = packageName + } + + internal fun onRunningChanged(running: Boolean) { + _isRunning.value = running + if (!running) { + // Nothing is being observed any more, so claiming to know what is in front would be a lie + // and would leave a stale package applied. + _foregroundPackage.value = null + } + } +} diff --git a/framework/foreground-app/src/main/kotlin/com/android/geto/framework/foregroundapp/ForegroundAppModule.kt b/framework/foreground-app/src/main/kotlin/com/android/geto/framework/foregroundapp/ForegroundAppModule.kt new file mode 100644 index 000000000..8e0376a58 --- /dev/null +++ b/framework/foreground-app/src/main/kotlin/com/android/geto/framework/foregroundapp/ForegroundAppModule.kt @@ -0,0 +1,34 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.framework.foregroundapp + +import com.android.geto.domain.framework.ForegroundAppWrapper +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface ForegroundAppModule { + + @Binds + @Singleton + fun foregroundAppWrapper(impl: DefaultForegroundAppWrapper): ForegroundAppWrapper +} diff --git a/framework/foreground-app/src/main/kotlin/com/android/geto/framework/foregroundapp/GetoAccessibilityService.kt b/framework/foreground-app/src/main/kotlin/com/android/geto/framework/foregroundapp/GetoAccessibilityService.kt new file mode 100644 index 000000000..c2e2c2aea --- /dev/null +++ b/framework/foreground-app/src/main/kotlin/com/android/geto/framework/foregroundapp/GetoAccessibilityService.kt @@ -0,0 +1,103 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.framework.foregroundapp + +import android.accessibilityservice.AccessibilityService +import android.content.Intent +import android.view.accessibility.AccessibilityEvent +import android.view.inputmethod.InputMethodManager +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +/** + * Reports which app is in front. + * + * An accessibility service is used rather than polling `UsageStatsManager`: Android pushes the event, + * so nothing runs between app switches, and the reaction is immediate instead of up to a second late. + * The subscription is narrowed to window-state changes in `accessibility_service_config.xml`, and this + * class deliberately never touches [AccessibilityEvent.getSource] or the window content — it needs + * only a package name. + */ +@AndroidEntryPoint +class GetoAccessibilityService : AccessibilityService() { + + @Inject + lateinit var foregroundApp: DefaultForegroundAppWrapper + + /** + * Packages whose windows are not an app coming to the foreground. + * + * Resolved once on connect rather than per event: this is the hot path, and enumerating input + * methods is a binder call. + */ + private var ignoredPackages: Set = emptySet() + + override fun onServiceConnected() { + super.onServiceConnected() + ignoredPackages = resolveIgnoredPackages() + foregroundApp.onRunningChanged(running = true) + } + + override fun onAccessibilityEvent(event: AccessibilityEvent?) { + if (event?.eventType != AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) return + + val packageName = event.packageName?.toString()?.takeIf(String::isNotBlank) ?: return + + // TYPE_WINDOW_STATE_CHANGED also fires for the keyboard, dialogs, the notification shade and + // vendor overlays. Treating those as "a new app is in front" made protection turn itself off + // the moment you tapped a text field, so a protected app could be sampled unprotected. + if (packageName in ignoredPackages) return + + // Keep this cheap: the only work on the hot path is publishing a string, which the wrapper + // deduplicates. Anything heavier would run for every dialog and screen inside every app. + foregroundApp.onForegroundPackage(packageName) + } + + override fun onInterrupt() = Unit + + override fun onUnbind(intent: Intent?): Boolean { + // Being unbound means app switches stop being visible, so whatever is applied has to come + // back. Publishing this is what lets the controller restore rather than strand the user. + foregroundApp.onRunningChanged(running = false) + return super.onUnbind(intent) + } + + override fun onDestroy() { + foregroundApp.onRunningChanged(running = false) + super.onDestroy() + } + + private fun resolveIgnoredPackages(): Set { + val inputMethods = runCatching { + getSystemService(InputMethodManager::class.java) + ?.enabledInputMethodList + .orEmpty() + .map { it.packageName } + }.getOrDefault(emptyList()) + + return buildSet { + add(packageName) + add(SYSTEM_UI_PACKAGE) + addAll(inputMethods) + } + } + + private companion object { + const val SYSTEM_UI_PACKAGE = "com.android.systemui" + } +} diff --git a/framework/foreground-app/src/main/res/values/strings.xml b/framework/foreground-app/src/main/res/values/strings.xml new file mode 100644 index 000000000..e5b633ca1 --- /dev/null +++ b/framework/foreground-app/src/main/res/values/strings.xml @@ -0,0 +1,22 @@ + + + Geto app switching + Lets Geto apply a profile only while its app is open + Geto needs to know which app you are using so it can apply that app\'s settings when you open it and put your original settings back when you leave. It only reads the name of the app in front — it does not read screen content, text or passwords. + diff --git a/framework/foreground-app/src/main/res/xml/accessibility_service_config.xml b/framework/foreground-app/src/main/res/xml/accessibility_service_config.xml new file mode 100644 index 000000000..ce9dd2c52 --- /dev/null +++ b/framework/foreground-app/src/main/res/xml/accessibility_service_config.xml @@ -0,0 +1,23 @@ + + diff --git a/framework/launcher-apps/build.gradle.kts b/framework/launcher-apps/build.gradle.kts index a4ba882f0..7d8dee660 100644 --- a/framework/launcher-apps/build.gradle.kts +++ b/framework/launcher-apps/build.gradle.kts @@ -28,5 +28,5 @@ android { dependencies { implementation(projects.domain.common) implementation(projects.domain.framework) - implementation(projects.framework.drawable) -} \ No newline at end of file + implementation(libs.coil.kt.compose) +} diff --git a/framework/launcher-apps/src/main/kotlin/com/android/geto/framework/launcherapps/AndroidLauncherAppsWrapper.kt b/framework/launcher-apps/src/main/kotlin/com/android/geto/framework/launcherapps/AndroidLauncherAppsWrapper.kt index c9d44f7ca..5be812a1d 100644 --- a/framework/launcher-apps/src/main/kotlin/com/android/geto/framework/launcherapps/AndroidLauncherAppsWrapper.kt +++ b/framework/launcher-apps/src/main/kotlin/com/android/geto/framework/launcherapps/AndroidLauncherAppsWrapper.kt @@ -18,5 +18,15 @@ package com.android.geto.framework.launcherapps interface AndroidLauncherAppsWrapper { - fun startMainActivity(componentName: String) + fun startMainActivity(componentName: String): LaunchResult +} + +sealed interface LaunchResult { + data object Success : LaunchResult + + data object InvalidComponent : LaunchResult + + data object NotFoundOrUnavailable : LaunchResult + + data object SecurityFailure : LaunchResult } diff --git a/framework/launcher-apps/src/main/kotlin/com/android/geto/framework/launcherapps/DefaultLauncherAppsWrapper.kt b/framework/launcher-apps/src/main/kotlin/com/android/geto/framework/launcherapps/DefaultLauncherAppsWrapper.kt index 128998e64..c65d20147 100644 --- a/framework/launcher-apps/src/main/kotlin/com/android/geto/framework/launcherapps/DefaultLauncherAppsWrapper.kt +++ b/framework/launcher-apps/src/main/kotlin/com/android/geto/framework/launcherapps/DefaultLauncherAppsWrapper.kt @@ -17,6 +17,7 @@ */ package com.android.geto.framework.launcherapps +import android.content.ActivityNotFoundException import android.content.ComponentName import android.content.Context import android.content.pm.LauncherActivityInfo @@ -30,124 +31,214 @@ import com.android.geto.domain.common.dispatcher.GetoDispatchers.Default import com.android.geto.domain.framework.LauncherAppsWrapper import com.android.geto.domain.framework.PackageManagerWrapper import com.android.geto.domain.model.LauncherAppsActivityInfo -import com.android.geto.framework.drawable.AndroidDrawableWrapper import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch import javax.inject.Inject +import javax.inject.Singleton +/** + * Maintains one process-wide launcher metadata snapshot. Package callbacks are reduced by a + * single coroutine so a slow initial query can never race a newer package update. + */ +@Singleton internal class DefaultLauncherAppsWrapper @Inject constructor( - @param:Dispatcher(Default) private val defaultDispatcher: CoroutineDispatcher, - @param:ApplicationContext private val context: Context, - private val androidDrawableWrapper: AndroidDrawableWrapper, + @Dispatcher(Default) defaultDispatcher: CoroutineDispatcher, + @ApplicationContext context: Context, private val packageManagerWrapper: PackageManagerWrapper, ) : LauncherAppsWrapper, AndroidLauncherAppsWrapper { private val launcherApps = context.getSystemService(Context.LAUNCHER_APPS_SERVICE) as LauncherApps + private val currentUser = myUserHandle() + private val scope = CoroutineScope(SupervisorJob() + defaultDispatcher) + private val events = Channel(capacity = Channel.UNLIMITED) + private val cachedActivities = linkedMapOf() + private val activityList = MutableSharedFlow>>(replay = 1) + + private val callback = object : LauncherApps.Callback() { + override fun onPackageAdded(packageName: String?, user: UserHandle?) { + enqueuePackageUpdate(packageName, user) + } - override fun getActivityListFlow(): Flow> = callbackFlow { - suspend fun getActivityList() { - val activities = - launcherApps.getActivityList(null, myUserHandle()).map { launcherActivityInfo -> - currentCoroutineContext().ensureActive() + override fun onPackageRemoved(packageName: String?, user: UserHandle?) { + enqueuePackageRemoval(packageName, user) + } - launcherActivityInfo.toLauncherAppsActivityInfo() - } + override fun onPackageChanged(packageName: String?, user: UserHandle?) { + enqueuePackageUpdate(packageName, user) + } - trySend(activities) + override fun onPackagesAvailable( + packageNames: Array?, + user: UserHandle?, + replacing: Boolean, + ) { + if (user != currentUser) return + packageNames.orEmpty().forEach { events.trySend(PackageEvent.Update(it)) } } - getActivityList() + override fun onPackagesUnavailable( + packageNames: Array?, + user: UserHandle?, + replacing: Boolean, + ) { + if (user != currentUser || replacing) return + packageNames.orEmpty().forEach { events.trySend(PackageEvent.Remove(it)) } + } + } - val callback = object : LauncherApps.Callback() { + init { + // Register first so changes occurring during the initial query are queued and replayed. + launcherApps.registerCallback(callback, Handler(Looper.getMainLooper())) + scope.launch { processEvents() } + refresh() + } - override fun onPackageAdded( - packageName: String?, - user: UserHandle?, - ) { - launch { - getActivityList() - } - } + override fun getActivityListFlow(): Flow>> = activityList.asSharedFlow() - override fun onPackageRemoved( - packageName: String?, - user: UserHandle?, - ) { - launch { - getActivityList() - } - } + override fun refresh() { + events.trySend(PackageEvent.RefreshAll) + } + + private fun enqueuePackageUpdate(packageName: String?, user: UserHandle?) { + if (user == currentUser && packageName != null) { + events.trySend(PackageEvent.Update(packageName)) + } + } - override fun onPackageChanged( - packageName: String?, - user: UserHandle?, - ) { - launch { - getActivityList() + private fun enqueuePackageRemoval(packageName: String?, user: UserHandle?) { + if (user == currentUser && packageName != null) { + events.trySend(PackageEvent.Remove(packageName)) + } + } + + private suspend fun processEvents() { + for (firstEvent in events) { + val batch = buildList { + add(firstEvent) + while (true) { + add(events.tryReceive().getOrNull() ?: break) } } - override fun onPackagesAvailable( - packageNames: Array?, - user: UserHandle?, - replacing: Boolean, - ) { - launch { - getActivityList() + runCatching { + if (batch.any { it == PackageEvent.RefreshAll }) { + loadAllActivities() + } else { + applyIncrementalEvents(batch) } + }.onSuccess { updatedActivities -> + cachedActivities.clear() + cachedActivities.putAll(updatedActivities) + activityList.emit(Result.success(snapshot())) + }.onFailure { throwable -> + activityList.emit(Result.failure(throwable)) } + } + } - override fun onPackagesUnavailable( - packageNames: Array?, - user: UserHandle?, - replacing: Boolean, - ) { - launch { - getActivityList() - } + private suspend fun loadAllActivities(): Map { + val launcherActivityInfos = launcherApps.getActivityList(null, currentUser) + val packageNames = launcherActivityInfos.mapTo(mutableSetOf()) { it.applicationInfo.packageName } + val lastUpdateTimes = packageManagerWrapper.getLastUpdateTimes(packageNames) + + return launcherActivityInfos.associate { launcherActivityInfo -> + val packageName = launcherActivityInfo.applicationInfo.packageName + val activityInfo = launcherActivityInfo.toLauncherAppsActivityInfo( + lastUpdateTime = lastUpdateTimes[packageName] ?: 0L, + ) + activityInfo.componentName to activityInfo + } + } + + private suspend fun applyIncrementalEvents( + batch: List, + ): Map { + val finalPackageActions = linkedMapOf() + batch.forEach { event -> + when (event) { + is PackageEvent.Remove -> finalPackageActions[event.packageName] = event + is PackageEvent.Update -> finalPackageActions[event.packageName] = event + PackageEvent.RefreshAll -> Unit } } - launcherApps.registerCallback( - callback, - Handler(Looper.getMainLooper()), - ) + val updatedActivities = LinkedHashMap(cachedActivities) + finalPackageActions.forEach { (packageName, event) -> + updatedActivities.entries.removeAll { it.value.packageName == packageName } - awaitClose { - launcherApps.unregisterCallback(callback) + if (event is PackageEvent.Update) { + val lastUpdateTime = packageManagerWrapper.getLastUpdateTime(packageName) + launcherApps.getActivityList(packageName, currentUser) + .map { it.toLauncherAppsActivityInfo(lastUpdateTime) } + .forEach { updatedActivities[it.componentName] = it } + } } - }.distinctUntilChanged().flowOn(defaultDispatcher) + return updatedActivities + } - private suspend fun LauncherActivityInfo.toLauncherAppsActivityInfo(): LauncherAppsActivityInfo = LauncherAppsActivityInfo( + private fun snapshot(): List = cachedActivities.values + .sortedWith( + compareBy(String.CASE_INSENSITIVE_ORDER) { + it.activityLabel + } + .thenBy { it.componentName }, + ) + + private fun LauncherActivityInfo.toLauncherAppsActivityInfo( + lastUpdateTime: Long, + ): LauncherAppsActivityInfo = LauncherAppsActivityInfo( componentName = componentName.flattenToString(), packageName = applicationInfo.packageName, - activityIcon = androidDrawableWrapper.toByteArray(drawable = getIcon(0)), - activityLabel = label.toString(), + activityLabel = label.toString().ifBlank { applicationInfo.packageName }, firstInstallTime = firstInstallTime, - lastUpdateTime = packageManagerWrapper.getLastInstallTime(packageName = applicationInfo.packageName), + lastUpdateTime = lastUpdateTime, isSystem = packageManagerWrapper.isSystem(flags = applicationInfo.flags), - ) - override fun startMainActivity(componentName: String) { - try { + override fun startMainActivity(componentName: String): LaunchResult { + val parsedComponentName = ComponentName.unflattenFromString(componentName) + ?: return LaunchResult.InvalidComponent + + val isAvailable = try { + launcherApps.getActivityList(parsedComponentName.packageName, currentUser) + .any { it.componentName == parsedComponentName } + } catch (_: SecurityException) { + return LaunchResult.SecurityFailure + } catch (_: IllegalStateException) { + return LaunchResult.NotFoundOrUnavailable + } + if (!isAvailable) return LaunchResult.NotFoundOrUnavailable + + return try { launcherApps.startMainActivity( - ComponentName.unflattenFromString(componentName), - myUserHandle(), + parsedComponentName, + currentUser, null, null, ) - } catch (e: SecurityException) { - e.printStackTrace() + LaunchResult.Success + } catch (_: SecurityException) { + LaunchResult.SecurityFailure + } catch (_: ActivityNotFoundException) { + LaunchResult.NotFoundOrUnavailable + } catch (_: IllegalStateException) { + LaunchResult.NotFoundOrUnavailable } } + + private sealed interface PackageEvent { + data object RefreshAll : PackageEvent + + data class Update(val packageName: String) : PackageEvent + + data class Remove(val packageName: String) : PackageEvent + } } diff --git a/framework/launcher-apps/src/main/kotlin/com/android/geto/framework/launcherapps/LauncherAppIconFetcher.kt b/framework/launcher-apps/src/main/kotlin/com/android/geto/framework/launcherapps/LauncherAppIconFetcher.kt new file mode 100644 index 000000000..7bd7a8c4b --- /dev/null +++ b/framework/launcher-apps/src/main/kotlin/com/android/geto/framework/launcherapps/LauncherAppIconFetcher.kt @@ -0,0 +1,95 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.framework.launcherapps + +import android.content.ComponentName +import android.content.Context +import android.content.pm.PackageManager +import android.graphics.Canvas +import android.graphics.Rect +import android.graphics.drawable.Drawable +import androidx.core.graphics.createBitmap +import androidx.core.graphics.drawable.toDrawable +import coil.ImageLoader +import coil.decode.DataSource +import coil.fetch.DrawableResult +import coil.fetch.FetchResult +import coil.fetch.Fetcher +import coil.key.Keyer +import coil.request.Options +import coil.size.Dimension +import com.android.geto.domain.model.LauncherAppIcon + +internal class LauncherAppIconKeyer : Keyer { + override fun key(data: LauncherAppIcon, options: Options): String = "launcher-app-icon:${data.componentName}:${data.lastUpdateTime}" +} + +internal class LauncherAppIconFetcher( + private val context: Context, + private val data: LauncherAppIcon, + private val options: Options, +) : Fetcher { + override suspend fun fetch(): FetchResult? { + val componentName = ComponentName.unflattenFromString(data.componentName) ?: return null + val drawable = try { + context.packageManager.getActivityIcon(componentName) + } catch (_: PackageManager.NameNotFoundException) { + return null + } + + val fallbackSize = (48 * context.resources.displayMetrics.density).toInt().coerceAtLeast(1) + val width = (options.size.width as? Dimension.Pixels)?.px + ?: drawable.intrinsicWidth.takeIf { it > 0 } + ?: fallbackSize + val height = (options.size.height as? Dimension.Pixels)?.px + ?: drawable.intrinsicHeight.takeIf { it > 0 } + ?: fallbackSize + + return DrawableResult( + drawable = drawable.renderAtSize(width = width, height = height), + isSampled = width < drawable.intrinsicWidth || height < drawable.intrinsicHeight, + dataSource = DataSource.DISK, + ) + } + + private fun Drawable.renderAtSize(width: Int, height: Int): Drawable { + val safeWidth = width.coerceIn(1, MAX_ICON_SIZE_PX) + val safeHeight = height.coerceIn(1, MAX_ICON_SIZE_PX) + val bitmap = createBitmap(safeWidth, safeHeight) + val canvas = Canvas(bitmap) + val previousBounds = Rect(bounds) + setBounds(0, 0, safeWidth, safeHeight) + draw(canvas) + bounds = previousBounds + return bitmap.toDrawable(context.resources) + } + + class Factory( + private val context: Context, + ) : Fetcher.Factory { + override fun create( + data: LauncherAppIcon, + options: Options, + imageLoader: ImageLoader, + ): Fetcher = LauncherAppIconFetcher(context, data, options) + } + + private companion object { + const val MAX_ICON_SIZE_PX = 512 + } +} diff --git a/framework/launcher-apps/src/main/kotlin/com/android/geto/framework/launcherapps/LauncherAppsModule.kt b/framework/launcher-apps/src/main/kotlin/com/android/geto/framework/launcherapps/LauncherAppsModule.kt index 255843b7e..7a0d64867 100644 --- a/framework/launcher-apps/src/main/kotlin/com/android/geto/framework/launcherapps/LauncherAppsModule.kt +++ b/framework/launcher-apps/src/main/kotlin/com/android/geto/framework/launcherapps/LauncherAppsModule.kt @@ -17,21 +17,44 @@ */ package com.android.geto.framework.launcherapps +import android.content.Context +import coil.ImageLoader +import com.android.geto.domain.common.dispatcher.Dispatcher +import com.android.geto.domain.common.dispatcher.GetoDispatchers.IO import com.android.geto.domain.framework.LauncherAppsWrapper import dagger.Binds import dagger.Module +import dagger.Provides import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) internal interface LauncherAppsModule { @Binds - @Singleton fun launcherAppsWrapper(impl: DefaultLauncherAppsWrapper): LauncherAppsWrapper @Binds - @Singleton fun androidLauncherAppsWrapper(impl: DefaultLauncherAppsWrapper): AndroidLauncherAppsWrapper } + +@Module +@InstallIn(SingletonComponent::class) +internal object LauncherAppsImageModule { + @Provides + @Singleton + fun imageLoader( + @ApplicationContext context: Context, + @Dispatcher(IO) ioDispatcher: CoroutineDispatcher, + ): ImageLoader = ImageLoader.Builder(context) + .fetcherDispatcher(ioDispatcher) + .components { + add(LauncherAppIconKeyer()) + add(LauncherAppIconFetcher.Factory(context)) + } + .crossfade(true) + .build() +} diff --git a/framework/notification-manager/src/main/kotlin/com/android/geto/framework/notificationmanager/AndroidNotificationManagerWrapper.kt b/framework/notification-manager/src/main/kotlin/com/android/geto/framework/notificationmanager/AndroidNotificationManagerWrapper.kt index 626564d6f..dddf8c825 100644 --- a/framework/notification-manager/src/main/kotlin/com/android/geto/framework/notificationmanager/AndroidNotificationManagerWrapper.kt +++ b/framework/notification-manager/src/main/kotlin/com/android/geto/framework/notificationmanager/AndroidNotificationManagerWrapper.kt @@ -32,14 +32,23 @@ interface AndroidNotificationManagerWrapper { channelId: String, name: String, importance: Int, + description: String? = null, ) + fun areNotificationsEnabled(): Boolean + + fun isNotificationChannelEnabled(channelId: String): Boolean + fun cancel(id: Int) companion object { const val NOTIFICATION_CHANNEL_ID = "geto_notification_channel_id" + const val PROTECTION_NOTIFICATION_CHANNEL_ID = "geto_protection" + const val PROTECTION_ALERT_CHANNEL_ID = "geto_protection_alerts" + const val ONE_SHOT_NOTIFICATION_ID = 10_001 const val ACTION_REVERT_SETTINGS = "ACTION_REVERT_SETTINGS" const val NOTIFICATION_EXTRA_COMPONENT_NAME = "component_name" const val NOTIFICATION_EXTRA_NOTIFICATION_ID = "notification_id" + const val NOTIFICATION_EXTRA_SESSION_TOKEN = "session_token" } } diff --git a/framework/notification-manager/src/main/kotlin/com/android/geto/framework/notificationmanager/DefaultNotificationManagerWrapper.kt b/framework/notification-manager/src/main/kotlin/com/android/geto/framework/notificationmanager/DefaultNotificationManagerWrapper.kt index eaf19ab77..4a81aa98a 100644 --- a/framework/notification-manager/src/main/kotlin/com/android/geto/framework/notificationmanager/DefaultNotificationManagerWrapper.kt +++ b/framework/notification-manager/src/main/kotlin/com/android/geto/framework/notificationmanager/DefaultNotificationManagerWrapper.kt @@ -49,13 +49,26 @@ internal class DefaultNotificationManagerWrapper @Inject constructor(@param:Appl channelId: String, name: String, importance: Int, + description: String?, ) { notificationManager.createNotificationChannel( NotificationChannel( channelId, name, importance, - ), + ).apply { + this.description = description + }, ) } + + override fun areNotificationsEnabled(): Boolean = notificationManager.areNotificationsEnabled() + + override fun isNotificationChannelEnabled(channelId: String): Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + notificationManager.getNotificationChannel(channelId)?.let { + it.importance != NotificationManager.IMPORTANCE_NONE + } ?: false + } else { + notificationManager.areNotificationsEnabled() + } } diff --git a/framework/notification-manager/src/main/res/values/strings.xml b/framework/notification-manager/src/main/res/values/strings.xml index 401dbd080..d3f3fe47e 100644 --- a/framework/notification-manager/src/main/res/values/strings.xml +++ b/framework/notification-manager/src/main/res/values/strings.xml @@ -17,4 +17,8 @@ --> Revert - \ No newline at end of file + Protection status + Shows when an app profile is being kept active. + Protection alerts + Warns when protection is interrupted or needs attention. + diff --git a/framework/package-manager/src/main/kotlin/com/android/geto/framework/packagemanager/DefaultPackageManagerWrapper.kt b/framework/package-manager/src/main/kotlin/com/android/geto/framework/packagemanager/DefaultPackageManagerWrapper.kt index 42fbbc9da..ccdce3a49 100644 --- a/framework/package-manager/src/main/kotlin/com/android/geto/framework/packagemanager/DefaultPackageManagerWrapper.kt +++ b/framework/package-manager/src/main/kotlin/com/android/geto/framework/packagemanager/DefaultPackageManagerWrapper.kt @@ -17,6 +17,7 @@ */ package com.android.geto.framework.packagemanager +import android.annotation.SuppressLint import android.content.ComponentName import android.content.Context import android.content.pm.ApplicationInfo @@ -50,7 +51,7 @@ internal class DefaultPackageManagerWrapper @Inject constructor( } } - override suspend fun getLastInstallTime(packageName: String): Long = withContext(ioDispatcher) { + override suspend fun getLastUpdateTime(packageName: String): Long = withContext(ioDispatcher) { val packageInfo = try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { packageManager.getPackageInfo( @@ -67,5 +68,21 @@ internal class DefaultPackageManagerWrapper @Inject constructor( packageInfo?.lastUpdateTime ?: 0L } + @SuppressLint("QueryPermissionsNeeded") + override suspend fun getLastUpdateTimes(packageNames: Set): Map = withContext(ioDispatcher) { + if (packageNames.isEmpty()) return@withContext emptyMap() + + val packageInfos = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.getInstalledPackages(PackageManager.PackageInfoFlags.of(0)) + } else { + @Suppress("DEPRECATION") + packageManager.getInstalledPackages(0) + } + + packageInfos.asSequence() + .filter { it.packageName in packageNames } + .associate { it.packageName to it.lastUpdateTime } + } + override fun isSystem(flags: Int): Boolean = (flags and (ApplicationInfo.FLAG_SYSTEM or ApplicationInfo.FLAG_UPDATED_SYSTEM_APP)) != 0 } diff --git a/framework/secure-settings/src/main/kotlin/com/android/geto/framework/securesettings/DefaultSecureSettingsWrapper.kt b/framework/secure-settings/src/main/kotlin/com/android/geto/framework/securesettings/DefaultSecureSettingsWrapper.kt index 72642fbc8..d8838c355 100644 --- a/framework/secure-settings/src/main/kotlin/com/android/geto/framework/securesettings/DefaultSecureSettingsWrapper.kt +++ b/framework/secure-settings/src/main/kotlin/com/android/geto/framework/securesettings/DefaultSecureSettingsWrapper.kt @@ -17,19 +17,25 @@ */ package com.android.geto.framework.securesettings +import android.Manifest import android.content.Context +import android.content.pm.PackageManager import android.provider.Settings import androidx.core.database.getLongOrNull import androidx.core.database.getStringOrNull import com.android.geto.domain.common.dispatcher.Dispatcher import com.android.geto.domain.common.dispatcher.GetoDispatchers.IO import com.android.geto.domain.framework.SecureSettingsWrapper +import com.android.geto.domain.model.ProtectionFailureReason import com.android.geto.domain.model.SecureSetting +import com.android.geto.domain.model.SettingReadResult import com.android.geto.domain.model.SettingType import com.android.geto.domain.model.SettingType.GLOBAL import com.android.geto.domain.model.SettingType.SECURE import com.android.geto.domain.model.SettingType.SYSTEM +import com.android.geto.domain.model.SettingWriteResult import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import javax.inject.Inject @@ -47,32 +53,118 @@ internal class DefaultSecureSettingsWrapper @Inject constructor( Settings.NameValueTable.VALUE, ) - override suspend fun canWriteSecureSettings( + override fun hasWriteSecureSettingsPermission(): Boolean = context.checkSelfPermission( + Manifest.permission.WRITE_SECURE_SETTINGS, + ) == PackageManager.PERMISSION_GRANTED + + override suspend fun read( + settingType: SettingType, + key: String, + ): SettingReadResult = withContext(ioDispatcher) { + if (key.isBlank()) { + return@withContext SettingReadResult.Failure( + reason = ProtectionFailureReason.INVALID_KEY, + message = "Setting key must not be blank", + ) + } + + try { + SettingReadResult.Success(readValue(settingType, key)) + } catch (exception: SecurityException) { + SettingReadResult.Failure( + reason = ProtectionFailureReason.PERMISSION_DENIED, + message = exception.message, + ) + } catch (exception: IllegalArgumentException) { + SettingReadResult.Failure( + reason = ProtectionFailureReason.INVALID_KEY, + message = exception.message, + ) + } catch (exception: CancellationException) { + throw exception + } catch (exception: RuntimeException) { + SettingReadResult.Failure( + reason = ProtectionFailureReason.READ_FAILED, + message = exception.message, + ) + } + } + + override suspend fun write( settingType: SettingType, key: String, - value: String, - ): Boolean = withContext(ioDispatcher) { - when (settingType) { - SYSTEM -> Settings.System.putString( - contentResolver, - key, - value, + value: String?, + ): SettingWriteResult = withContext(ioDispatcher) { + if (!hasWriteSecureSettingsPermission()) { + return@withContext SettingWriteResult.Failure( + reason = ProtectionFailureReason.PERMISSION_DENIED, + expectedValue = value, + message = "WRITE_SECURE_SETTINGS has not been granted", ) + } - SECURE -> Settings.Secure.putString( - contentResolver, - key, - value, + if (key.isBlank()) { + return@withContext SettingWriteResult.Failure( + reason = ProtectionFailureReason.INVALID_KEY, + expectedValue = value, + message = "Setting key must not be blank", ) + } + + try { + val accepted = when (settingType) { + SYSTEM -> Settings.System.putString(contentResolver, key, value) + SECURE -> Settings.Secure.putString(contentResolver, key, value) + GLOBAL -> Settings.Global.putString(contentResolver, key, value) + } + + if (!accepted) { + return@withContext SettingWriteResult.Failure( + reason = ProtectionFailureReason.WRITE_REJECTED, + expectedValue = value, + actualValue = readValue(settingType, key), + ) + } - GLOBAL -> Settings.Global.putString( - contentResolver, - key, - value, + val actualValue = readValue(settingType, key) + if (actualValue == value) { + SettingWriteResult.Success(value = actualValue) + } else { + SettingWriteResult.Failure( + reason = ProtectionFailureReason.VERIFICATION_FAILED, + expectedValue = value, + actualValue = actualValue, + ) + } + } catch (exception: SecurityException) { + SettingWriteResult.Failure( + reason = ProtectionFailureReason.PERMISSION_DENIED, + expectedValue = value, + message = exception.message, + ) + } catch (exception: IllegalArgumentException) { + SettingWriteResult.Failure( + reason = ProtectionFailureReason.INVALID_KEY, + expectedValue = value, + message = exception.message, + ) + } catch (exception: CancellationException) { + throw exception + } catch (exception: RuntimeException) { + SettingWriteResult.Failure( + reason = ProtectionFailureReason.WRITE_REJECTED, + expectedValue = value, + message = exception.message, ) } } + private fun readValue(settingType: SettingType, key: String): String? = when (settingType) { + SYSTEM -> Settings.System.getString(contentResolver, key) + SECURE -> Settings.Secure.getString(contentResolver, key) + GLOBAL -> Settings.Global.getString(contentResolver, key) + } + override suspend fun getSecureSettings(settingType: SettingType): List = withContext(ioDispatcher) { val cursor = when (settingType) { SYSTEM -> contentResolver.query( diff --git a/framework/shizuku/.gitignore b/framework/shizuku/.gitignore new file mode 100644 index 000000000..796b96d1c --- /dev/null +++ b/framework/shizuku/.gitignore @@ -0,0 +1 @@ +/build diff --git a/framework/shizuku/build.gradle.kts b/framework/shizuku/build.gradle.kts new file mode 100644 index 000000000..10f8935dc --- /dev/null +++ b/framework/shizuku/build.gradle.kts @@ -0,0 +1,43 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +plugins { + alias(libs.plugins.com.android.geto.library) + alias(libs.plugins.com.android.geto.hilt) +} + +android { + namespace = "com.android.geto.framework.shizuku" + + defaultConfig { + consumerProguardFiles("consumer-proguard-rules.pro") + } + + buildFeatures { + aidl = true + buildConfig = true + } +} + +dependencies { + implementation(projects.domain.common) + implementation(projects.domain.framework) + + implementation(libs.shizuku.api) + implementation(libs.shizuku.provider) +} diff --git a/framework/shizuku/consumer-proguard-rules.pro b/framework/shizuku/consumer-proguard-rules.pro new file mode 100644 index 000000000..555f558a6 --- /dev/null +++ b/framework/shizuku/consumer-proguard-rules.pro @@ -0,0 +1,8 @@ +-keepclassmembers class com.android.geto.framework.shizuku.UserService { + public (...); +} + +# Keep AIDL files +-keep class android.content.pm.IPackageManager** { *; } +-keep class android.app.IActivityManager** { *; } +-keep class com.android.geto.framework.shizuku.IUserService** { *; } diff --git a/framework/shizuku/src/main/AndroidManifest.xml b/framework/shizuku/src/main/AndroidManifest.xml new file mode 100644 index 000000000..af5147d15 --- /dev/null +++ b/framework/shizuku/src/main/AndroidManifest.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + diff --git a/framework/shizuku/src/main/aidl/android/app/IActivityManager.aidl b/framework/shizuku/src/main/aidl/android/app/IActivityManager.aidl new file mode 100644 index 000000000..db48f9d7a --- /dev/null +++ b/framework/shizuku/src/main/aidl/android/app/IActivityManager.aidl @@ -0,0 +1,5 @@ +package android.app; + +interface IActivityManager { + int getCurrentUserId(); +} diff --git a/framework/shizuku/src/main/aidl/android/content/pm/IPackageManager.aidl b/framework/shizuku/src/main/aidl/android/content/pm/IPackageManager.aidl new file mode 100644 index 000000000..da7be13c9 --- /dev/null +++ b/framework/shizuku/src/main/aidl/android/content/pm/IPackageManager.aidl @@ -0,0 +1,5 @@ +package android.content.pm; + +interface IPackageManager { + void grantRuntimePermission(String packageName, String permissionName, int userId); +} diff --git a/framework/shizuku/src/main/aidl/com/android/geto/framework/shizuku/IUserService.aidl b/framework/shizuku/src/main/aidl/com/android/geto/framework/shizuku/IUserService.aidl new file mode 100644 index 000000000..1cd4d7779 --- /dev/null +++ b/framework/shizuku/src/main/aidl/com/android/geto/framework/shizuku/IUserService.aidl @@ -0,0 +1,25 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.android.geto.framework.shizuku; + +interface IUserService { + void destroy() = 16777114; + + void grantRuntimePermission(String packageName, String permissionName) = 1; //You can specify your own method IDs in the AIDL. Check out the documentation for more details on this. +} diff --git a/framework/shizuku/src/main/kotlin/com/android/geto/framework/shizuku/DefaultShizukuWrapper.kt b/framework/shizuku/src/main/kotlin/com/android/geto/framework/shizuku/DefaultShizukuWrapper.kt new file mode 100644 index 000000000..ca15ea264 --- /dev/null +++ b/framework/shizuku/src/main/kotlin/com/android/geto/framework/shizuku/DefaultShizukuWrapper.kt @@ -0,0 +1,254 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.framework.shizuku + +import android.Manifest +import android.content.ComponentName +import android.content.Context +import android.content.ServiceConnection +import android.content.pm.PackageManager +import android.os.IBinder +import com.android.geto.domain.common.dispatcher.Dispatcher +import com.android.geto.domain.common.dispatcher.GetoDispatchers.IO +import com.android.geto.domain.framework.ShizukuWrapper +import com.android.geto.domain.model.ShizukuGrantResult +import com.android.geto.domain.model.ShizukuState +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import rikka.shizuku.Shizuku +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +@Singleton +internal class DefaultShizukuWrapper @Inject constructor( + @param:ApplicationContext private val context: Context, + @param:Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, +) : ShizukuWrapper { + + private val _state = MutableStateFlow(ShizukuState.Unknown) + + override val state = _state.asStateFlow() + + private val serviceArgs = Shizuku.UserServiceArgs( + ComponentName(context.packageName, UserService::class.java.name), + ).daemon(false).processNameSuffix("user_service") + + /** + * Serialises grant attempts. Two overlapping binds against the same [serviceArgs] would fight + * over the same connection. + */ + private val grantMutex = Mutex() + + private val listenerLock = Any() + + /** Reference count, so one screen stopping cannot tear down another screen's listeners. */ + private var observers = 0 + + private val binderReceivedListener = Shizuku.OnBinderReceivedListener(::refresh) + + private val binderDeadListener = Shizuku.OnBinderDeadListener(::refresh) + + override fun start() { + synchronized(listenerLock) { + if (observers++ == 0) { + // Sticky, so an already-received binder reports itself immediately. The plain + // variant only fires on arrival, which has usually happened before any screen is + // on-screen to hear it. + Shizuku.addBinderReceivedListenerSticky(binderReceivedListener) + Shizuku.addBinderDeadListener(binderDeadListener) + } + } + + refresh() + } + + override fun stop() { + synchronized(listenerLock) { + if (observers > 0 && --observers == 0) { + Shizuku.removeBinderReceivedListener(binderReceivedListener) + Shizuku.removeBinderDeadListener(binderDeadListener) + } + } + } + + override fun refresh() { + _state.value = currentState() + } + + override suspend fun grantWriteSecureSettings(): ShizukuGrantResult = withContext(ioDispatcher) { + grantMutex.withLock { + refresh() + + when (val current = _state.value) { + ShizukuState.Unknown, + ShizukuState.NotInstalled, + ShizukuState.NotRunning, + ShizukuState.OutdatedVersion, + ShizukuState.PermissionDenied, + -> return@withLock ShizukuGrantResult.Failure(current) + + ShizukuState.PermissionRequired, + ShizukuState.Ready, + -> Unit + } + + try { + if (_state.value == ShizukuState.PermissionRequired && !requestShizukuPermission()) { + refresh() + return@withLock ShizukuGrantResult.Failure(_state.value) + } + + if (Shizuku.getVersion() < MINIMUM_USER_SERVICE_VERSION) { + _state.value = ShizukuState.OutdatedVersion + return@withLock ShizukuGrantResult.Failure(ShizukuState.OutdatedVersion) + } + + grantThroughUserService() + + refresh() + + ShizukuGrantResult.Success + } catch (exception: CancellationException) { + throw exception + } catch (exception: Throwable) { + refresh() + ShizukuGrantResult.Error(exception.message ?: exception::class.simpleName) + } + } + } + + /** + * Binds the privileged user service and only calls through once it is actually connected. + * + * The service has to exist before the grant is issued: an earlier version granted first and + * bound afterwards, against a null service reference, so the call silently did nothing while + * still reporting success. + */ + private suspend fun grantThroughUserService() { + val binding = CompletableDeferred() + + val connection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { + if (binder != null && binder.pingBinder()) { + binding.complete(IUserService.Stub.asInterface(binder)) + } else { + binding.completeExceptionally( + IllegalStateException("Shizuku returned a dead user service binder"), + ) + } + } + + override fun onServiceDisconnected(name: ComponentName?) { + binding.completeExceptionally( + IllegalStateException("Shizuku disconnected the user service"), + ) + } + } + + try { + Shizuku.bindUserService(serviceArgs, connection) + + // Without a bound timeout a wedged Shizuku would leave the caller suspended forever. + val userService = withTimeout(BIND_TIMEOUT_MILLIS) { binding.await() } + + userService.grantRuntimePermission( + context.packageName, + Manifest.permission.WRITE_SECURE_SETTINGS, + ) + } finally { + runCatching { Shizuku.unbindUserService(serviceArgs, connection, true) } + } + } + + private suspend fun requestShizukuPermission(): Boolean = suspendCancellableCoroutine { continuation -> + val listener = object : Shizuku.OnRequestPermissionResultListener { + override fun onRequestPermissionResult(requestCode: Int, grantResult: Int) { + if (requestCode != REQUEST_PERMISSION_CODE) return + + Shizuku.removeRequestPermissionResultListener(this) + + if (continuation.isActive) { + continuation.resume(grantResult == PackageManager.PERMISSION_GRANTED) + } + } + } + + Shizuku.addRequestPermissionResultListener(listener) + + continuation.invokeOnCancellation { + runCatching { Shizuku.removeRequestPermissionResultListener(listener) } + } + + try { + Shizuku.requestPermission(REQUEST_PERMISSION_CODE) + } catch (exception: Throwable) { + Shizuku.removeRequestPermissionResultListener(listener) + + if (continuation.isActive) continuation.resumeWithException(exception) + } + } + + private fun currentState(): ShizukuState { + if (!isShizukuInstalled()) return ShizukuState.NotInstalled + + // Every Shizuku entry point below reaches through a binder that can disappear between two + // statements, and throws IllegalStateException when it has. + return try { + when { + !Shizuku.pingBinder() -> ShizukuState.NotRunning + + Shizuku.isPreV11() || + Shizuku.getVersion() < MINIMUM_USER_SERVICE_VERSION -> ShizukuState.OutdatedVersion + + Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED -> ShizukuState.Ready + + // Shizuku uses the rationale flag to mean "the user said no and I will not ask again". + Shizuku.shouldShowRequestPermissionRationale() -> ShizukuState.PermissionDenied + + else -> ShizukuState.PermissionRequired + } + } catch (_: Throwable) { + ShizukuState.NotRunning + } + } + + private fun isShizukuInstalled(): Boolean = try { + context.packageManager.getPackageInfo(SHIZUKU_PACKAGE_NAME, 0) + true + } catch (_: PackageManager.NameNotFoundException) { + false + } + + private companion object { + const val SHIZUKU_PACKAGE_NAME = "moe.shizuku.privileged.api" + const val REQUEST_PERMISSION_CODE = 1 + const val MINIMUM_USER_SERVICE_VERSION = 10 + const val BIND_TIMEOUT_MILLIS = 15_000L + } +} diff --git a/framework/shizuku/src/main/kotlin/com/android/geto/framework/shizuku/ShizukuModule.kt b/framework/shizuku/src/main/kotlin/com/android/geto/framework/shizuku/ShizukuModule.kt new file mode 100644 index 000000000..365971a6f --- /dev/null +++ b/framework/shizuku/src/main/kotlin/com/android/geto/framework/shizuku/ShizukuModule.kt @@ -0,0 +1,34 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.framework.shizuku + +import com.android.geto.domain.framework.ShizukuWrapper +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface ShizukuModule { + + @Binds + @Singleton + fun shizukuWrapper(impl: DefaultShizukuWrapper): ShizukuWrapper +} diff --git a/framework/shizuku/src/main/kotlin/com/android/geto/framework/shizuku/UserService.kt b/framework/shizuku/src/main/kotlin/com/android/geto/framework/shizuku/UserService.kt new file mode 100644 index 000000000..7598fdd2b --- /dev/null +++ b/framework/shizuku/src/main/kotlin/com/android/geto/framework/shizuku/UserService.kt @@ -0,0 +1,54 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.framework.shizuku + +import android.app.IActivityManager +import android.content.pm.IPackageManager +import rikka.shizuku.SystemServiceHelper +import kotlin.system.exitProcess + +/** + * Runs inside the privileged process Shizuku hands us, so it can reach the hidden + * [IPackageManager.grantRuntimePermission]. `WRITE_SECURE_SETTINGS` carries the `development` + * protection flag in AOSP, which is what makes it grantable this way — the same reason + * `adb shell pm grant` works for it. + */ +internal class UserService : IUserService.Stub() { + private val packageManager = IPackageManager.Stub.asInterface( + SystemServiceHelper.getSystemService("package"), + ) + + private val activityManager = IActivityManager.Stub.asInterface( + SystemServiceHelper.getSystemService("activity"), + ) + + override fun destroy() { + exitProcess(1) + } + + override fun grantRuntimePermission( + packageName: String?, + permissionName: String?, + ) { + packageManager.grantRuntimePermission( + packageName, + permissionName, + activityManager.currentUserId, + ) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dab35044a..90c3ecb48 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,4 @@ [versions] -accompanist = "0.37.3" androidDesugarJdkLibs = "2.1.2" androidGradlePlugin = "9.1.0" androidTools = "32.1.0" @@ -26,9 +25,9 @@ ksp = "2.3.4" protobuf = "4.34.1" protobufPlugin = "0.9.6" room = "2.8.4" +shizuku = "13.1.5" [libraries] -accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanist" } android-desugarJdkLibs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "androidDesugarJdkLibs" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidxActivity" } androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "androidxActivity" } @@ -62,6 +61,7 @@ hilt-android-gradle-plugin = { group = "com.google.dagger", name = "hilt-android hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" } hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } javax-inject = { group = "javax.inject", name = "javax.inject", version.ref = "javaxInject" } +junit4 = { group = "junit", name = "junit", version.ref = "junit4" } kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } @@ -72,6 +72,8 @@ room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } +shizuku-api = { group = "dev.rikka.shizuku", name = "api", version.ref = "shizuku" } +shizuku-provider = { group = "dev.rikka.shizuku", name = "provider", version.ref = "shizuku" } # Dependencies of the included build-logic android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" } diff --git a/service/build.gradle.kts b/service/build.gradle.kts index 0506b7c80..180b9f548 100644 --- a/service/build.gradle.kts +++ b/service/build.gradle.kts @@ -27,7 +27,16 @@ android { dependencies { implementation(projects.common) + implementation(projects.domain.framework) + implementation(projects.domain.model) + implementation(projects.domain.repository) + implementation(projects.domain.useCase) implementation(projects.framework.notificationManager) implementation(libs.androidx.core.ktx) -} \ No newline at end of file + implementation(libs.kotlinx.coroutines.core) + + testImplementation(kotlin("test")) + testImplementation(libs.junit4) + testImplementation(libs.kotlinx.coroutines.test) +} diff --git a/service/src/main/AndroidManifest.xml b/service/src/main/AndroidManifest.xml index a48c87812..bf18178a8 100644 --- a/service/src/main/AndroidManifest.xml +++ b/service/src/main/AndroidManifest.xml @@ -16,13 +16,29 @@ ~ --> - - + + + + + android:foregroundServiceType="specialUse"> + + + + + + + + + - \ No newline at end of file + diff --git a/service/src/main/kotlin/com/android/geto/service/DomainProtectionServiceRuntime.kt b/service/src/main/kotlin/com/android/geto/service/DomainProtectionServiceRuntime.kt new file mode 100644 index 000000000..4a59f2d50 --- /dev/null +++ b/service/src/main/kotlin/com/android/geto/service/DomainProtectionServiceRuntime.kt @@ -0,0 +1,136 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.service + +import android.content.ComponentName +import android.content.Context +import com.android.geto.domain.model.ProtectedApp +import com.android.geto.domain.model.ProtectionMode +import com.android.geto.domain.model.ProtectionPauseDuration +import com.android.geto.domain.model.ProtectionResult +import com.android.geto.domain.model.ProtectionSessionStatus +import com.android.geto.domain.model.ProtectionState +import com.android.geto.domain.model.SettingType +import com.android.geto.domain.usecase.ProtectionController +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject + +internal class DomainProtectionServiceRuntime @Inject constructor( + private val protectionController: ProtectionController, + @param:ApplicationContext private val context: Context, +) : ProtectionServiceRuntime { + + /** + * Resolving a label is a binder call, and with several protected apps it would otherwise run on + * every database emission. Labels only change when a package is replaced, which is why + * [invalidateLabels] exists and why the boot/package-replaced receiver calls it. + */ + private val labelCache = ConcurrentHashMap() + + override val state: Flow = protectionController.state.map { state -> + state.asServiceState() + } + + override val armedApps: Flow> = combine( + protectionController.armedComponentNames, + protectionController.state, + ) { armed, state -> + armed.sorted().map { componentName -> + ArmedAppStatus( + componentName = componentName, + label = componentName.displayLabel(), + applied = state.forComponentName(componentName)?.asStatus(), + ) + } + } + + override suspend fun reconcile() { + protectionController.reconcile() + } + + override suspend fun reapply(settingType: SettingType, key: String): Boolean = when (protectionController.reapply(type = settingType, key = key)) { + is ProtectionResult.Success, + is ProtectionResult.KeyNotProtected, + ProtectionResult.NoActiveProtection, + -> true + + else -> false + } + + override suspend fun restore(sessionToken: String): Boolean = when (protectionController.restore(sessionToken)) { + is ProtectionResult.Success, + ProtectionResult.NoActiveProtection, + // The session is already gone, which is the outcome the caller wanted. + is ProtectionResult.StaleSession, + -> true + + else -> false + } + + override suspend fun pause( + sessionToken: String, + duration: ProtectionPauseDuration, + ): Boolean = protectionController.pause(sessionToken, duration) is ProtectionResult.Success + + override suspend fun resume(sessionToken: String): Boolean = protectionController.resume(sessionToken) is ProtectionResult.Success + + override fun invalidateLabels() { + labelCache.clear() + } + + private fun ProtectionState.asServiceState(): ProtectionServiceState { + if (apps.isEmpty()) return ProtectionServiceState.Inactive + + return ProtectionServiceState.Running( + apps = apps.map { it.asStatus() }, + observedSettings = apps + .filter { it.mode == ProtectionMode.FOREGROUND && it.isWatched } + .flatMapTo(linkedSetOf()) { app -> + app.protectedSettings.map { setting -> + ObservedProtectionSetting( + settingType = setting.settingType, + key = setting.key, + ) + } + }, + ) + } + + private fun ProtectedApp.asStatus(): ProtectedAppStatus = ProtectedAppStatus( + token = sessionToken, + componentName = componentName, + label = componentName.displayLabel(), + mode = mode, + status = status, + pause = pause, + settingCount = protectedSettings.size, + message = lastError.takeIf { status == ProtectionSessionStatus.RECOVERY_REQUIRED }, + ) + + private fun String.displayLabel(): String = labelCache.getOrPut(this) { + val component = ComponentName.unflattenFromString(this) ?: return@getOrPut this + runCatching { + context.packageManager.getActivityInfo(component, 0).loadLabel(context.packageManager) + .toString() + }.getOrDefault(component.packageName) + } +} diff --git a/service/src/main/kotlin/com/android/geto/service/ForegroundProtectionCoordinator.kt b/service/src/main/kotlin/com/android/geto/service/ForegroundProtectionCoordinator.kt new file mode 100644 index 000000000..994df6153 --- /dev/null +++ b/service/src/main/kotlin/com/android/geto/service/ForegroundProtectionCoordinator.kt @@ -0,0 +1,246 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.service + +import android.content.ComponentName +import android.content.Context +import com.android.geto.common.ApplicationScope +import com.android.geto.domain.framework.ForegroundAppWrapper +import com.android.geto.domain.model.ProtectionFailure +import com.android.geto.domain.model.ProtectionMode +import com.android.geto.domain.model.ProtectionResult +import com.android.geto.domain.usecase.ProtectionController +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Applies an armed profile while its app is in front and restores the originals the moment it leaves. + * + * This is the whole point of the app: writing something like `development_settings_enabled = 0` and + * leaving it there disables developer options system-wide and takes ADB and Shizuku with it. Scoping + * the change to the app's time on screen keeps the device usable the rest of the day. + * + * Nothing here polls. The foreground flow is fed by push events from the accessibility service, and + * both timers are single delays armed on demand and cancelled on the normal path. + */ +@Singleton +class ForegroundProtectionCoordinator @Inject constructor( + private val protectionController: ProtectionController, + private val foregroundAppWrapper: ForegroundAppWrapper, + private val protectionServiceManager: ProtectionServiceManager, + private val alertNotifier: ProtectionAlertNotifier, + @param:ApplicationContext private val context: Context, + @param:ApplicationScope private val applicationScope: CoroutineScope, +) { + /** Whether the user has granted the detector its permission; false means nothing will apply. */ + val isDetectorEnabled: Boolean get() = foregroundAppWrapper.isEnabled() + + /** Whether the detector is actually bound and delivering events right now. */ + val isDetectorRunning: Flow = foregroundAppWrapper.isRunning + + @Volatile + private var initialized = false + + /** The component currently applied, so a repeat event for the same app does nothing. */ + @Volatile + private var appliedComponentName: String? = null + + @Volatile + private var foregroundPackage: String? = null + + private var watchdogJob: Job? = null + private var pendingRestoreJob: Job? = null + + fun initialize() { + if (initialized) return + synchronized(this) { + if (initialized) return + initialized = true + } + + applicationScope.launch { + // Nothing should be applied at process start: a session only means "applied right now". + // Anything found here outlived the process that applied it, including a profile carried + // over from the old always-on mode. + runCatching { protectionController.restoreEverythingApplied() } + + observeForeground() + } + } + + private suspend fun observeForeground() { + combine( + foregroundAppWrapper.foregroundPackage, + foregroundAppWrapper.isRunning, + protectionController.armedComponentNames, + ) { currentPackage, isRunning, armed -> + Triple(currentPackage, isRunning, armed) + } + .distinctUntilChanged() + .collect { (currentPackage, isRunning, armed) -> + foregroundPackage = currentPackage + + try { + if (!isRunning) { + // Without the detector nothing would ever restore these. Fail safe. + cancelPendingRestore() + restoreApplied() + return@collect + } + + val wanted = currentPackage + ?.let { pkg -> armed.firstOrNull { it.packageOf() == pkg } } + + if (wanted == appliedComponentName) { + // Still on the protected app — or back on it before the grace period ran out. + cancelPendingRestore() + return@collect + } + + if (wanted == null) { + // Some other window is in front. Restoring immediately made protection + // flicker off for any overlay the detector's denylist misses, so give the app + // a moment to come back before undoing anything. + schedulePendingRestore() + return@collect + } + + cancelPendingRestore() + restoreApplied() + apply(wanted) + } catch (exception: CancellationException) { + throw exception + } catch (_: RuntimeException) { + // Never leave settings applied because of a transient failure. + restoreApplied() + } + } + } + + private suspend fun apply(componentName: String) { + when (val result = protectionController.enable(componentName, ProtectionMode.FOREGROUND)) { + is ProtectionResult.Success -> { + appliedComponentName = componentName + armWatchdog(componentName) + // Start the service now rather than waiting for the state observer to notice. It + // exists only to defend the values while they are applied, so its lifetime matches + // this window. + protectionServiceManager.onProtectionEnabled() + } + + // Silence here was the worst part of this path: a rejected key, a missing permission and + // a conflict all looked identical to "the app just doesn't work". + else -> reportApplyFailure(componentName, result) + } + } + + private fun reportApplyFailure(componentName: String, result: ProtectionResult) { + val label = ComponentName.unflattenFromString(componentName)?.packageName ?: componentName + + val detail = when (result) { + is ProtectionResult.PermissionDenied -> + context.getString(R.string.apply_failed_permission) + + is ProtectionResult.KeyConflict -> + context.getString(R.string.apply_failed_key, result.key) + + is ProtectionResult.Failure -> result.failures.firstKeyOrNull() + ?.let { context.getString(R.string.apply_failed_key, it) } + ?: context.getString(R.string.apply_failed_generic) + + is ProtectionResult.RecoveryRequired -> result.failures.firstKeyOrNull() + ?.let { context.getString(R.string.apply_failed_key, it) } + ?: context.getString(R.string.apply_failed_generic) + + else -> context.getString(R.string.apply_failed_generic) + } + + runCatching { alertNotifier.notifyRecoveryRequired(label = label, detail = detail) } + } + + private fun List.firstKeyOrNull(): String? = firstNotNullOfOrNull { it.key } + + private suspend fun restoreApplied() { + val applied = appliedComponentName ?: return + appliedComponentName = null + cancelWatchdog() + protectionController.restoreApplied(applied) + } + + /** + * Restores shortly after the protected app stops being in front. + * + * A single delay, not a timer: it exists because a window that is not really an app switch would + * otherwise undo protection while the user is still in the app. + */ + private fun schedulePendingRestore() { + if (appliedComponentName == null || pendingRestoreJob?.isActive == true) return + + pendingRestoreJob = applicationScope.launch { + delay(RESTORE_GRACE_MILLIS) + restoreApplied() + } + } + + private fun cancelPendingRestore() { + pendingRestoreJob?.cancel() + pendingRestoreJob = null + } + + /** + * Backstop for a detector that died without saying so — a vendor task killer, say — which would + * otherwise leave the settings applied indefinitely. + * + * It deliberately checks the app is no longer in front. An earlier version did not, so simply + * using a protected app for longer than the timeout silently turned protection off under the user. + */ + private fun armWatchdog(componentName: String) { + cancelWatchdog() + watchdogJob = applicationScope.launch { + delay(WATCHDOG_MILLIS) + + val stillInFront = foregroundPackage == componentName.packageOf() + if (appliedComponentName == componentName && !stillInFront) { + appliedComponentName = null + runCatching { protectionController.restoreApplied(componentName) } + } + } + } + + private fun cancelWatchdog() { + watchdogJob?.cancel() + watchdogJob = null + } + + /** Armed entries are component names; foreground events give a package name. */ + private fun String.packageOf(): String = ComponentName.unflattenFromString(this)?.packageName ?: substringBefore('/') + + private companion object { + const val WATCHDOG_MILLIS = 15 * 60 * 1_000L + const val RESTORE_GRACE_MILLIS = 1_500L + } +} diff --git a/service/src/main/kotlin/com/android/geto/service/ProtectionAlertNotifier.kt b/service/src/main/kotlin/com/android/geto/service/ProtectionAlertNotifier.kt new file mode 100644 index 000000000..757146352 --- /dev/null +++ b/service/src/main/kotlin/com/android/geto/service/ProtectionAlertNotifier.kt @@ -0,0 +1,96 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.service + +import android.app.PendingIntent +import android.content.Context +import androidx.core.app.NotificationCompat +import com.android.geto.framework.notificationmanager.AndroidNotificationManagerWrapper +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import com.android.geto.framework.notificationmanager.R as notificationR + +@Singleton +class ProtectionAlertNotifier @Inject constructor( + @param:ApplicationContext private val context: Context, + private val notificationManager: AndroidNotificationManagerWrapper, +) { + fun notifyInterrupted(label: String?) { + notify( + notificationId = INTERRUPTION_NOTIFICATION_ID, + title = context.getString(R.string.protection_interrupted), + text = label?.let { + context.getString(R.string.protection_restarted_for, it) + } ?: context.getString(R.string.protection_restarted), + ) + } + + fun notifyMayBeInterrupted(label: String?) { + notify( + notificationId = INTERRUPTION_NOTIFICATION_ID, + title = context.getString(R.string.protection_interrupted), + text = label?.let { + context.getString(R.string.open_geto_to_check_profile, it) + } ?: context.getString(R.string.open_geto_to_check_protection), + ) + } + + fun notifyRecoveryRequired(label: String?, detail: String? = null) { + notify( + notificationId = RECOVERY_NOTIFICATION_ID, + title = context.getString(R.string.protection_needs_attention), + text = detail ?: label?.let { + context.getString(R.string.open_geto_to_recover_profile, it) + } ?: context.getString(R.string.open_geto_to_recover), + ) + } + + private fun notify(notificationId: Int, title: String, text: String) { + val contentIntent = context.packageManager.getLaunchIntentForPackage(context.packageName) + ?.let { + PendingIntent.getActivity( + context, + notificationId, + it, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + + val notification = NotificationCompat.Builder( + context, + AndroidNotificationManagerWrapper.PROTECTION_ALERT_CHANNEL_ID, + ) + .setSmallIcon(notificationR.drawable.baseline_settings_24) + .setContentTitle(title) + .setContentText(text) + .setStyle(NotificationCompat.BigTextStyle().bigText(text)) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .setContentIntent(contentIntent) + .build() + + notificationManager.notify(notificationId, notification) + } + + private companion object { + const val INTERRUPTION_NOTIFICATION_ID = 20_002 + const val RECOVERY_NOTIFICATION_ID = 20_003 + } +} diff --git a/service/src/main/kotlin/com/android/geto/service/ProtectionRecoveryReceiver.kt b/service/src/main/kotlin/com/android/geto/service/ProtectionRecoveryReceiver.kt new file mode 100644 index 000000000..1fb5f3eff --- /dev/null +++ b/service/src/main/kotlin/com/android/geto/service/ProtectionRecoveryReceiver.kt @@ -0,0 +1,63 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.service + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.android.geto.common.ApplicationScope +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +@AndroidEntryPoint +class ProtectionRecoveryReceiver : BroadcastReceiver() { + @Inject + lateinit var serviceManager: ProtectionServiceManager + + @Inject + @ApplicationScope + lateinit var applicationScope: CoroutineScope + + @Inject + lateinit var runtime: ProtectionServiceRuntime + + override fun onReceive(context: Context, intent: Intent) { + if ( + intent.action != Intent.ACTION_BOOT_COMPLETED && + intent.action != Intent.ACTION_MY_PACKAGE_REPLACED + ) { + return + } + + // A package replacement can change an app's display name, and labels are cached. + if (intent.action == Intent.ACTION_MY_PACKAGE_REPLACED) { + runtime.invalidateLabels() + } + + val pendingResult = goAsync() + applicationScope.launch { + try { + serviceManager.reconcileAfterSystemEvent() + } finally { + pendingResult.finish() + } + } + } +} diff --git a/service/src/main/kotlin/com/android/geto/service/ProtectionService.kt b/service/src/main/kotlin/com/android/geto/service/ProtectionService.kt new file mode 100644 index 000000000..eb9d1e423 --- /dev/null +++ b/service/src/main/kotlin/com/android/geto/service/ProtectionService.kt @@ -0,0 +1,596 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.service + +import android.app.Notification +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.database.ContentObserver +import android.net.Uri +import android.os.Build +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.provider.Settings +import android.text.format.DateFormat +import androidx.core.app.NotificationCompat +import androidx.core.app.ServiceCompat +import androidx.core.content.ContextCompat +import com.android.geto.domain.model.ProtectionPauseDuration +import com.android.geto.domain.model.ProtectionPauseState +import com.android.geto.domain.model.ProtectionSessionStatus +import com.android.geto.domain.model.SettingType +import com.android.geto.framework.notificationmanager.AndroidNotificationManagerWrapper +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.util.Date +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject +import com.android.geto.framework.notificationmanager.R as notificationR + +@AndroidEntryPoint +class ProtectionService : Service() { + @Inject + lateinit var notificationManager: AndroidNotificationManagerWrapper + + @Inject + lateinit var runtime: ProtectionServiceRuntime + + @Inject + lateinit var alertNotifier: ProtectionAlertNotifier + + private val serviceJob = SupervisorJob() + private val serviceScope = CoroutineScope(serviceJob) + + /** + * One observer instance per URI. `unregisterContentObserver` removes every URI a given instance + * was registered for, so sharing one instance would make it impossible to stop watching a single + * key when one app of several turns off. + */ + private val observersByUri = ConcurrentHashMap() + private val pendingReapplyJobs = ConcurrentHashMap() + + /** + * DROP_OLDEST rather than SUSPEND: `tryEmit` from a ContentObserver cannot suspend, so with the + * default policy a burst past the buffer silently discarded repairs. Dropping is now explicit and + * recorded, and [droppedChange] turns it into a full reconcile instead of a lost repair. + */ + private val changedSettings = MutableSharedFlow( + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + private val droppedChange = AtomicBoolean(false) + + @Volatile + private var runningState: ProtectionServiceState.Running? = null + + /** Session tokens already alerted about, so a recovery alert is posted once, not per emission. */ + private val alertedRecoveryTokens = ConcurrentHashMap.newKeySet() + + @Volatile + private var stickyRestart = false + + @Volatile + private var explicitStopRequested = false + + @Volatile + private var interruptionAlertPosted = false + + @Volatile + private var autoRestartEnabled = false + + override fun onCreate() { + super.onCreate() + // Clear any flag left set by a stop request that raced a teardown; otherwise it would + // suppress this instance's legitimate interruption alert. + PROCESS_STOP_REQUESTED.set(false) + _isRunning.update { true } + + ServiceCompat.startForeground( + this, + ONGOING_NOTIFICATION_ID, + startingNotification(), + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE + } else { + 0 + }, + ) + + serviceScope.launch { observeProtectionState() } + + serviceScope.launch { + changedSettings.collect { setting -> + pendingReapplyJobs.remove(setting)?.cancel() + pendingReapplyJobs[setting] = launch { + delay(SETTING_CHANGE_COALESCE_MILLIS) + val currentJob = coroutineContext[Job] + if (currentJob != null) { + pendingReapplyJobs.remove(setting, currentJob) + } + if (currentJob?.isActive != true) return@launch + if (!observersByUri.containsKey(setting.toUri())) return@launch + + // No alert here on failure: reapply moves every affected app to + // RECOVERY_REQUIRED, and the state handler alerts per app with the right label. + // Alerting here could only guess at which app owns the key, and guessed wrong. + runtime.reapply(settingType = setting.settingType, key = setting.key) + + if (droppedChange.compareAndSet(true, false)) { + runtime.reconcile() + } + } + } + } + } + + /** + * Retried because everything downstream is binder or database work that can throw transiently. + * Without a retry the collector would die and the service would sit there looking healthy while + * protecting nothing. + * + * The retry is bounded, though: a permanent failure such as a corrupt database would otherwise + * spin this forever at a fixed interval, screen off, with the foreground service making sure the + * process is never killed to end it. After [RetryPolicy.MAX_ATTEMPTS] it stops and says so. + */ + private suspend fun observeProtectionState() { + val retryPolicy = RetryPolicy() + + while (serviceScope.isActive) { + try { + runtime.state.collectLatest { state -> + retryPolicy.reset() + handleProtectionState(state) + awaitNextPauseDeadline(state) + } + return + } catch (exception: CancellationException) { + throw exception + } catch (_: RuntimeException) { + val delayMillis = retryPolicy.nextDelayMillis() + if (delayMillis == null) { + alertNotifier.notifyRecoveryRequired( + label = null, + detail = getString(R.string.protection_stopped_repeated_failures), + ) + interruptionAlertPosted = true + stopForegroundAndSelf(intentional = true) + return + } + delay(delayMillis) + } + } + } + + /** + * Sleeps until the soonest pause expiry, then resumes those apps. + * + * `collectLatest` cancels this on the next emission, so the timer re-arms whenever the pause set + * changes. The service stays alive through a pause, which is what lets a plain delay work here. + */ + private suspend fun awaitNextPauseDeadline(state: ProtectionServiceState) { + val running = state as? ProtectionServiceState.Running ?: return + val deadlines = running.apps.mapNotNull { app -> + (app.pause as? ProtectionPauseState.PausedUntil)?.untilMillis?.let { app.token to it } + } + val soonest = deadlines.minOfOrNull { it.second } ?: return + + delay((soonest - System.currentTimeMillis()).coerceAtLeast(0)) + + deadlines.filter { it.second <= System.currentTimeMillis() } + .forEach { (token, _) -> runtime.resume(token) } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent == null) { + stickyRestart = true + // A null intent is only delivered when Android recreates a START_STICKY service. + // Preserve that contract across process death instead of downgrading the restarted + // instance to START_NOT_STICKY. + autoRestartEnabled = true + } + autoRestartEnabled = intent?.getBooleanExtra( + EXTRA_AUTO_RESTART, + autoRestartEnabled, + ) ?: autoRestartEnabled + + when (intent?.action) { + ACTION_STOP_AND_RESTORE -> { + val sessionToken = intent.getStringExtra(EXTRA_SESSION_TOKEN) + if (sessionToken != null) { + serviceScope.launch { + if (!runtime.restore(sessionToken)) { + alertNotifier.notifyRecoveryRequired(labelFor(sessionToken)) + } + } + } + } + + ACTION_PAUSE -> { + val sessionToken = intent.getStringExtra(EXTRA_SESSION_TOKEN) + val duration = intent.getStringExtra(EXTRA_PAUSE_DURATION) + ?.let { runCatching { ProtectionPauseDuration.valueOf(it) }.getOrNull() } + ?: ProtectionPauseDuration.THIRTY_MINUTES + if (sessionToken != null) { + serviceScope.launch { runtime.pause(sessionToken, duration) } + } + } + + ACTION_RESUME -> { + val sessionToken = intent.getStringExtra(EXTRA_SESSION_TOKEN) + if (sessionToken != null) { + serviceScope.launch { runtime.resume(sessionToken) } + } + } + + ACTION_RECONCILE, null -> serviceScope.launch { runtime.reconcile() } + + ACTION_UPDATE_AUTO_RESTART -> Unit + } + + return if (autoRestartEnabled) START_STICKY else START_NOT_STICKY + } + + override fun onDestroy() { + val processStopRequested = PROCESS_STOP_REQUESTED.getAndSet(false) + if ( + runningState?.needsForeground == true && + !explicitStopRequested && + !processStopRequested && + !interruptionAlertPosted + ) { + alertNotifier.notifyMayBeInterrupted(soleProtectedLabel()) + } + unregisterAllObservers() + cancelPendingReapplyJobs() + serviceScope.cancel() + _isRunning.update { false } + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onTimeout(startId: Int, fgsType: Int) { + alertNotifier.notifyRecoveryRequired( + label = soleProtectedLabel(), + detail = getString(R.string.protection_timed_out), + ) + interruptionAlertPosted = true + stopForegroundAndSelf(intentional = false) + } + + private fun handleProtectionState(state: ProtectionServiceState) { + when (state) { + ProtectionServiceState.Loading -> Unit + + ProtectionServiceState.Inactive -> { + runningState = null + unregisterAllObservers() + cancelPendingReapplyJobs() + stopForegroundAndSelf(intentional = true) + } + + is ProtectionServiceState.Running -> { + runningState = state + + // One app needing recovery must not take protection away from the others. Alert only + // for apps that have newly entered recovery — this branch runs on every state change, + // so notifying unconditionally re-posted the same alert repeatedly. + val recoveryTokens = state.recoveryApps.mapTo(mutableSetOf()) { it.token } + state.recoveryApps + .filter { alertedRecoveryTokens.add(it.token) } + .forEach { app -> alertNotifier.notifyRecoveryRequired(app.label, app.message) } + alertedRecoveryTokens.retainAll(recoveryTokens) + + if (!state.needsForeground) { + unregisterAllObservers() + cancelPendingReapplyJobs() + stopForegroundAndSelf(intentional = true) + return + } + + syncSettingsObservers(state.observedSettings) + + notificationManager.notify( + ONGOING_NOTIFICATION_ID, + createOngoingNotification(state), + ) + + if (stickyRestart) { + stickyRestart = false + alertNotifier.notifyInterrupted(state.apps.firstOrNull()?.label) + } + } + } + } + + /** Adds and removes only what changed, so one app turning off leaves the others watching. */ + private fun syncSettingsObservers(settings: Set) { + val wanted = settings.associateBy { it.toUri() } + + (observersByUri.keys - wanted.keys).forEach { uri -> + observersByUri.remove(uri)?.let { observer -> + runCatching { contentResolver.unregisterContentObserver(observer) } + } + } + + (wanted.keys - observersByUri.keys).forEach { uri -> + val setting = wanted.getValue(uri) + val observer = object : ContentObserver(Handler(Looper.getMainLooper())) { + override fun onChange(selfChange: Boolean, changedUri: Uri?) { + if (!changedSettings.tryEmit(setting)) { + // Whatever was dropped still has to be repaired; a reconcile covers all keys. + droppedChange.set(true) + } + } + } + observersByUri[uri] = observer + runCatching { contentResolver.registerContentObserver(uri, false, observer) } + .onFailure { + // Some OEM ROMs refuse to observe particular settings. That key is now unwatched, + // which the user needs to know about rather than discovering it as silent drift. + observersByUri.remove(uri) + alertNotifier.notifyRecoveryRequired( + label = soleProtectedLabel(), + detail = getString(R.string.setting_cannot_be_watched, setting.key), + ) + } + } + } + + private fun unregisterAllObservers() { + observersByUri.values.forEach { observer -> + runCatching { contentResolver.unregisterContentObserver(observer) } + } + observersByUri.clear() + } + + private fun cancelPendingReapplyJobs() { + pendingReapplyJobs.values.forEach(Job::cancel) + pendingReapplyJobs.clear() + } + + /** + * A label only when exactly one app is protected. With several, naming any one of them would be a + * guess, and the notifier already has a correct app-agnostic message. + */ + private fun soleProtectedLabel(): String? = runningState?.apps?.singleOrNull()?.label + + private fun labelFor(sessionToken: String): String? = runningState?.apps?.firstOrNull { it.token == sessionToken }?.label + + private fun startingNotification(): Notification = notificationBuilder() + .setContentTitle(getString(R.string.protection_active)) + .setContentText(getString(R.string.starting_protection)) + .build() + + private fun createOngoingNotification(state: ProtectionServiceState.Running): Notification { + val watched = state.apps.filter { it.mode == com.android.geto.domain.model.ProtectionMode.FOREGROUND } + val single = watched.singleOrNull() + + val builder = notificationBuilder() + + return if (single != null) { + // Keep the familiar single-app wording and per-app actions. + builder + .setContentTitle( + getString( + if (single.pause != ProtectionPauseState.Running) { + R.string.protection_paused + } else { + R.string.protection_active + }, + ), + ) + .setContentText(single.notificationText()) + .addAppActions(single) + .build() + } else { + val pausedCount = watched.count { it.pause != ProtectionPauseState.Running } + builder + .setContentTitle( + if (pausedCount > 0) { + getString(R.string.protecting_apps_with_paused, watched.size, pausedCount) + } else { + resources.getQuantityString( + R.plurals.protecting_apps, + watched.size, + watched.size, + ) + }, + ) + .setContentText(watched.joinToString(separator = ", ") { it.label }) + .setStyle( + NotificationCompat.InboxStyle().also { style -> + watched.forEach { style.addLine(it.notificationText()) } + }, + ) + // A notification action cannot ask which app, so with several protected the only + // sensible affordance is to open the list. + .addAction( + android.R.drawable.ic_menu_manage, + getString(R.string.manage_protection), + contentPendingIntent(), + ) + .build() + } + } + + private fun NotificationCompat.Builder.addAppActions( + app: ProtectedAppStatus, + ): NotificationCompat.Builder = apply { + addAction( + android.R.drawable.ic_menu_close_clear_cancel, + getString(R.string.stop_and_restore), + servicePendingIntent( + action = ACTION_STOP_AND_RESTORE, + sessionToken = app.token, + requestCode = STOP_REQUEST_CODE, + ), + ) + + val paused = app.pause != ProtectionPauseState.Running + addAction( + if (paused) android.R.drawable.ic_media_play else android.R.drawable.ic_media_pause, + getString(if (paused) R.string.resume_now else R.string.pause_for_thirty_minutes), + servicePendingIntent( + action = if (paused) ACTION_RESUME else ACTION_PAUSE, + sessionToken = app.token, + requestCode = PAUSE_REQUEST_CODE, + pauseDuration = ProtectionPauseDuration.THIRTY_MINUTES.takeIf { !paused }, + ), + ) + } + + private fun ProtectedAppStatus.notificationText(): String = when { + status == ProtectionSessionStatus.RECOVERY_REQUIRED -> getString( + R.string.app_needs_attention, + label, + ) + + pause is ProtectionPauseState.PausedUntil -> getString( + R.string.paused_resumes_at, + label, + DateFormat.getTimeFormat(this@ProtectionService) + .format(Date((pause as ProtectionPauseState.PausedUntil).untilMillis)), + ) + + pause == ProtectionPauseState.PausedIndefinitely -> getString( + R.string.paused_until_resumed, + label, + ) + + else -> getString(R.string.protecting_profile, label) + } + + private fun notificationBuilder(): NotificationCompat.Builder = NotificationCompat.Builder( + this, + AndroidNotificationManagerWrapper.PROTECTION_NOTIFICATION_CHANNEL_ID, + ) + .setSmallIcon(notificationR.drawable.baseline_settings_24) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setContentIntent(contentPendingIntent()) + + private fun contentPendingIntent(): PendingIntent? = packageManager.getLaunchIntentForPackage(packageName)?.let { + PendingIntent.getActivity( + this, + CONTENT_REQUEST_CODE, + it, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + + private fun servicePendingIntent( + action: String, + sessionToken: String, + requestCode: Int, + pauseDuration: ProtectionPauseDuration? = null, + ): PendingIntent { + val intent = Intent(this, ProtectionService::class.java).apply { + this.action = action + // Distinct data keeps these PendingIntents from collapsing into one another. + data = Uri.parse("geto-protection:$action:$sessionToken") + putExtra(EXTRA_SESSION_TOKEN, sessionToken) + pauseDuration?.let { putExtra(EXTRA_PAUSE_DURATION, it.name) } + } + + return PendingIntent.getService( + this, + requestCode, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + + private fun stopForegroundAndSelf(intentional: Boolean) { + explicitStopRequested = intentional + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + stopSelf() + } + + private fun ObservedProtectionSetting.toUri(): Uri = when (settingType) { + SettingType.SYSTEM -> Settings.System.getUriFor(key) + SettingType.SECURE -> Settings.Secure.getUriFor(key) + SettingType.GLOBAL -> Settings.Global.getUriFor(key) + } + + companion object { + private const val ONGOING_NOTIFICATION_ID = 20_001 + private const val STOP_REQUEST_CODE = 20_011 + private const val CONTENT_REQUEST_CODE = 20_012 + private const val PAUSE_REQUEST_CODE = 20_013 + private const val SETTING_CHANGE_COALESCE_MILLIS = 500L + private const val EXTRA_AUTO_RESTART = "com.android.geto.extra.AUTO_RESTART_PROTECTION" + private const val EXTRA_SESSION_TOKEN = "com.android.geto.extra.PROTECTION_SESSION_TOKEN" + private const val EXTRA_PAUSE_DURATION = "com.android.geto.extra.PROTECTION_PAUSE_DURATION" + + const val ACTION_RECONCILE = "com.android.geto.action.RECONCILE_PROTECTION" + const val ACTION_UPDATE_AUTO_RESTART = + "com.android.geto.action.UPDATE_AUTO_RESTART_PROTECTION" + const val ACTION_STOP_AND_RESTORE = "com.android.geto.action.STOP_AND_RESTORE_PROTECTION" + const val ACTION_PAUSE = "com.android.geto.action.PAUSE_PROTECTION" + const val ACTION_RESUME = "com.android.geto.action.RESUME_PROTECTION" + + private val _isRunning = MutableStateFlow(false) + val isRunning = _isRunning.asStateFlow() + private val PROCESS_STOP_REQUESTED = AtomicBoolean(false) + + fun start(context: Context, autoRestart: Boolean) { + val intent = Intent(context, ProtectionService::class.java).apply { + action = ACTION_RECONCILE + putExtra(EXTRA_AUTO_RESTART, autoRestart) + } + ContextCompat.startForegroundService(context, intent) + } + + fun updateAutoRestart(context: Context, autoRestart: Boolean) { + val intent = Intent(context, ProtectionService::class.java).apply { + action = ACTION_UPDATE_AUTO_RESTART + putExtra(EXTRA_AUTO_RESTART, autoRestart) + } + context.startService(intent) + } + + fun stopWithoutRestore(context: Context) { + if (isRunning.value) { + PROCESS_STOP_REQUESTED.set(true) + } + context.stopService(Intent(context, ProtectionService::class.java)) + } + } +} diff --git a/service/src/main/kotlin/com/android/geto/service/ProtectionServiceManager.kt b/service/src/main/kotlin/com/android/geto/service/ProtectionServiceManager.kt new file mode 100644 index 000000000..293002ec0 --- /dev/null +++ b/service/src/main/kotlin/com/android/geto/service/ProtectionServiceManager.kt @@ -0,0 +1,372 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.service + +import android.app.ForegroundServiceStartNotAllowedException +import android.content.Context +import android.os.Build +import com.android.geto.common.ApplicationScope +import com.android.geto.domain.repository.UserDataRepository +import com.android.geto.framework.notificationmanager.AndroidNotificationManagerWrapper +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ProtectionServiceManager @Inject constructor( + @param:ApplicationContext private val context: Context, + private val userDataRepository: UserDataRepository, + private val runtime: ProtectionServiceRuntime, + private val alertNotifier: ProtectionAlertNotifier, + private val notificationManager: AndroidNotificationManagerWrapper, + @param:ApplicationScope private val applicationScope: CoroutineScope, +) { + @Volatile + private var initialized = false + + private val restartScheduled = AtomicBoolean(false) + private val restartAttempts = AtomicInteger(0) + private val runningGeneration = AtomicInteger(0) + + /** Monotonic, so a stable-run reset can tell "quiet" from "restarting just slowly enough". */ + private val totalRestarts = AtomicInteger(0) + + fun initialize() { + if (initialized) return + synchronized(this) { + if (initialized) return + initialized = true + } + + applicationScope.launch { observeProtectionState() } + } + + fun reconcile() { + launchSafely { runtime.reconcile() } + } + + fun reconcileFromVisibleApp() { + launchSafely { + runtime.reconcile() + val userData = userDataRepository.userData.first() + if (runtime.state.first().needsForeground() && userData.autoRestartProtection) { + startService(autoRestart = true) + } + } + } + + fun onProtectionEnabled() { + resetRestartBackoff() + launchSafely { + val userData = userDataRepository.userData.first() + if (runtime.state.first().needsForeground()) { + startService(autoRestart = userData.autoRestartProtection) + } + } + } + + suspend fun reconcileAfterSystemEvent() { + try { + val initialState = runtime.state.first() + if (initialState !is ProtectionServiceState.Running) return + + initialState.oneShotApps.forEach { app -> + notifyRecoveryRequired( + label = app.label, + detail = context.getString(R.string.one_shot_restore_required, app.label), + ) + } + + if (!initialState.needsForeground) return + + val userData = userDataRepository.userData.first() + if (!userData.autoRestartProtection) return + + runtime.reconcile() + if (runtime.state.first().needsForeground()) { + startService(autoRestart = true) + } + } catch (exception: CancellationException) { + throw exception + } catch (exception: RuntimeException) { + notifyManagerFailure(exception) + } + } + + /** + * Runs for the whole process lifetime. + * + * The retry is bounded and only reports once at the end. It previously retried on a flat + * two-second timer forever *and* posted a notification on every iteration, so a failure that + * could not clear itself cost a binder round-trip 30 times a minute with the screen off. + */ + private suspend fun observeProtectionState() { + var previousState: ProtectionServiceState? = null + var previousAutoRestart: Boolean? = null + var previousIsServiceRunning: Boolean? = null + val retryPolicy = RetryPolicy() + + while (currentCoroutineContext().isActive) { + try { + combine( + runtime.state, + userDataRepository.userData, + ProtectionService.isRunning, + ) { state, userData, isServiceRunning -> + Triple(state, userData.autoRestartProtection, isServiceRunning) + } + .distinctUntilChanged() + .collect { (state, autoRestart, isServiceRunning) -> + retryPolicy.reset() + try { + when { + state.needsForeground() -> { + val wasEnabledInThisProcess = previousState != null && + !previousState.needsForeground() + val autoRestartWasJustEnabled = + previousAutoRestart == false && autoRestart + val stoppedUnexpectedly = + previousIsServiceRunning == true && !isServiceRunning + + if (stoppedUnexpectedly) { + runningGeneration.incrementAndGet() + totalRestarts.incrementAndGet() + } + + when { + isServiceRunning -> { + updateRunningService(autoRestart) + if (previousIsServiceRunning != true) { + scheduleStableRunBackoffReset() + } + } + + wasEnabledInThisProcess || autoRestartWasJustEnabled -> { + resetRestartBackoff() + startService(autoRestart) + } + + stoppedUnexpectedly && autoRestart -> + scheduleUnexpectedRestart() + } + } + + // Nothing left worth a foreground service: only one-shot sessions, + // apps needing recovery, or nothing at all. The service itself posts + // the per-app recovery alerts, so this just stops it. + state is ProtectionServiceState.Running || + state is ProtectionServiceState.Inactive -> { + resetRestartBackoff() + ProtectionService.stopWithoutRestore(context) + } + + else -> Unit + } + } catch (exception: CancellationException) { + throw exception + } catch (exception: RuntimeException) { + notifyManagerFailure(exception) + } + + if (state != ProtectionServiceState.Loading) { + previousState = state + } + previousAutoRestart = autoRestart + previousIsServiceRunning = isServiceRunning + } + } catch (exception: CancellationException) { + throw exception + } catch (exception: RuntimeException) { + val delayMillis = retryPolicy.nextDelayMillis() + if (delayMillis == null) { + // One alert at the end, not one per attempt. + notifyManagerFailure(exception) + return + } + delay(delayMillis) + } + } + } + + private fun updateRunningService(autoRestart: Boolean) { + try { + ProtectionService.updateAutoRestart(context, autoRestart) + } catch (exception: RuntimeException) { + notifyManagerFailure(exception) + } + } + + private fun startService(autoRestart: Boolean): StartOutcome { + if (ProtectionService.isRunning.value) { + updateRunningService(autoRestart) + return StartOutcome.STARTED_OR_UPDATED + } + + if ( + !notificationManager.areNotificationsEnabled() || + !notificationManager.isNotificationChannelEnabled( + AndroidNotificationManagerWrapper.PROTECTION_NOTIFICATION_CHANNEL_ID, + ) + ) { + // Silently returning here meant protection simply never started, with nothing anywhere + // explaining why. The user has to be told, since only they can re-enable the channel. + notifyRecoveryRequired( + label = null, + detail = context.getString(R.string.protection_blocked_by_notifications), + ) + return StartOutcome.BLOCKED_BY_NOTIFICATIONS + } + + return try { + ProtectionService.start(context, autoRestart) + StartOutcome.STARTED_OR_UPDATED + } catch (exception: RuntimeException) { + notifyManagerFailure(exception) + StartOutcome.FAILED + } + } + + /** + * Restarts the service after an unexpected stop, backing off and eventually giving up. + * + * Without the give-up an unstartable service — `ForegroundServiceStartNotAllowedException` on + * Android 12+, say — retried every five minutes forever, each attempt costing a preferences read, + * a database query, a `startForegroundService` binder call and a notification. + */ + private fun scheduleUnexpectedRestart() { + if (!restartScheduled.compareAndSet(false, true)) return + + val attempt = restartAttempts.getAndIncrement() + if (attempt >= MAX_RESTART_ATTEMPTS) { + restartScheduled.set(false) + notifyRestartGivenUp() + return + } + + val backoffMillis = (RESTART_BASE_MILLIS shl attempt.coerceAtMost(MAX_BACKOFF_EXPONENT)) + .coerceAtMost(RESTART_MAX_MILLIS) + + applicationScope.launch { + var retry = false + try { + delay(backoffMillis) + val autoRestart = userDataRepository.userData.first().autoRestartProtection + val state = runtime.state.first() + if (autoRestart && state.needsForeground() && !ProtectionService.isRunning.value) { + retry = startService(autoRestart = true) == StartOutcome.FAILED + } + } catch (exception: CancellationException) { + throw exception + } catch (exception: RuntimeException) { + notifyManagerFailure(exception) + retry = true + } finally { + restartScheduled.set(false) + if (retry) scheduleUnexpectedRestart() + } + } + } + + /** + * Clears the backoff once the service has genuinely settled. + * + * The uptime check alone was not enough: a vendor task killer that stops the service just after + * the threshold reset the counter every cycle, so backoff never engaged and the app restarted at + * the two-second floor indefinitely. Requiring a quiet stretch of *no restarts* as well means + * repeated kills still escalate. + */ + private fun scheduleStableRunBackoffReset() { + val generation = runningGeneration.incrementAndGet() + val restartsAtSchedule = totalRestarts.get() + applicationScope.launch { + delay(STABLE_RUN_MILLIS) + if ( + runningGeneration.get() == generation && + ProtectionService.isRunning.value && + totalRestarts.get() == restartsAtSchedule + ) { + restartAttempts.set(0) + } + } + } + + private fun notifyRestartGivenUp() { + notifyRecoveryRequired( + label = null, + detail = context.getString(R.string.protection_stopped_repeated_failures), + ) + } + + private fun resetRestartBackoff() { + runningGeneration.incrementAndGet() + restartAttempts.set(0) + } + + private fun launchSafely(block: suspend () -> Unit) { + applicationScope.launch { + try { + block() + } catch (exception: CancellationException) { + throw exception + } catch (exception: RuntimeException) { + notifyManagerFailure(exception) + } + } + } + + private fun notifyManagerFailure(exception: RuntimeException) { + val detail = if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + exception is ForegroundServiceStartNotAllowedException + ) { + context.getString(R.string.open_geto_to_resume_protection) + } else { + context.getString(R.string.protection_manager_failed) + } + notifyRecoveryRequired(label = null, detail = detail) + } + + private fun notifyRecoveryRequired(label: String?, detail: String?) { + runCatching { alertNotifier.notifyRecoveryRequired(label, detail) } + } + + private enum class StartOutcome { + STARTED_OR_UPDATED, + BLOCKED_BY_NOTIFICATIONS, + FAILED, + } + + private companion object { + const val RESTART_BASE_MILLIS = 2_000L + const val RESTART_MAX_MILLIS = 5 * 60 * 1_000L + const val STABLE_RUN_MILLIS = 60_000L + const val MAX_BACKOFF_EXPONENT = 8 + const val MAX_RESTART_ATTEMPTS = 10 + } +} diff --git a/service/src/main/kotlin/com/android/geto/service/ProtectionServiceModule.kt b/service/src/main/kotlin/com/android/geto/service/ProtectionServiceModule.kt new file mode 100644 index 000000000..a238e6cd6 --- /dev/null +++ b/service/src/main/kotlin/com/android/geto/service/ProtectionServiceModule.kt @@ -0,0 +1,34 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.service + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface ProtectionServiceModule { + @Binds + @Singleton + fun bindProtectionServiceRuntime( + implementation: DomainProtectionServiceRuntime, + ): ProtectionServiceRuntime +} diff --git a/service/src/main/kotlin/com/android/geto/service/ProtectionServiceRuntime.kt b/service/src/main/kotlin/com/android/geto/service/ProtectionServiceRuntime.kt new file mode 100644 index 000000000..948389db7 --- /dev/null +++ b/service/src/main/kotlin/com/android/geto/service/ProtectionServiceRuntime.kt @@ -0,0 +1,132 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.service + +import com.android.geto.domain.model.ProtectionMode +import com.android.geto.domain.model.ProtectionPauseDuration +import com.android.geto.domain.model.ProtectionPauseState +import com.android.geto.domain.model.ProtectionSessionStatus +import com.android.geto.domain.model.SettingType +import kotlinx.coroutines.flow.Flow + +/** + * Android-facing projection of the domain protection controller. + * + * Keeping this boundary inside the service module prevents the Android service from depending on + * database entities or the controller's persistence details. + */ +interface ProtectionServiceRuntime { + val state: Flow + + /** + * Every app the user has armed, whether or not it is applied right now. + * + * [state] alone could never show these: it is built from sessions, and a session exists only + * while an app is in the foreground — so whenever the user is looking at Geto, it is empty. + */ + val armedApps: Flow> + + suspend fun reconcile() + + /** + * Repairs one key. Takes no session token because a content observer fires for a key, not for an + * app, and several apps may claim the same key. + */ + suspend fun reapply(settingType: SettingType, key: String): Boolean + + /** Returns true when protection was restored or was already inactive. */ + suspend fun restore(sessionToken: String): Boolean + + suspend fun pause(sessionToken: String, duration: ProtectionPauseDuration): Boolean + + suspend fun resume(sessionToken: String): Boolean + + /** Drops cached app labels; they can change when a package is replaced. */ + fun invalidateLabels() +} + +sealed interface ProtectionServiceState { + /** Nothing has been read yet. */ + data object Loading : ProtectionServiceState + + /** No app is protected. */ + data object Inactive : ProtectionServiceState + + data class Running( + val apps: List, + /** + * The union of every watched key across every app, deduplicated — a key five apps share is + * observed once and repaired once. + */ + val observedSettings: Set, + ) : ProtectionServiceState { + /** + * A foreground service is only warranted while something still needs it. + * + * An app paused with a deadline keeps it alive, because the service is what runs the timer + * that resumes on time. An app paused with no deadline has nothing to wait for, so holding a + * resident process and a permanent notification for it would buy nothing — the user resumes + * it by hand. + */ + val needsForeground: Boolean + get() = apps.any { + it.mode == ProtectionMode.FOREGROUND && + it.status == ProtectionSessionStatus.ACTIVE && + it.pause != ProtectionPauseState.PausedIndefinitely + } + + val recoveryApps: List + get() = apps.filter { it.status == ProtectionSessionStatus.RECOVERY_REQUIRED } + + val pausedApps: List + get() = apps.filter { it.pause != ProtectionPauseState.Running } + + val oneShotApps: List + get() = apps.filter { it.mode == ProtectionMode.ONE_SHOT } + } +} + +/** True when at least one app needs the foreground service kept alive. */ +fun ProtectionServiceState?.needsForeground(): Boolean = (this as? ProtectionServiceState.Running)?.needsForeground == true + +/** An armed app, plus its live status when it happens to be applied. */ +data class ArmedAppStatus( + val componentName: String, + val label: String, + val applied: ProtectedAppStatus?, +) + +data class ProtectedAppStatus( + val token: String, + val componentName: String, + val label: String, + val mode: ProtectionMode, + val status: ProtectionSessionStatus, + val pause: ProtectionPauseState, + val settingCount: Int, + val message: String? = null, +) + +/** + * A key the service watches. Carries no session token on purpose: that is what lets one observer and + * one pending repair cover every app claiming the key. + */ +data class ObservedProtectionSetting( + val settingType: SettingType, + val key: String, +) diff --git a/service/src/main/kotlin/com/android/geto/service/RetryPolicy.kt b/service/src/main/kotlin/com/android/geto/service/RetryPolicy.kt new file mode 100644 index 000000000..f7059eaf4 --- /dev/null +++ b/service/src/main/kotlin/com/android/geto/service/RetryPolicy.kt @@ -0,0 +1,63 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.service + +/** + * Bounded retry for the long-lived collectors and the service restart chain. + * + * These all used to retry on a flat 2-second timer with no cap. `SQLiteException` is a + * `RuntimeException`, so a corrupt database or a permanently blocked service start meant 30 wakeups a + * minute, with the screen off, for as long as the process lived — and the foreground service + * guarantees it lives. Growing the delay and eventually stopping bounds that cost, and giving up + * loudly is what keeps it safe: the caller tells the user rather than failing silently. + * + * Not thread-safe; each caller owns its own instance on a single coroutine. + */ +internal class RetryPolicy( + private val baseMillis: Long = BASE_MILLIS, + private val maxMillis: Long = MAX_MILLIS, + private val maxAttempts: Int = MAX_ATTEMPTS, +) { + private var attempts = 0 + + val hasGivenUp: Boolean get() = attempts >= maxAttempts + + /** Call after any clean run, so a later unrelated failure starts from the short delay again. */ + fun reset() { + attempts = 0 + } + + /** + * How long to wait before the next attempt, or null once this policy has given up and the caller + * should stop and report. + */ + fun nextDelayMillis(): Long? { + if (hasGivenUp) return null + + val exponent = attempts.coerceAtMost(MAX_EXPONENT) + attempts++ + return (baseMillis shl exponent).coerceAtMost(maxMillis) + } + + companion object { + const val BASE_MILLIS = 2_000L + const val MAX_MILLIS = 5 * 60 * 1_000L + const val MAX_ATTEMPTS = 10 + private const val MAX_EXPONENT = 8 + } +} diff --git a/service/src/main/kotlin/com/android/geto/service/SettingsObserverService.kt b/service/src/main/kotlin/com/android/geto/service/SettingsObserverService.kt deleted file mode 100644 index 8e7d96c9f..000000000 --- a/service/src/main/kotlin/com/android/geto/service/SettingsObserverService.kt +++ /dev/null @@ -1,187 +0,0 @@ -/* - * - * Copyright 2023 Einstein Blanco - * - * Licensed under the GNU General Public License v3.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.gnu.org/licenses/gpl-3.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.android.geto.service - -import android.app.Notification -import android.app.PendingIntent -import android.app.Service -import android.content.Intent -import android.content.pm.ServiceInfo -import android.database.ContentObserver -import android.net.Uri -import android.os.Build -import android.os.Handler -import android.os.IBinder -import android.os.Looper -import android.provider.Settings -import androidx.core.app.NotificationCompat -import androidx.core.app.ServiceCompat -import com.android.geto.framework.notificationmanager.AndroidNotificationManagerWrapper -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import javax.inject.Inject -import com.android.geto.common.R as commonR - -private const val NOTIFICATION_ID = 1 -private const val SERVICE_ACTION_STOP = "com.android.geto.action.STOP" - -@AndroidEntryPoint -class SettingsObserverService : Service() { - @Inject - lateinit var androidNotificationManagerWrapper: AndroidNotificationManagerWrapper - - private val observer = object : ContentObserver(Handler(Looper.getMainLooper())) { - override fun onChange(selfChange: Boolean, uri: Uri?) { - super.onChange(selfChange, uri) - - val category = when { - uri.toString().startsWith(Settings.System.CONTENT_URI.toString()) -> getString( - commonR.string.system, - ) - - uri.toString().startsWith(Settings.Secure.CONTENT_URI.toString()) -> getString( - commonR.string.secure, - ) - - uri.toString().startsWith(Settings.Global.CONTENT_URI.toString()) -> getString( - commonR.string.global, - ) - - else -> getString(R.string.unknown) - } - - androidNotificationManagerWrapper.notify( - id = NOTIFICATION_ID, - notification = getNotification( - title = category, - text = uri?.lastPathSegment ?: getString(R.string.unknown), - ), - ) - } - } - - private val stopIntent - get() = Intent( - this, - SettingsObserverService::class.java, - ).apply { - action = SERVICE_ACTION_STOP - } - - private val stopPendingIntent - get() = PendingIntent.getService( - this, - 0, - stopIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ) - - override fun onCreate() { - super.onCreate() - - _isRunning.update { - true - } - - ServiceCompat.startForeground( - this, - NOTIFICATION_ID, - getNotification( - title = getString(R.string.settings_observer), - text = getString(R.string.waiting_for_changes), - ), - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC - } else { - 0 - }, - ) - - contentResolver.registerContentObserver( - Settings.System.CONTENT_URI, - true, - observer, - ) - - contentResolver.registerContentObserver( - Settings.Secure.CONTENT_URI, - true, - observer, - ) - - contentResolver.registerContentObserver( - Settings.Global.CONTENT_URI, - true, - observer, - ) - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - when (intent?.action) { - SERVICE_ACTION_STOP -> { - ServiceCompat.stopForeground( - this, - ServiceCompat.STOP_FOREGROUND_REMOVE, - ) - - stopSelf() - - return START_NOT_STICKY - } - } - - return START_STICKY - } - - override fun onDestroy() { - super.onDestroy() - - _isRunning.update { - false - } - - contentResolver.unregisterContentObserver(observer) - } - - override fun onBind(intent: Intent?): IBinder? = null - - private fun getNotification( - title: String, - text: String, - ): Notification = NotificationCompat.Builder( - this, - AndroidNotificationManagerWrapper.NOTIFICATION_CHANNEL_ID, - ) - .setSmallIcon(android.R.drawable.ic_menu_info_details) - .setContentTitle(title) - .setContentText(text) - .setOngoing(true) - .addAction( - android.R.drawable.ic_menu_close_clear_cancel, - getString(R.string.stop), - stopPendingIntent, - ) - .build() - - companion object { - private val _isRunning = MutableStateFlow(false) - val isRunning = _isRunning.asStateFlow() - } -} diff --git a/service/src/main/res/values/strings.xml b/service/src/main/res/values/strings.xml index 3a6a45f08..40e0b7395 100644 --- a/service/src/main/res/values/strings.xml +++ b/service/src/main/res/values/strings.xml @@ -16,8 +16,38 @@ ~ --> - Settings Observer - Waiting for changes… - Unknown - Stop - \ No newline at end of file + Protection active + Starting protection… + Protecting %1$s + Stop & restore + Protection paused + %1$s — resumes at %2$s + %1$s — paused until you resume it + Pause 30m + Resume now + Manage + %1$s — needs attention + Protecting %1$d apps · %2$d paused + + Protecting %1$d app + Protecting %1$d apps + + Protection was interrupted + Protection has restarted. + Protection for %1$s has restarted. + Open Geto to check whether protection is still running. + Open Geto to check protection for %1$s. + Protection needs attention + Open Geto to review and recover protection. + Open Geto to review and recover protection for %1$s. + Android prevented background recovery. Open Geto to resume protection. + Android stopped the protection service. Open Geto to recover it. + %1$s still has one-time settings applied. Open Geto to restore them. + Protection could not restart automatically. Open Geto to review it. + Protection stopped after repeated failures. Open Geto to restart it. + Protection can\'t start while its notification is turned off. Enable it in Android settings. + Android would not let Geto watch %1$s, so changes to it will not be repaired. + Could not apply %1$s. Its settings were left unchanged. + Geto needs Write Secure Settings before it can apply this profile. + Could not apply this profile. Its settings were left unchanged. + diff --git a/service/src/test/kotlin/com/android/geto/service/ProtectionServiceStateTest.kt b/service/src/test/kotlin/com/android/geto/service/ProtectionServiceStateTest.kt new file mode 100644 index 000000000..0f012f088 --- /dev/null +++ b/service/src/test/kotlin/com/android/geto/service/ProtectionServiceStateTest.kt @@ -0,0 +1,94 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.service + +import com.android.geto.domain.model.ProtectionMode +import com.android.geto.domain.model.ProtectionPauseState +import com.android.geto.domain.model.ProtectionSessionStatus +import org.junit.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * `needsForeground` decides whether a foreground service — a resident process and a permanent + * notification — is justified. Getting it wrong costs battery for no benefit, so both directions of + * the pause rule are pinned here. + */ +class ProtectionServiceStateTest { + + @Test + fun needsForeground_isTrue_whenAnAppIsActivelyProtected() { + assertTrue(runningWith(app()).needsForeground) + } + + @Test + fun needsForeground_isTrue_whenPausedWithADeadline() { + // The service is what runs the timer that resumes on time, so it has to stay alive. + val paused = app(pause = ProtectionPauseState.PausedUntil(untilMillis = 1_000)) + + assertTrue(runningWith(paused).needsForeground) + } + + @Test + fun needsForeground_isFalse_whenEveryAppIsPausedIndefinitely() { + // Nothing to wait for, so holding the process and notification would buy nothing. + val paused = app(pause = ProtectionPauseState.PausedIndefinitely) + + assertFalse(runningWith(paused).needsForeground) + } + + @Test + fun needsForeground_isTrue_whenOneAppIsIndefinitelyPausedButAnotherIsNot() { + val state = runningWith( + app(token = "a", pause = ProtectionPauseState.PausedIndefinitely), + app(token = "b"), + ) + + assertTrue(state.needsForeground) + } + + @Test + fun needsForeground_isFalse_forOneShotAndRecoveryOnly() { + val state = runningWith( + app(token = "a", mode = ProtectionMode.ONE_SHOT), + app(token = "b", status = ProtectionSessionStatus.RECOVERY_REQUIRED), + ) + + assertFalse(state.needsForeground) + } + + private fun runningWith(vararg apps: ProtectedAppStatus) = ProtectionServiceState.Running( + apps = apps.toList(), + observedSettings = emptySet(), + ) + + private fun app( + token: String = "token", + mode: ProtectionMode = ProtectionMode.FOREGROUND, + status: ProtectionSessionStatus = ProtectionSessionStatus.ACTIVE, + pause: ProtectionPauseState = ProtectionPauseState.Running, + ) = ProtectedAppStatus( + token = token, + componentName = "com.example.$token/.MainActivity", + label = token, + mode = mode, + status = status, + pause = pause, + settingCount = 1, + ) +} diff --git a/service/src/test/kotlin/com/android/geto/service/RetryPolicyTest.kt b/service/src/test/kotlin/com/android/geto/service/RetryPolicyTest.kt new file mode 100644 index 000000000..8bd558ec1 --- /dev/null +++ b/service/src/test/kotlin/com/android/geto/service/RetryPolicyTest.kt @@ -0,0 +1,77 @@ +/* + * + * Copyright 2023 Einstein Blanco + * + * Licensed under the GNU General Public License v3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.geto.service + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The point of this policy is that a permanent failure stops costing battery. Both halves are pinned: + * the delay has to grow, and the retrying has to end. + */ +class RetryPolicyTest { + + @Test + fun delayGrowsExponentiallyAndIsCapped() { + val policy = RetryPolicy(baseMillis = 2_000L, maxMillis = 30_000L, maxAttempts = 10) + + assertEquals( + listOf(2_000L, 4_000L, 8_000L, 16_000L, 30_000L, 30_000L), + List(6) { policy.nextDelayMillis() }, + ) + } + + @Test + fun retryingEndsAfterTheAttemptCap() { + val policy = RetryPolicy(maxAttempts = 3) + + repeat(3) { attempt -> + assertFalse(policy.hasGivenUp, "should still be retrying at attempt $attempt") + assertTrue(policy.nextDelayMillis() != null) + } + + assertTrue(policy.hasGivenUp) + assertNull(policy.nextDelayMillis(), "a given-up policy must not ask for another delay") + } + + @Test + fun resetStartsOverSoALaterUnrelatedFailureIsNotPenalised() { + val policy = RetryPolicy(baseMillis = 2_000L, maxAttempts = 3) + policy.nextDelayMillis() + policy.nextDelayMillis() + + policy.reset() + + assertFalse(policy.hasGivenUp) + assertEquals(2_000L, policy.nextDelayMillis()) + } + + @Test + fun resetRevivesAGivenUpPolicy() { + val policy = RetryPolicy(maxAttempts = 1) + policy.nextDelayMillis() + assertTrue(policy.hasGivenUp) + + policy.reset() + + assertFalse(policy.hasGivenUp) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 7fdb60fd1..5ffcacd90 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -54,10 +54,12 @@ include(":feature:home") include(":feature:settings") include(":framework:asset-manager") include(":framework:drawable") +include(":framework:foreground-app") include(":framework:launcher-apps") include(":framework:notification-manager") include(":framework:package-manager") include(":framework:secure-settings") +include(":framework:shizuku") include(":framework:shortcut-manager") include(":service") include(":ui") \ No newline at end of file