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 1e3a2dafd..34a0e8179 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 @@ -62,7 +62,7 @@ sealed interface Action { ) : Action { override val title: Int get() = R.string.diy_action_dim_wallpaper override val icon: Int get() = R.drawable.rounded_mobile_screensaver_24 - override val permissions: List = listOf("shizuku", "root") + override val permissions: List = listOf("SHIZUKU", "ROOT") override val isConfigurable: Boolean = true } @@ -76,7 +76,7 @@ sealed interface Action { ) : Action { override val title: Int get() = R.string.diy_action_device_effects override val icon: Int get() = R.drawable.rounded_bed_24 - override val permissions: List = listOf("notification_policy") + override val permissions: List = listOf("NOTIFICATION_POLICY") override val isConfigurable: Boolean = true } @@ -103,7 +103,7 @@ sealed interface Action { SoundModeType.VIBRATE -> R.drawable.rounded_mobile_vibrate_24 SoundModeType.SILENT -> R.drawable.rounded_volume_off_24 } - override val permissions: List = listOf("notification_policy") + override val permissions: List = listOf("NOTIFICATION_POLICY") override val isConfigurable: Boolean = true } @@ -187,4 +187,67 @@ sealed interface Action { override val icon: Int = R.drawable.rounded_shield_lock_24 override val permissions: List = listOf("shizuku", "root") } + + @Keep + data object TurnOnWifi : Action { + override val title: Int = R.string.diy_action_wifi_on + override val icon: Int = R.drawable.rounded_android_wifi_4_bar_plus_24 + override val permissions: List = listOf("SHIZUKU", "ROOT") + } + + @Keep + data object TurnOffWifi : Action { + override val title: Int = R.string.diy_action_wifi_off + override val icon: Int = R.drawable.rounded_android_wifi_4_bar_plus_24 + override val permissions: List = listOf("SHIZUKU", "ROOT") + } + + @Keep + data object TurnOnCellularData : Action { + override val title: Int = R.string.diy_action_cellular_on + override val icon: Int = R.drawable.rounded_signal_cellular_alt_24 + override val permissions: List = listOf("SHIZUKU", "ROOT") + } + + @Keep + data object TurnOffCellularData : Action { + override val title: Int = R.string.diy_action_cellular_off + override val icon: Int = R.drawable.rounded_signal_cellular_alt_24 + override val permissions: List = listOf("SHIZUKU", "ROOT") + } + + @Keep + data object TurnOnAutoBrightness : Action { + override val title: Int = R.string.diy_action_auto_brightness_on + override val icon: Int = R.drawable.rounded_brightness_auto_24 + override val permissions: List = listOf("WRITE_SETTINGS") + } + + @Keep + data object TurnOffAutoBrightness : Action { + override val title: Int = R.string.diy_action_auto_brightness_off + override val icon: Int = R.drawable.rounded_brightness_auto_24 + override val permissions: List = listOf("WRITE_SETTINGS") + } + + @Keep + data class FreezeApps( + @SerializedName("packageNames") val packageNames: List = emptyList() + ) : Action { + override val title: Int get() = R.string.diy_action_freeze_apps + override val icon: Int get() = R.drawable.rounded_mode_cool_24 + override val permissions: List = listOf("SHIZUKU", "ROOT") + override val isConfigurable: Boolean = true + } + + @Keep + data class UnfreezeApps( + @SerializedName("packageNames") val packageNames: List = emptyList() + ) : Action { + override val title: Int get() = R.string.diy_action_unfreeze_apps + override val icon: Int get() = R.drawable.rounded_mode_cool_off_24 + override val permissions: List = listOf("SHIZUKU", "ROOT") + override val isConfigurable: Boolean = true + } } + 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 4f0a4a93e..85d9c939d 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 @@ -398,7 +398,25 @@ object CombinedActionExecutor { Toast.makeText(context, "Failed to pin app: ${e.message}", Toast.LENGTH_SHORT).show() } } + + is Action.TurnOnWifi -> setWifiEnabled(context, true) + is Action.TurnOffWifi -> setWifiEnabled(context, false) + is Action.TurnOnCellularData -> setCellularDataEnabled(context, true) + is Action.TurnOffCellularData -> setCellularDataEnabled(context, false) + is Action.TurnOnAutoBrightness -> setAutoBrightnessEnabled(context, true) + is Action.TurnOffAutoBrightness -> setAutoBrightnessEnabled(context, false) + is Action.FreezeApps -> { + action.packageNames.forEach { pkg -> + com.sameerasw.essentials.utils.FreezeManager.freezeApp(context, pkg) + } + } + is Action.UnfreezeApps -> { + action.packageNames.forEach { pkg -> + com.sameerasw.essentials.utils.FreezeManager.unfreezeApp(context, pkg) + } + } } + } } @@ -420,4 +438,30 @@ object CombinedActionExecutor { e.printStackTrace() } } + + private fun setWifiEnabled(context: Context, enabled: Boolean) { + val state = if (enabled) "enable" else "disable" + com.sameerasw.essentials.utils.ShellUtils.runCommand(context, "svc wifi $state") + } + + private fun setCellularDataEnabled(context: Context, enabled: Boolean) { + val state = if (enabled) "enable" else "disable" + com.sameerasw.essentials.utils.ShellUtils.runCommand(context, "svc data $state") + } + + private fun setAutoBrightnessEnabled(context: Context, enabled: Boolean) { + val value = if (enabled) 1 else 0 + try { + android.provider.Settings.System.putInt( + context.contentResolver, + android.provider.Settings.System.SCREEN_BRIGHTNESS_MODE, + value + ) + } catch (e: Exception) { + com.sameerasw.essentials.utils.ShellUtils.runCommand( + context, + "settings put system screen_brightness_mode $value" + ) + } + } } diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/AppFlowHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/AppFlowHandler.kt index b0bce19b0..b9c1c193c 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/handlers/AppFlowHandler.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/AppFlowHandler.kt @@ -4,6 +4,7 @@ import android.accessibilityservice.AccessibilityService import android.app.NotificationManager import android.app.PendingIntent import android.content.BroadcastReceiver +import android.media.AudioManager import android.content.ComponentName import android.content.Context import android.content.Intent @@ -14,6 +15,7 @@ import android.os.Looper import android.provider.Settings import android.util.Log import androidx.core.app.NotificationCompat +import android.view.inputmethod.InputMethodManager import com.google.gson.Gson import com.sameerasw.essentials.domain.diy.Automation import com.sameerasw.essentials.domain.diy.DIYRepository @@ -103,13 +105,62 @@ class AppFlowHandler( private val ignoredSystemPackages = listOf( "android", "com.android.systemui", - "com.google.android.inputmethod.latin" + "com.google.android.inputmethod.latin", + "com.google.android.gms" ) + private fun isIgnoredPackage(packageName: String): Boolean { + if (ignoredSystemPackages.contains(packageName)) return true + + val lowerPkg = packageName.lowercase() + if (lowerPkg.contains("systemui") || + lowerPkg.contains("keyguard") || + lowerPkg.contains("volume") || + lowerPkg.contains("soundassistant") || + lowerPkg.contains("dialer") || + lowerPkg.contains("telecom") || + lowerPkg.contains("phone") || + lowerPkg.contains("incallui") || + lowerPkg.contains("packageinstaller") || + lowerPkg.contains("permissioncontroller") + ) { + return true + } + + // Check active call state via AudioManager mode + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager + if (audioManager != null) { + val mode = audioManager.mode + if (mode == AudioManager.MODE_IN_CALL || + mode == AudioManager.MODE_IN_COMMUNICATION || + mode == AudioManager.MODE_RINGTONE + ) { + return true + } + } + + return try { + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + val ims = imm?.enabledInputMethodList + ims?.any { it.packageName == packageName } == true + } catch (_: Exception) { + false + } + } + fun onPackageChanged(packageName: String, isFromUsageStats: Boolean = false) { val prefs = context.getSharedPreferences("essentials_prefs", Context.MODE_PRIVATE) val useUsageAccess = prefs.getBoolean("use_usage_access", false) + Log.d("AppFlowHandler", "onPackageChanged: packageName=$packageName, isFromUsageStats=$isFromUsageStats, useUsageAccess=$useUsageAccess, currentPackage=$currentPackage") + + // If the new foreground window belongs to a system overlay (status bar, quick settings, + // notifications), a keyboard (IME), a volume dialog, or a phone call, completely ignore it. + // We do NOT update currentPackage so that state-dependent features remain stable. + if (isIgnoredPackage(packageName)) { + Log.d("AppFlowHandler", "onPackageChanged: Ignoring system/IME/volume/call package $packageName") + return + } val oldPackage = currentPackage if (isFromUsageStats == useUsageAccess) { currentPackage = packageName @@ -213,7 +264,7 @@ class AppFlowHandler( pendingNLRunnable?.let { handler.removeCallbacks(it) } - if (ignoredSystemPackages.contains(packageName)) { + if (isIgnoredPackage(packageName)) { Log.d("NightLight", "Ignoring system package $packageName") return } @@ -285,6 +336,10 @@ class AppFlowHandler( } private fun checkAppAutomations(packageName: String) { + if (isIgnoredPackage(packageName)) { + Log.d("AppFlowHandler", "checkAppAutomations: Ignoring system/IME package $packageName") + return + } scope.launch { val automations = DIYRepository.automations.value val appAutomations = diff --git a/app/src/main/java/com/sameerasw/essentials/ui/activities/AutomationEditorActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/AutomationEditorActivity.kt index fe30975c6..bcbcb549e 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/activities/AutomationEditorActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/AutomationEditorActivity.kt @@ -37,7 +37,10 @@ import androidx.compose.material3.Text import androidx.compose.material3.carousel.HorizontalMultiBrowseCarousel import androidx.compose.material3.carousel.rememberCarouselState import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.Lifecycle import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -70,9 +73,11 @@ import com.sameerasw.essentials.ui.components.containers.RoundedCardContainer import com.sameerasw.essentials.ui.components.menus.SegmentedDropdownMenu import com.sameerasw.essentials.ui.components.menus.SegmentedDropdownMenuItem import com.sameerasw.essentials.ui.components.pickers.SegmentedPicker +import com.sameerasw.essentials.ui.components.sheets.AppSelectionSheet import com.sameerasw.essentials.ui.components.sheets.DimWallpaperSettingsSheet import com.sameerasw.essentials.ui.components.sheets.ScreenOffSettingsSheet import com.sameerasw.essentials.ui.components.sheets.SoundModeSettingsSheet + import com.sameerasw.essentials.ui.theme.EssentialsTheme import com.sameerasw.essentials.utils.AppUtil import com.sameerasw.essentials.utils.HapticUtil @@ -227,9 +232,31 @@ class AutomationEditorActivity : ComponentActivity() { var showScreenOffSettings by remember { mutableStateOf(false) } var showDeviceEffectsSettings by remember { mutableStateOf(false) } var showSoundModeSettings by remember { mutableStateOf(false) } + var showFreezeAppsSettings by remember { mutableStateOf(false) } + var temporarySelectedAppsForAction by remember { mutableStateOf>(emptyList()) } var showTimeSettings by remember { mutableStateOf(false) } var configAction by remember { mutableStateOf(null) } // Generic config action + + var showPermissionSheet by remember { mutableStateOf(false) } + var permissionKeysToShow by remember { mutableStateOf>(emptyList()) } + var permissionFeatureTitle by remember { mutableStateOf("") } + + // Automatic refresh on resume + val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + viewModel.check(context) + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + // Validation val isValid = when (automationType) { Automation.Type.TRIGGER -> selectedTrigger != null && selectedAction != null Automation.Type.ACTION_SHORTCUT, Automation.Type.PIXEL_SEARCHBAR -> selectedAction != null @@ -608,7 +635,15 @@ class AutomationEditorActivity : ComponentActivity() { Action.ToggleMediaVolume, Action.LikeCurrentSong, Action.CircleToSearch, - Action.PinApp + Action.PinApp, + Action.TurnOnWifi, + Action.TurnOffWifi, + Action.TurnOnCellularData, + Action.TurnOffCellularData, + Action.TurnOnAutoBrightness, + Action.TurnOffAutoBrightness, + Action.FreezeApps(), + Action.UnfreezeApps() ) // Only show Device Effects on Android 15+ actions.add(Action.DeviceEffects()) @@ -667,30 +702,30 @@ class AutomationEditorActivity : ComponentActivity() { } } // Check permissions immediately on selection - // For Device Effects, we need Notification Policy Access - if (resolvedAction is Action.DeviceEffects) { - val nm = - context.getSystemService( - NOTIFICATION_SERVICE - ) as android.app.NotificationManager - if (!nm.isNotificationPolicyAccessGranted) { - val intent = - Intent(android.provider.Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS) - context.startActivity(intent) - } + val missing = getMissingPermissions(context, resolvedAction, viewModel) + if (missing.isNotEmpty()) { + permissionKeysToShow = missing + permissionFeatureTitle = resolvedAction.title + showPermissionSheet = true } }, onSettingsClick = { - configAction = resolvedAction - if (resolvedAction is Action.DimWallpaper) { - showDimSettings = true - } else if (resolvedAction is Action.ScreenOff) { - showScreenOffSettings = true - } else if (resolvedAction is Action.DeviceEffects) { - showDeviceEffectsSettings = true - } else if (resolvedAction is Action.SoundMode) { - showSoundModeSettings = true - } + configAction = resolvedAction + if (resolvedAction is Action.DimWallpaper) { + showDimSettings = true + } else if (resolvedAction is Action.ScreenOff) { + showScreenOffSettings = true + } else if (resolvedAction is Action.DeviceEffects) { + showDeviceEffectsSettings = true + } else if (resolvedAction is Action.SoundMode) { + showSoundModeSettings = true + } else if (resolvedAction is Action.FreezeApps) { + temporarySelectedAppsForAction = resolvedAction.packageNames + showFreezeAppsSettings = true + } else if (resolvedAction is Action.UnfreezeApps) { + temporarySelectedAppsForAction = resolvedAction.packageNames + showFreezeAppsSettings = true + } } ) } @@ -793,6 +828,58 @@ class AutomationEditorActivity : ComponentActivity() { ) } + if (showFreezeAppsSettings && (configAction is Action.FreezeApps || configAction is Action.UnfreezeApps)) { + AppSelectionSheet( + onDismissRequest = { + val finalAction = when (val action = configAction) { + is Action.FreezeApps -> action.copy(packageNames = temporarySelectedAppsForAction) + is Action.UnfreezeApps -> action.copy(packageNames = temporarySelectedAppsForAction) + else -> configAction + } + if (finalAction != null) { + when (automationType) { + Automation.Type.TRIGGER -> selectedAction = finalAction + Automation.Type.ACTION_SHORTCUT, Automation.Type.PIXEL_SEARCHBAR -> selectedAction = finalAction + Automation.Type.STATE, Automation.Type.APP -> { + if (selectedActionTab == 0) selectedInAction = finalAction + else selectedOutAction = finalAction + } + } + } + showFreezeAppsSettings = false + configAction = null + }, + onLoadApps = { + temporarySelectedAppsForAction.map { AppSelection(it, true) } + }, + onSaveApps = { _, selections -> + temporarySelectedAppsForAction = selections.filter { it.isEnabled }.map { it.packageName } + }, + excludePackages = if (automationType == Automation.Type.APP) selectedApps else emptyList() + ) + } + + + + if (showPermissionSheet) { + val permissionItems = com.sameerasw.essentials.utils.PermissionUIHelper.getPermissionItems( + permissionKeysToShow, + context, + viewModel, + this@AutomationEditorActivity + ) + if (permissionItems.isNotEmpty()) { + com.sameerasw.essentials.ui.components.sheets.PermissionsBottomSheet( + onDismissRequest = { + showPermissionSheet = false + permissionKeysToShow = emptyList() + }, + featureTitle = permissionFeatureTitle, + permissions = permissionItems + ) + } + } + // Bottom Actions Row( modifier = Modifier @@ -823,7 +910,18 @@ class AutomationEditorActivity : ComponentActivity() { Button( onClick = { HapticUtil.performVirtualKeyHaptic(view) - // Save logic + // Check for missing permissions before saving + val actionsToCheck = when (automationType) { + Automation.Type.TRIGGER, Automation.Type.ACTION_SHORTCUT, Automation.Type.PIXEL_SEARCHBAR -> listOfNotNull(selectedAction) + else -> listOfNotNull(selectedInAction, selectedOutAction) + } + val allMissingPermissions = actionsToCheck.flatMap { getMissingPermissions(context, it, viewModel) }.distinct() + if (allMissingPermissions.isNotEmpty()) { + permissionKeysToShow = allMissingPermissions + permissionFeatureTitle = R.string.tab_diy + showPermissionSheet = true + return@Button + } if (automationType == Automation.Type.TRIGGER) { val newAutomation = Automation( id = if (isEditMode) existingAutomation.id else java.util.UUID.randomUUID() @@ -873,22 +971,48 @@ class AutomationEditorActivity : ComponentActivity() { finish() }, modifier = Modifier.weight(1f), - enabled = isValid - ) { - 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)) - } + enabled = isValid + ) { + 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)) + } } } } } } } + + private fun getMissingPermissions( + context: Context, + action: Action?, + viewModel: com.sameerasw.essentials.viewmodels.MainViewModel + ): List { + if (action == null) return emptyList() + val resolvedPermissions = action.permissions.map { permKey -> + if (permKey == "SHIZUKU" || permKey == "ROOT") { + if (com.sameerasw.essentials.utils.ShellUtils.isRootEnabled(context)) "ROOT" else "SHIZUKU" + } else { + permKey + } + }.distinct() + + return resolvedPermissions.filter { permKey -> + when (permKey) { + "SHIZUKU" -> !viewModel.isShizukuPermissionGranted.value + "ROOT" -> !viewModel.isRootPermissionGranted.value + "WRITE_SETTINGS" -> !viewModel.isWriteSettingsEnabled.value + "NOTIFICATION_POLICY" -> !viewModel.isNotificationPolicyAccessGranted.value + "WRITE_SECURE_SETTINGS" -> !viewModel.isWriteSecureSettingsEnabled.value + else -> false + } + } + } } @Composable diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/sheets/AppSelectionSheets.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/sheets/AppSelectionSheets.kt index a1e14b2c0..8ee95ce59 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/components/sheets/AppSelectionSheets.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/components/sheets/AppSelectionSheets.kt @@ -60,6 +60,7 @@ fun AppSelectionSheet( onLoadApps: suspend (Context) -> List, onSaveApps: suspend (Context, List) -> Unit, onAppToggle: ((Context, String, Boolean) -> Unit)? = null, + excludePackages: List = emptyList(), context: Context = LocalContext.current ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -112,7 +113,8 @@ fun AppSelectionSheet( searchQuery.isEmpty() || it.appName.contains(searchQuery, ignoreCase = true) val isVisible = !it.isSystemApp || showSystemApps || it.isEnabled // Always show if enabled, or if system toggle checks out - matchesSearch && isVisible + val isExcluded = excludePackages.contains(it.packageName) + matchesSearch && isVisible && !isExcluded } .sortedWith(compareByDescending { initialEnabledPackageNames.contains(it.packageName) }.thenBy { it.appName.lowercase() }) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8caf42e3c..b106fea12 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -856,6 +856,12 @@ Toggle Flashlight Turn On Low Power Mode Turn Off Low Power Mode + Turn On WiFi + Turn Off WiFi + Turn On Cellular Data + Turn Off Cellular Data + Turn On Auto Brightness + Turn Off Auto Brightness Dim Wallpaper Screen Off Media Play/Pause @@ -868,6 +874,11 @@ Circle to Search Pin Foreground App This action requires Shizuku or Root to adjust system wallpaper dimming. + Freeze Apps + Unfreeze Apps + This action requires Shizuku or Root to freeze specific applications. + This action requires Shizuku or Root to unfreeze specific applications. + Select Trigger App Automate based on open app