Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions app/src/main/java/com/sameerasw/essentials/domain/diy/Action.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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))
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Action?>(null) }
var configIndex by remember { mutableStateOf<Int?>(null) }
var showAddActionSheet by remember { mutableStateOf(false) }
Expand Down Expand Up @@ -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 -> {}
}
}
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
}
}
Expand Down
113 changes: 113 additions & 0 deletions app/src/main/java/com/sameerasw/essentials/utils/DeviceCare.kt
Original file line number Diff line number Diff line change
@@ -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<String> {
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<String> {
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()
}
}
}
8 changes: 8 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1460,6 +1460,14 @@
<string name="diy_send_notification_title">Title</string>
<string name="diy_send_notification_body">Message</string>
<string name="diy_send_notification_label">Label</string>
<string name="diy_action_device_care">Device care</string>
<string name="diy_device_care_desc">Free up storage and memory without closing what you\'re using</string>
<string name="diy_device_care_clear_cache">Clear app caches</string>
<string name="diy_device_care_clear_cache_desc">Deletes temporary files that apps can rebuild</string>
<string name="diy_device_care_close_apps">Close background apps</string>
<string name="diy_device_care_close_apps_desc">Skips what you\'re using, music, your keyboard and launcher</string>
<string name="diy_device_care_done">Device care: %1$s freed</string>
<string name="diy_device_care_done_apps">Device care: background apps closed</string>
<string name="diy_action_low_power_off">Turn Off Low Power Mode</string>
<string name="diy_action_set_charging_mode">Set Charging Mode</string>
<string name="diy_action_notification_lighting">Trigger notification lighting</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
Loading