diff --git a/app/src/main/java/com/sameerasw/essentials/domain/diy/Action.kt b/app/src/main/java/com/sameerasw/essentials/domain/diy/Action.kt index fc5d67bcd..a9cb6e27b 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/diy/Action.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/diy/Action.kt @@ -168,6 +168,17 @@ sealed interface Action { override val isConfigurable: Boolean get() = true } + @Keep + data class DeviceCare( + @SerializedName("clearCache") val clearCache: Boolean = true, + @SerializedName("closeApps") val closeApps: Boolean = true, + ) : Action { + override val title: Int get() = R.string.diy_action_device_care + override val icon: Int get() = R.drawable.rounded_mop_24 + override val permissions: List = listOf("SHIZUKU", "ROOT") + override val isConfigurable: Boolean get() = true + } + @Keep data object TurnOffLowPower : Action { override val title: Int = R.string.diy_action_low_power_off diff --git a/app/src/main/java/com/sameerasw/essentials/domain/diy/ActionRegistry.kt b/app/src/main/java/com/sameerasw/essentials/domain/diy/ActionRegistry.kt index 46a57cb85..6870acecb 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/diy/ActionRegistry.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/diy/ActionRegistry.kt @@ -83,6 +83,7 @@ object ActionRegistry { add(Action.CustomSettings()) add(Action.Wait()) add(Action.SendNotification()) + add(Action.DeviceCare()) add(Action.CircleToSearch()) add(Action.TurnOffBackGesture) add(Action.TurnOnBackGesture) diff --git a/app/src/main/java/com/sameerasw/essentials/services/automation/executors/CombinedActionExecutor.kt b/app/src/main/java/com/sameerasw/essentials/services/automation/executors/CombinedActionExecutor.kt index 45dea2875..741162260 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/automation/executors/CombinedActionExecutor.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/automation/executors/CombinedActionExecutor.kt @@ -50,6 +50,7 @@ import com.sameerasw.essentials.services.tiles.ScreenOffAccessibilityService import com.sameerasw.essentials.ui.activities.PixelSearchResultsActivity import com.sameerasw.essentials.utils.AutomationFailureNotifier import com.sameerasw.essentials.utils.AutomationNotificationPoster +import com.sameerasw.essentials.utils.DeviceCare import com.sameerasw.essentials.utils.DeviceLockUtils import com.sameerasw.essentials.utils.HapticUtil import com.sameerasw.essentials.utils.PermissionUtils @@ -173,6 +174,7 @@ object CombinedActionExecutor { is Action.TurnOffCaffeinate -> CaffeinateController.cancelAll(context) is Action.Wait -> delay(action.seconds * 1000L) is Action.SendNotification -> AutomationNotificationPoster.post(context, action) + is Action.DeviceCare -> withContext(Dispatchers.IO) { DeviceCare.run(context, action) } is Action.TurnOffLowPower -> setLowPowerMode(context, false) is Action.SetChargingMode -> com.sameerasw.essentials.utils.battery.ChargingModeUtil diff --git a/app/src/main/java/com/sameerasw/essentials/ui/core/sheets/DeviceCareSettingsSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/core/sheets/DeviceCareSettingsSheet.kt new file mode 100644 index 000000000..81a87941d --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/core/sheets/DeviceCareSettingsSheet.kt @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: UI Feature - Automation + * File: DeviceCareSettingsSheet.kt + * Description: UI bottom sheet for configuring the Device Care automation action. + */ + +package com.sameerasw.essentials.ui.core.sheets + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.sameerasw.essentials.R +import com.sameerasw.essentials.domain.diy.Action +import com.sameerasw.essentials.ui.core.cards.IconToggleItem +import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer +import com.sameerasw.essentials.utils.HapticUtil + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DeviceCareSettingsSheet( + initialAction: Action.DeviceCare, + onDismiss: () -> Unit, + onSave: (Action.DeviceCare) -> Unit, +) { + val view = LocalView.current + var clearCache by remember { mutableStateOf(initialAction.clearCache) } + var closeApps by remember { mutableStateOf(initialAction.closeApps) } + + EssentialsBottomSheet(onDismissRequest = onDismiss) { + Column( + modifier = + Modifier + .padding(16.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(R.string.diy_action_device_care), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + + Text( + text = stringResource(R.string.diy_device_care_desc), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + RoundedCardContainer(spacing = 2.dp, cornerRadius = 24.dp) { + IconToggleItem( + iconRes = R.drawable.rounded_cleaning_services_24, + title = stringResource(R.string.diy_device_care_clear_cache), + description = stringResource(R.string.diy_device_care_clear_cache_desc), + isChecked = clearCache, + onCheckedChange = { clearCache = it }, + ) + IconToggleItem( + iconRes = R.drawable.rounded_memory_24, + title = stringResource(R.string.diy_device_care_close_apps), + description = stringResource(R.string.diy_device_care_close_apps_desc), + isChecked = closeApps, + onCheckedChange = { closeApps = it }, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + onDismiss() + }, + modifier = Modifier.weight(1f), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surfaceBright, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Icon( + painter = painterResource(id = R.drawable.rounded_close_24), + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.size(8.dp)) + Text(stringResource(R.string.action_cancel)) + } + + Button( + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + onSave( + Action.DeviceCare( + clearCache = clearCache, + closeApps = closeApps, + ), + ) + }, + modifier = Modifier.weight(1f), + enabled = clearCache || closeApps, + ) { + Icon( + painter = painterResource(id = R.drawable.rounded_check_24), + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.size(8.dp)) + Text(stringResource(R.string.action_save)) + } + } + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/hardware/ActionSequenceEditor.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/hardware/ActionSequenceEditor.kt index 01a4a9164..2ce8137ef 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/hardware/ActionSequenceEditor.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/hardware/ActionSequenceEditor.kt @@ -61,6 +61,7 @@ import com.sameerasw.essentials.ui.core.sheets.OpenActivityPicker import com.sameerasw.essentials.ui.core.sheets.SingleAppSelectionSheet import com.sameerasw.essentials.ui.core.sheets.WaitSettingsSheet import com.sameerasw.essentials.ui.core.sheets.SendNotificationSettingsSheet +import com.sameerasw.essentials.ui.core.sheets.DeviceCareSettingsSheet import com.sameerasw.essentials.ui.core.sheets.formatWait import com.sameerasw.essentials.ui.core.sheets.ChargingModeSettingsSheet import com.sameerasw.essentials.ui.core.sheets.NotificationLightingActionSheet @@ -114,6 +115,7 @@ fun ActionSequenceEditor( var showHilightSettings by remember { mutableStateOf(false) } var showWaitSettings by remember { mutableStateOf(false) } var showSendNotificationSettings by remember { mutableStateOf(false) } + var showDeviceCareSettings by remember { mutableStateOf(false) } var configAction by remember { mutableStateOf(null) } var configIndex by remember { mutableStateOf(null) } var showAddActionSheet by remember { mutableStateOf(false) } @@ -228,6 +230,7 @@ fun ActionSequenceEditor( is Action.CustomSettings -> showCustomSettingsSettings = true is Action.Wait -> showWaitSettings = true is Action.SendNotification -> showSendNotificationSettings = true + is Action.DeviceCare -> showDeviceCareSettings = true else -> {} } } @@ -651,6 +654,18 @@ fun ActionSequenceEditor( ) } + if (showDeviceCareSettings && configAction is Action.DeviceCare) { + DeviceCareSettingsSheet( + initialAction = configAction as Action.DeviceCare, + onDismiss = { showDeviceCareSettings = false }, + onSave = { newAction -> + showDeviceCareSettings = false + onActionSelected(newAction) + configAction = null + }, + ) + } + if (showPermissionSheet) { val permissionItems = com.sameerasw.essentials.utils.PermissionUIHelper.getPermissionItems( @@ -688,6 +703,15 @@ private fun RemapSequenceItem( when { action is Action.Wait -> "$baseTitle · ${formatWait(action.seconds)}" action is Action.SendNotification && action.notificationTitle.isNotBlank() -> "$baseTitle · ${action.notificationTitle}" + action is Action.DeviceCare && action.clearCache != action.closeApps -> { + val optionTitle = + if (action.clearCache) { + stringResource(R.string.diy_device_care_clear_cache) + } else { + stringResource(R.string.diy_device_care_close_apps) + } + "$baseTitle · $optionTitle" + } else -> baseTitle } } diff --git a/app/src/main/java/com/sameerasw/essentials/utils/DeviceCare.kt b/app/src/main/java/com/sameerasw/essentials/utils/DeviceCare.kt new file mode 100644 index 000000000..b254cc009 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/utils/DeviceCare.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Utilities - General + * File: DeviceCare.kt + * Description: Clears app caches and closes idle background apps. + */ + +package com.sameerasw.essentials.utils + +import android.content.Context +import android.content.Intent +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.os.Environment +import android.os.Handler +import android.os.Looper +import android.os.StatFs +import android.provider.Settings +import android.text.format.Formatter +import android.widget.Toast +import com.sameerasw.essentials.R +import com.sameerasw.essentials.domain.diy.Action + +object DeviceCare { + fun run( + context: Context, + action: Action.DeviceCare, + ) { + if (!ShellUtils.hasPermission(context)) return + + val before = StatFs(Environment.getDataDirectory().path).availableBytes + + if (action.clearCache) { + clearCache(context) + } + if (action.closeApps) { + closeApps(context) + } + + val after = StatFs(Environment.getDataDirectory().path).availableBytes + val freed = (after - before).coerceAtLeast(0) + + val message = + if (action.clearCache) { + context.getString(R.string.diy_device_care_done, Formatter.formatShortFileSize(context, freed)) + } else { + context.getString(R.string.diy_device_care_done_apps) + } + showToast(context, message) + } + + private fun clearCache(context: Context) { + ShellUtils.runCommand( + context, + "pm trim-caches 999G", + featureName = context.getString(R.string.diy_action_device_care), + ) + } + + private fun closeApps(context: Context) { + val packages = getKillablePackages(context) + if (packages.isEmpty()) return + + val command = packages.joinToString("; ") { "am kill $it" } + ShellUtils.runCommand( + context, + command, + featureName = context.getString(R.string.diy_action_device_care), + ) + } + + private fun getKillablePackages(context: Context): List { + val excluded = getExcludedPackages(context) + return context.packageManager + .getInstalledApplications(0) + .asSequence() + .filter { (it.flags and ApplicationInfo.FLAG_SYSTEM) == 0 } + .map { it.packageName } + .filter { it !in excluded && !FreezeManager.isAppActive(context, it) } + .toList() + } + + private fun getExcludedPackages(context: Context): Set { + val excluded = mutableSetOf(context.packageName, "moe.shizuku.privileged.api") + + context.packageManager + .resolveActivity( + Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME), + PackageManager.MATCH_DEFAULT_ONLY, + )?.activityInfo + ?.packageName + ?.let { excluded.add(it) } + + Settings.Secure + .getString(context.contentResolver, Settings.Secure.DEFAULT_INPUT_METHOD) + ?.substringBefore('/') + ?.takeIf { it.isNotBlank() } + ?.let { excluded.add(it) } + + return excluded + } + + private fun showToast( + context: Context, + message: String, + ) { + Handler(Looper.getMainLooper()).post { + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e08d93c08..8d6b6fded 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1460,6 +1460,14 @@ Title Message Label + Device care + Free up storage and memory without closing what you\'re using + Clear app caches + Deletes temporary files that apps can rebuild + Close background apps + Skips what you\'re using, music, your keyboard and launcher + Device care: %1$s freed + Device care: background apps closed Turn Off Low Power Mode Set Charging Mode Trigger notification lighting diff --git a/app/src/test/java/com/sameerasw/essentials/domain/ActionListJsonTest.kt b/app/src/test/java/com/sameerasw/essentials/domain/ActionListJsonTest.kt index 84e6a5722..f88369395 100644 --- a/app/src/test/java/com/sameerasw/essentials/domain/ActionListJsonTest.kt +++ b/app/src/test/java/com/sameerasw/essentials/domain/ActionListJsonTest.kt @@ -38,4 +38,20 @@ class ActionListJsonTest { val expected = listOf(Action.SendNotification(notificationTitle = "Hi", body = "", label = "")) assertEquals(expected, ActionGsonAdapter.listFromJson(json)) } + + @Test + fun deviceCareRoundTrip() { + val actions = listOf(Action.DeviceCare(clearCache = false, closeApps = true)) + assertEquals(actions, ActionGsonAdapter.listFromJson(ActionGsonAdapter.listToJson(actions))) + } + + @Test + fun deviceCareDefaultsLoadWithBothOptionsTrue() { + val singleAction = ActionGsonAdapter.fromJson("""{"type":"DeviceCare"}""") + assertEquals(Action.DeviceCare(clearCache = true, closeApps = true), singleAction) + + val json = """[{"type":"DeviceCare"}]""" + val expected = listOf(Action.DeviceCare(clearCache = true, closeApps = true)) + assertEquals(expected, ActionGsonAdapter.listFromJson(json)) + } }