From 62b3abedbe7a074287d2f288b4956b6817927a0c Mon Sep 17 00:00:00 2001 From: Mudit200408 Date: Sat, 30 May 2026 19:22:16 +0530 Subject: [PATCH 1/4] feat: Add per-app refresh rate feature --- README.md | 1 - .../data/repository/SettingsRepository.kt | 36 ++++ .../domain/model/AppRefreshRateConfig.kt | 8 + .../services/AppDetectionService.kt | 21 ++ .../services/handlers/AppFlowHandler.kt | 103 +++++++++ .../sheets/PerAppRefreshRateSettingsSheet.kt | 192 +++++++++++++++++ .../configs/RefreshRateSettingsUI.kt | 195 ++++++++++++++++++ .../essentials/utils/RefreshRateUtils.kt | 38 ++++ .../essentials/utils/ServiceUtils.kt | 4 +- .../essentials/viewmodels/MainViewModel.kt | 38 ++++ app/src/main/res/values/strings.xml | 13 ++ 11 files changed, 647 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/sameerasw/essentials/domain/model/AppRefreshRateConfig.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/ui/components/sheets/PerAppRefreshRateSettingsSheet.kt diff --git a/README.md b/README.md index 0e4ec9bb5..73eed1725 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,6 @@ Essential tools, mods and workarounds for Pixels and other Androids GitHub Issues or Pull Requests by label GitHub Issues or Pull Requests by label - My website Community diff --git a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt index e571eac93..46165e3c1 100644 --- a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt @@ -11,6 +11,7 @@ import com.sameerasw.essentials.domain.model.NotificationLightingSide import com.sameerasw.essentials.domain.model.NotificationLightingStyle import com.sameerasw.essentials.domain.model.NotificationLightingSweepPosition import com.sameerasw.essentials.domain.model.ScaleAnimationsProfile +import com.sameerasw.essentials.domain.model.AppRefreshRateConfig import com.sameerasw.essentials.domain.model.TrackedRepo import com.sameerasw.essentials.domain.model.github.GitHubUser import com.sameerasw.essentials.utils.RootUtils @@ -240,6 +241,9 @@ class SettingsRepository(private val context: Context) { const val KEY_EDGE_LIGHTING_SWEEP_SELECTED_SHAPES = "edge_lighting_sweep_selected_shapes" const val KEY_DISABLE_ROTATION_SUGGESTION = "disable_rotation_suggestion" + const val KEY_PER_APP_REFRESH_RATE_ENABLED = "per_app_refresh_rate_enabled" + const val KEY_PER_APP_REFRESH_RATE_CONFIGS = "per_app_refresh_rate_configs" + const val KEY_LOCK_SCREEN_CLOCK_WEIGHT = "lock_screen_clock_weight" const val KEY_LOCK_SCREEN_CLOCK_WIDTH = "lock_screen_clock_width" const val KEY_LOCK_SCREEN_CLOCK_GRADE = "lock_screen_clock_grade" @@ -554,6 +558,38 @@ class SettingsRepository(private val context: Context) { } } + fun loadPerAppRefreshRateConfigs(): List { + val json = prefs.getString(KEY_PER_APP_REFRESH_RATE_CONFIGS, null) + return if (json != null) { + try { + gson.fromJson( + json, + Array::class.java + ).toList() + } catch (e: Exception) { + emptyList() + } + } else { + emptyList() + } + } + + fun savePerAppRefreshRateConfigs(configs: List) { + val json = gson.toJson(configs) + putString(KEY_PER_APP_REFRESH_RATE_CONFIGS, json) + } + + fun updatePerAppRefreshRateConfig(config: AppRefreshRateConfig) { + val current = loadPerAppRefreshRateConfigs().toMutableList() + val index = current.indexOfFirst { it.packageName == config.packageName } + if (index != -1) { + current[index] = config + } else { + current.add(config) + } + savePerAppRefreshRateConfigs(current) + } + private fun updateAppSelection(key: String, packageName: String, enabled: Boolean) { val current = loadAppSelection(key).toMutableList() val index = current.indexOfFirst { it.packageName == packageName } diff --git a/app/src/main/java/com/sameerasw/essentials/domain/model/AppRefreshRateConfig.kt b/app/src/main/java/com/sameerasw/essentials/domain/model/AppRefreshRateConfig.kt new file mode 100644 index 000000000..c9a4cd793 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/domain/model/AppRefreshRateConfig.kt @@ -0,0 +1,8 @@ +package com.sameerasw.essentials.domain.model + +data class AppRefreshRateConfig( + val packageName: String, + val refreshRate: Float, + val isFixed: Boolean = false, + val isEnabled: Boolean = true +) diff --git a/app/src/main/java/com/sameerasw/essentials/services/AppDetectionService.kt b/app/src/main/java/com/sameerasw/essentials/services/AppDetectionService.kt index 0447434dc..c9f83ef05 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/AppDetectionService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/AppDetectionService.kt @@ -4,6 +4,7 @@ import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.Service +import android.app.usage.UsageEvents import android.app.usage.UsageStats import android.app.usage.UsageStatsManager import android.content.BroadcastReceiver @@ -101,6 +102,26 @@ class AppDetectionService : Service() { private fun getForegroundPackage(): String? { val usageStatsManager = getSystemService(USAGE_STATS_SERVICE) as UsageStatsManager val time = System.currentTimeMillis() + + // 1. Try to find the last resumed activity using queryEvents (real-time & accurate) + try { + val events = usageStatsManager.queryEvents(time - 1000 * 15, time) + val event = UsageEvents.Event() + var lastResumedPackage: String? = null + while (events.hasNextEvent()) { + events.getNextEvent(event) + if (event.eventType == UsageEvents.Event.ACTIVITY_RESUMED) { + lastResumedPackage = event.packageName + } + } + if (lastResumedPackage != null) { + return lastResumedPackage + } + } catch (e: Exception) { + android.util.Log.e("AppDetectionService", "Failed to query usage events", e) + } + + // 2. Fallback to queryUsageStats if no events found in the window val stats = usageStatsManager.queryUsageStats( UsageStatsManager.INTERVAL_DAILY, time - 1000 * 10, 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 3bd36d3a9..4f7da9a60 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 @@ -18,9 +18,12 @@ import com.google.gson.Gson import com.sameerasw.essentials.domain.diy.Automation import com.sameerasw.essentials.domain.diy.DIYRepository import com.sameerasw.essentials.domain.model.AppSelection +import com.sameerasw.essentials.domain.model.AppRefreshRateConfig +import com.sameerasw.essentials.data.repository.SettingsRepository import com.sameerasw.essentials.services.automation.executors.CombinedActionExecutor import com.sameerasw.essentials.utils.FreezeManager import com.sameerasw.essentials.utils.StatusBarManager +import com.sameerasw.essentials.utils.RefreshRateUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -78,6 +81,12 @@ class AppFlowHandler( private set private var currentUsageStatsPackage: String? = null + // Per-App Refresh Rate State + private var perAppRateSnapshot: RefreshRateUtils.RefreshRateState? = null + private var perAppCurrentPackage: String? = null + private var pendingRateRunnable: Runnable? = null + private var pendingRestoreRunnable: Runnable? = null + // App Automation State private val activeAppAutomationIds = mutableSetOf() @@ -118,6 +127,7 @@ class AppFlowHandler( checkHighlightNightLight(packageName) checkAppAutomations(packageName) checkGestureBarAutomation(packageName) + checkPerAppRefreshRate(packageName) } } @@ -682,6 +692,99 @@ class AppFlowHandler( } } + private fun checkPerAppRefreshRate(packageName: String) { + if (ignoredSystemPackages.contains(packageName)) { + return + } + + val settingsRepository = SettingsRepository(context) + val isEnabled = settingsRepository.getBoolean(SettingsRepository.KEY_PER_APP_REFRESH_RATE_ENABLED, false) + if (!isEnabled) { + cancelPendingRateRunnable() + cancelPendingRestoreRunnable() + if (perAppRateSnapshot != null) { + restoreFromSnapshot() + } + return + } + + val configs = settingsRepository.loadPerAppRefreshRateConfigs() + val config = configs.find { it.packageName == packageName && it.isEnabled } + + if (config != null) { + cancelPendingRestoreRunnable() + if (perAppRateSnapshot == null) { + perAppRateSnapshot = RefreshRateUtils.getCurrentState(context) + Log.d("AppFlowHandler", "per-app refresh rate: snapshotted state: $perAppRateSnapshot") + } + perAppCurrentPackage = packageName + Log.d("AppFlowHandler", "per-app refresh rate: applying ${config.refreshRate} Hz (isFixed=${config.isFixed}) for $packageName") + if (config.isFixed) { + RefreshRateUtils.applyFixedRefreshRate(context, config.refreshRate) + } else { + RefreshRateUtils.applyDynamicRefreshRate(context, config.refreshRate) + } + + // Re-apply after a short delay to beat OEM adaptive display controllers that + // fire asynchronously after window transitions (e.g. resuming from recents). + cancelPendingRateRunnable() + val runnable = Runnable { + if (perAppCurrentPackage == packageName) { + Log.d("AppFlowHandler", "per-app refresh rate: delayed re-apply ${config.refreshRate} Hz (isFixed=${config.isFixed}) for $packageName") + if (config.isFixed) { + RefreshRateUtils.applyFixedRefreshRate(context, config.refreshRate) + } else { + RefreshRateUtils.applyDynamicRefreshRate(context, config.refreshRate) + } + } + } + pendingRateRunnable = runnable + handler.postDelayed(runnable, 400L) + } else { + cancelPendingRateRunnable() + perAppCurrentPackage = null + if (perAppRateSnapshot != null && pendingRestoreRunnable == null) { + Log.d("AppFlowHandler", "per-app refresh rate: scheduling delayed restoration (1000ms) for leaving $packageName") + val runnable = Runnable { + if (perAppCurrentPackage == null && perAppRateSnapshot != null) { + Log.d("AppFlowHandler", "per-app refresh rate: restoring to global state from snapshot (delayed)") + restoreFromSnapshot() + } + pendingRestoreRunnable = null + } + pendingRestoreRunnable = runnable + handler.postDelayed(runnable, 1000L) + } + } + } + + private fun cancelPendingRateRunnable() { + pendingRateRunnable?.let { handler.removeCallbacks(it) } + pendingRateRunnable = null + } + + private fun cancelPendingRestoreRunnable() { + pendingRestoreRunnable?.let { handler.removeCallbacks(it) } + pendingRestoreRunnable = null + } + + private fun restoreFromSnapshot() { + val snapshot = perAppRateSnapshot ?: return + try { + if (snapshot.isSystemManaged) { + RefreshRateUtils.resetRefreshRate(context, snapshot.usesInfinityDefaultPeak) + } else if (snapshot.min > 0f && snapshot.peak > 0f && snapshot.min != snapshot.peak) { + RefreshRateUtils.applyRangeRefreshRate(context, snapshot.min, snapshot.peak) + } else { + RefreshRateUtils.applyFixedRefreshRate(context, snapshot.peak.coerceAtLeast(snapshot.min)) + } + } catch (e: Exception) { + Log.e("AppFlowHandler", "Failed to restore refresh rate from snapshot", e) + } finally { + perAppRateSnapshot = null + } + } + private fun restartShizuku() { try { val intent = Intent("moe.shizuku.privileged.api.START").apply { diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/sheets/PerAppRefreshRateSettingsSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/sheets/PerAppRefreshRateSettingsSheet.kt new file mode 100644 index 000000000..477facf01 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/components/sheets/PerAppRefreshRateSettingsSheet.kt @@ -0,0 +1,192 @@ +package com.sameerasw.essentials.ui.components.sheets + +import androidx.compose.foundation.Image +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.sameerasw.essentials.R +import com.sameerasw.essentials.domain.model.NotificationApp +import com.sameerasw.essentials.ui.components.containers.RoundedCardContainer +import com.sameerasw.essentials.ui.components.pickers.SegmentedPicker +import com.sameerasw.essentials.utils.AppUtil +import com.sameerasw.essentials.ui.components.cards.IconToggleItem +import com.sameerasw.essentials.utils.RefreshRateUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PerAppRefreshRateSettingsSheet( + packageName: String, + currentRate: Float, + isFixed: Boolean, + onSave: (Float, Boolean) -> Unit, + onDelete: () -> Unit, + onDismissRequest: () -> Unit +) { + val context = LocalContext.current + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + var appInfo by remember { mutableStateOf(null) } + + val rates = remember { RefreshRateUtils.getSupportedRefreshRates(context) } + var selectedRate by remember { mutableStateOf(if (currentRate <= 0f) (rates.lastOrNull() ?: 120f) else currentRate) } + var selectedIsFixed by remember { mutableStateOf(isFixed) } + + LaunchedEffect(packageName) { + withContext(Dispatchers.IO) { + val app = AppUtil.getAppsByPackageNames(context, listOf(packageName)).firstOrNull() + withContext(Dispatchers.Main) { + appInfo = app + } + } + } + + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + // Title + Text( + text = stringResource(R.string.refresh_rate_per_app_select_rate), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) + + // App Header + appInfo?.let { app -> + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Image( + bitmap = app.icon, + contentDescription = app.appName, + modifier = Modifier + .size(64.dp) + .clip(RoundedCornerShape(12.dp)) + ) + Text( + text = app.appName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + text = app.packageName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } ?: Spacer(modifier = Modifier.height(110.dp)) + + // Refresh Rate Selection & Fixed Mode option + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + RoundedCardContainer( + spacing = 0.dp, + cornerRadius = 24.dp + ) { + SegmentedPicker( + items = rates, + selectedItem = selectedRate, + onItemSelected = { selectedRate = it }, + labelProvider = { "${it.toInt()} Hz" }, + modifier = Modifier.fillMaxWidth() + ) + } + + RoundedCardContainer( + spacing = 0.dp, + cornerRadius = 24.dp + ) { + IconToggleItem( + iconRes = R.drawable.rounded_shutter_speed_24, + title = stringResource(R.string.refresh_rate_per_app_fixed_toggle), + description = stringResource(R.string.refresh_rate_per_app_fixed_toggle_desc), + isChecked = selectedIsFixed, + onCheckedChange = { selectedIsFixed = it }, + modifier = Modifier.fillMaxWidth() + ) + } + } + + // Action Buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Delete Button (only if there was an existing config, i.e., currentRate > 0) + if (currentRate > 0f) { + OutlinedButton( + onClick = { + onDelete() + onDismissRequest() + }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(24.dp) + ) { + Text( + text = stringResource(R.string.action_delete), + color = MaterialTheme.colorScheme.error + ) + } + } + + // Save Button + Button( + onClick = { + onSave(selectedRate, selectedIsFixed) + onDismissRequest() + }, + modifier = Modifier.weight(1.5f), + shape = RoundedCornerShape(24.dp) + ) { + Text(stringResource(R.string.action_save)) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/RefreshRateSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/RefreshRateSettingsUI.kt index 2f4e73ef2..361700518 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/RefreshRateSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/RefreshRateSettingsUI.kt @@ -28,6 +28,20 @@ import com.sameerasw.essentials.utils.HapticUtil import com.sameerasw.essentials.utils.RefreshRateUtils import com.sameerasw.essentials.viewmodels.MainViewModel import kotlin.math.roundToInt +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.res.painterResource +import com.sameerasw.essentials.domain.model.AppRefreshRateConfig +import com.sameerasw.essentials.ui.components.cards.FeatureCard +import com.sameerasw.essentials.ui.components.menus.SegmentedDropdownMenuItem +import com.sameerasw.essentials.ui.components.sheets.PerAppRefreshRateSettingsSheet +import com.sameerasw.essentials.ui.components.sheets.SingleAppSelectionSheet +import com.sameerasw.essentials.utils.AppUtil @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -42,6 +56,12 @@ fun RefreshRateSettingsUI( val isFixedMode = viewModel.refreshRateMode.value == RefreshRateUtils.MODE_FIXED val systemLabel = stringResource(R.string.refresh_rate_system_default) + var isAppSelectionSheetOpen by remember { mutableStateOf(false) } + var isEditSheetOpen by remember { mutableStateOf(false) } + var editingPackageName by remember { mutableStateOf("") } + var editingCurrentRate by remember { mutableStateOf(0f) } + var editingIsFixed by remember { mutableStateOf(false) } + Column( modifier = modifier .fillMaxWidth() @@ -195,6 +215,181 @@ fun RefreshRateSettingsUI( } } } + + Text( + text = stringResource(R.string.refresh_rate_section_per_app), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 16.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + val onTogglePerAppRefreshRate: (Boolean) -> Unit = { enabled -> + if (enabled) { + val isUseUsageAccessVal = viewModel.isUseUsageAccess.value + val hasPermission = if (isUseUsageAccessVal) { + viewModel.isUsageStatsPermissionGranted.value + } else { + viewModel.isAccessibilityEnabled.value + } + + if (!hasPermission) { + if (isUseUsageAccessVal) { + com.sameerasw.essentials.utils.PermissionUtils.openUsageStatsSettings(context) + android.widget.Toast.makeText( + context, + context.getString(R.string.refresh_rate_per_app_usage_access_required), + android.widget.Toast.LENGTH_LONG + ).show() + } else { + com.sameerasw.essentials.utils.PermissionUtils.openAccessibilitySettings(context) + android.widget.Toast.makeText( + context, + context.getString(R.string.refresh_rate_per_app_accessibility_required), + android.widget.Toast.LENGTH_LONG + ).show() + } + } else { + viewModel.setPerAppRefreshRateEnabled(true, context) + } + } else { + viewModel.setPerAppRefreshRateEnabled(false, context) + } + } + + RoundedCardContainer( + modifier = Modifier, + spacing = 2.dp, + cornerRadius = 24.dp + ) { + FeatureCard( + title = stringResource(R.string.refresh_rate_per_app_enable_title), + description = stringResource(R.string.refresh_rate_per_app_enable_desc), + iconRes = R.drawable.rounded_shutter_speed_24, + isEnabled = viewModel.isPerAppRefreshRateEnabled.value, + showToggle = true, + hasMoreSettings = false, + onToggle = onTogglePerAppRefreshRate, + onClick = { onTogglePerAppRefreshRate(!viewModel.isPerAppRefreshRateEnabled.value) } + ) + } + + if (viewModel.isPerAppRefreshRateEnabled.value) { + RoundedCardContainer( + modifier = Modifier, + spacing = 2.dp, + cornerRadius = 24.dp + ) { + FeatureCard( + title = stringResource(R.string.refresh_rate_per_app_add_app), + description = stringResource(R.string.refresh_rate_per_app_add_app_desc), + iconRes = R.drawable.rounded_add_24, + isEnabled = true, + showToggle = false, + hasMoreSettings = false, + onToggle = {}, + onClick = { isAppSelectionSheetOpen = true } + ) + } + + val configs by viewModel.perAppRefreshRateConfigs + if (configs.isNotEmpty()) { + RoundedCardContainer( + modifier = Modifier, + spacing = 2.dp, + cornerRadius = 24.dp + ) { + configs.forEach { config -> + val appName = remember(config.packageName) { + try { + val appInfo = context.packageManager.getApplicationInfo(config.packageName, 0) + context.packageManager.getApplicationLabel(appInfo).toString() + } catch (e: Exception) { + config.packageName + } + } + + val appIconPainter = remember(config.packageName) { + try { + val drawable = context.packageManager.getApplicationIcon(config.packageName) + androidx.compose.ui.graphics.painter.BitmapPainter( + AppUtil.drawableToBitmap(drawable).asImageBitmap() + ) + } catch (e: Exception) { + null + } + } + + FeatureCard( + title = appName, + description = "${config.refreshRate.toInt()} Hz (${if (config.isFixed) stringResource(R.string.refresh_rate_per_app_mode_fixed) else stringResource(R.string.refresh_rate_per_app_mode_dynamic)})", + isEnabled = config.isEnabled, + showToggle = true, + onToggle = { isChecked -> + viewModel.updatePerAppRefreshRateConfig(config.copy(isEnabled = isChecked)) + }, + onClick = { + editingPackageName = config.packageName + editingCurrentRate = config.refreshRate + editingIsFixed = config.isFixed + isEditSheetOpen = true + }, + iconPainter = appIconPainter, + hasMoreSettings = true, + additionalMenuItems = { onDismiss -> + SegmentedDropdownMenuItem( + text = { Text(stringResource(R.string.action_remove)) }, + onClick = { + onDismiss() + viewModel.removePerAppRefreshRateConfig(config.packageName) + }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.rounded_delete_24), + contentDescription = null + ) + } + ) + } + ) + } + } + } + } + + if (isAppSelectionSheetOpen) { + SingleAppSelectionSheet( + onDismissRequest = { isAppSelectionSheetOpen = false }, + onAppSelected = { app -> + isAppSelectionSheetOpen = false + editingPackageName = app.packageName + editingCurrentRate = 0f + editingIsFixed = false + isEditSheetOpen = true + } + ) + } + + if (isEditSheetOpen) { + PerAppRefreshRateSettingsSheet( + packageName = editingPackageName, + currentRate = editingCurrentRate, + isFixed = editingIsFixed, + onSave = { rate, isFixed -> + viewModel.updatePerAppRefreshRateConfig( + AppRefreshRateConfig( + packageName = editingPackageName, + refreshRate = rate, + isFixed = isFixed, + isEnabled = true + ) + ) + }, + onDelete = { + viewModel.removePerAppRefreshRateConfig(editingPackageName) + }, + onDismissRequest = { isEditSheetOpen = false } + ) + } } } diff --git a/app/src/main/java/com/sameerasw/essentials/utils/RefreshRateUtils.kt b/app/src/main/java/com/sameerasw/essentials/utils/RefreshRateUtils.kt index 32f6d027e..ccdf5c813 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/RefreshRateUtils.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/RefreshRateUtils.kt @@ -78,6 +78,8 @@ object RefreshRateUtils { val formatted = formatRate(clamped) ShellUtils.runCommand(context, "settings put system $KEY_PEAK_REFRESH_RATE $formatted") ShellUtils.runCommand(context, "settings put system $KEY_MIN_REFRESH_RATE $formatted") + ShellUtils.runCommand(context, "settings put global $KEY_PEAK_REFRESH_RATE $formatted") + ShellUtils.runCommand(context, "settings put global $KEY_MIN_REFRESH_RATE $formatted") return true } @@ -94,6 +96,14 @@ object RefreshRateUtils { context, "settings put system $KEY_PEAK_REFRESH_RATE ${formatRate(safePeak)}" ) + ShellUtils.runCommand( + context, + "settings put global $KEY_MIN_REFRESH_RATE ${formatRate(safeMin)}" + ) + ShellUtils.runCommand( + context, + "settings put global $KEY_PEAK_REFRESH_RATE ${formatRate(safePeak)}" + ) return true } @@ -212,6 +222,34 @@ object RefreshRateUtils { } } + fun getSupportedRefreshRates(context: Context): List { + return try { + val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager + val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY) + val rates = display?.supportedModes + ?.map { it.refreshRate.roundToInt().toFloat() } + ?.distinct() + ?.sorted() + ?.filter { it >= 30f } + ?: emptyList() + if (rates.isEmpty()) listOf(60f, 120f) else rates + } catch (_: Exception) { + listOf(60f, 120f) + } + } + + fun applyDynamicRefreshRate(context: Context, value: Float): Boolean { + if (!ShellUtils.hasPermission(context)) return false + + val clamped = normalizeRate(value) + val formatted = formatRate(clamped) + ShellUtils.runCommand(context, "settings put system $KEY_PEAK_REFRESH_RATE $formatted") + ShellUtils.runCommand(context, "settings put system $KEY_MIN_REFRESH_RATE 0") + ShellUtils.runCommand(context, "settings put global $KEY_PEAK_REFRESH_RATE $formatted") + ShellUtils.runCommand(context, "settings put global $KEY_MIN_REFRESH_RATE 0") + return true + } + private fun formatRate(value: Float): String { return String.format(Locale.US, "%.0f", value) } diff --git a/app/src/main/java/com/sameerasw/essentials/utils/ServiceUtils.kt b/app/src/main/java/com/sameerasw/essentials/utils/ServiceUtils.kt index dc7a83829..8318b64a5 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/ServiceUtils.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/ServiceUtils.kt @@ -30,6 +30,8 @@ object ServiceUtils { settingsRepository.getBoolean(SettingsRepository.KEY_HIDE_GESTURE_BAR_ON_LAUNCHER_ENABLED) val isUseUsageAccess = settingsRepository.getBoolean(SettingsRepository.KEY_USE_USAGE_ACCESS) + val isPerAppRefreshRateEnabled = + settingsRepository.getBoolean(SettingsRepository.KEY_PER_APP_REFRESH_RATE_ENABLED) val hasAppAutomations = DIYRepository.automations.value.any { it.isEnabled && it.type == Automation.Type.APP @@ -39,7 +41,7 @@ object ServiceUtils { val hasShutUpApps = shutUpConfigs.any { it.isEnabled } val shouldRun = - (isUseUsageAccess && (isAppLockEnabled || isDynamicNightLightEnabled || isHideGestureBarOnLauncherEnabled || hasAppAutomations)) || hasShutUpApps + isUseUsageAccess && (isAppLockEnabled || isDynamicNightLightEnabled || isHideGestureBarOnLauncherEnabled || hasAppAutomations || hasShutUpApps || isPerAppRefreshRateEnabled) val intent = Intent(context, AppDetectionService::class.java) if (shouldRun) { diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt index 0a1df9de7..645e565bc 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -37,6 +37,7 @@ import com.sameerasw.essentials.data.repository.UpdateRepository import com.sameerasw.essentials.domain.HapticFeedbackType import com.sameerasw.essentials.domain.MapsState import com.sameerasw.essentials.domain.model.AppSelection +import com.sameerasw.essentials.domain.model.AppRefreshRateConfig import com.sameerasw.essentials.domain.model.DnsPreset import com.sameerasw.essentials.domain.model.NotificationApp import com.sameerasw.essentials.domain.model.NotificationLightingColorMode @@ -163,6 +164,9 @@ class MainViewModel : ViewModel() { val shutUpRestoreDelay = mutableIntStateOf(10) val edgeLightingSweepSelectedShapes = mutableStateOf>(emptySet()) + val isPerAppRefreshRateEnabled = mutableStateOf(false) + val perAppRefreshRateConfigs = mutableStateOf>(emptyList()) + data class CalendarAccount( val id: Long, @@ -615,6 +619,15 @@ class MainViewModel : ViewModel() { appContext?.let { updateAppDetectionService(it) } } + SettingsRepository.KEY_PER_APP_REFRESH_RATE_ENABLED -> { + isPerAppRefreshRateEnabled.value = settingsRepository.getBoolean(key) + appContext?.let { updateAppDetectionService(it) } + } + + SettingsRepository.KEY_PER_APP_REFRESH_RATE_CONFIGS -> { + loadPerAppRefreshRateConfigs() + } + SettingsRepository.KEY_LIVE_WALLPAPER_SELECTED_VIDEO -> { liveWallpaperSelectedVideo.value = settingsRepository.getLiveWallpaperSelectedVideo() @@ -674,6 +687,28 @@ class MainViewModel : ViewModel() { shutUpConfigs.value = settingsRepository.loadShutUpConfigs() } + fun loadPerAppRefreshRateConfigs() { + perAppRefreshRateConfigs.value = settingsRepository.loadPerAppRefreshRateConfigs() + } + + fun updatePerAppRefreshRateConfig(config: AppRefreshRateConfig) { + settingsRepository.updatePerAppRefreshRateConfig(config) + loadPerAppRefreshRateConfigs() + } + + fun removePerAppRefreshRateConfig(packageName: String) { + val current = perAppRefreshRateConfigs.value.toMutableList() + current.removeAll { it.packageName == packageName } + settingsRepository.savePerAppRefreshRateConfigs(current) + loadPerAppRefreshRateConfigs() + } + + fun setPerAppRefreshRateEnabled(enabled: Boolean, context: Context) { + isPerAppRefreshRateEnabled.value = enabled + settingsRepository.putBoolean(SettingsRepository.KEY_PER_APP_REFRESH_RATE_ENABLED, enabled) + updateAppDetectionService(context) + } + fun updateShutUpConfig(config: com.sameerasw.essentials.domain.model.ShutUpAppConfig) { settingsRepository.updateShutUpConfig(config) loadShutUpConfigs() @@ -811,6 +846,9 @@ class MainViewModel : ViewModel() { settingsRepository.getLockScreenClockSelectedColorId() lockScreenClockSeedColor.intValue = settingsRepository.getLockScreenClockSeedColor() loadShutUpConfigs() + isPerAppRefreshRateEnabled.value = + settingsRepository.getBoolean(SettingsRepository.KEY_PER_APP_REFRESH_RATE_ENABLED, false) + loadPerAppRefreshRateConfigs() recentSearches.value = settingsRepository.getRecentSearches() if (isHideGestureBarEnabled.value) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7fe5b43d0..d36316eae 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -860,6 +860,19 @@ Enable Disable + Per-App Refresh Rate + Per-App Refresh Rate + Automatically switch the refresh rate when a configured app is opened + Add App + Select an app to customize its refresh rate + Select Refresh Rate + Fixed + Dynamic + Fixed Mode + Locks the screen to this refresh rate. Turn off to allow the display to scale down and save battery. + Accessibility service is required for Per-App Refresh Rate to detect active apps. + Usage access permission is required for Per-App Refresh Rate to detect active apps. + Automation Service Automations Active Monitoring system events for your automations From 524a19554ba5b79a01cd6d36aca1f823af16468a Mon Sep 17 00:00:00 2001 From: Mudit200408 Date: Sat, 6 Jun 2026 14:21:42 +0530 Subject: [PATCH 2/4] feat: Allow changing refresh rate on landscape and when media is playing --- .../domain/model/AppRefreshRateConfig.kt | 4 +- .../services/AppDetectionService.kt | 1 + .../services/NotificationListener.kt | 7 ++ .../services/handlers/AppFlowHandler.kt | 93 +++++++++++++++++-- .../tiles/ScreenOffAccessibilityService.kt | 1 + .../sheets/PerAppRefreshRateSettingsSheet.kt | 57 +++++++++++- .../configs/RefreshRateSettingsUI.kt | 21 ++++- app/src/main/res/values/strings.xml | 4 + 8 files changed, 175 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/domain/model/AppRefreshRateConfig.kt b/app/src/main/java/com/sameerasw/essentials/domain/model/AppRefreshRateConfig.kt index c9a4cd793..3c56a050a 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/model/AppRefreshRateConfig.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/model/AppRefreshRateConfig.kt @@ -4,5 +4,7 @@ data class AppRefreshRateConfig( val packageName: String, val refreshRate: Float, val isFixed: Boolean = false, - val isEnabled: Boolean = true + val isEnabled: Boolean = true, + val landscapeRefreshRate: Float? = null, + val onlyOnMediaPlaying: Boolean = false ) diff --git a/app/src/main/java/com/sameerasw/essentials/services/AppDetectionService.kt b/app/src/main/java/com/sameerasw/essentials/services/AppDetectionService.kt index c9f83ef05..03e62b4a9 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/AppDetectionService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/AppDetectionService.kt @@ -150,6 +150,7 @@ class AppDetectionService : Service() { override fun onDestroy() { isRunning = false isPolling = false + appFlowHandler.destroy() handler.removeCallbacksAndMessages(null) try { unregisterReceiver(authReceiver) diff --git a/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt b/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt index 069a66768..935627c14 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt @@ -669,6 +669,13 @@ class NotificationListener : NotificationListenerService() { if (eventType != null) { triggerAmbientGlance(controller, eventType, isLiked, sbn = sbn) } + + val playStateIntent = Intent("com.sameerasw.essentials.MEDIA_PLAYBACK_CHANGED").apply { + putExtra("package_name", sbn.packageName) + putExtra("is_playing", isPlaying) + setPackage(packageName) + } + sendBroadcast(playStateIntent) } } catch (e: Exception) { e.printStackTrace() 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 4f7da9a60..4297dbf7e 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 @@ -12,6 +12,9 @@ import android.content.pm.PackageManager import android.os.Handler import android.os.Looper import android.provider.Settings +import android.content.res.Configuration +import android.content.IntentFilter +import android.os.Build import android.util.Log import androidx.core.app.NotificationCompat import com.google.gson.Gson @@ -22,6 +25,7 @@ import com.sameerasw.essentials.domain.model.AppRefreshRateConfig import com.sameerasw.essentials.data.repository.SettingsRepository import com.sameerasw.essentials.services.automation.executors.CombinedActionExecutor import com.sameerasw.essentials.utils.FreezeManager +import com.sameerasw.essentials.services.NotificationListener import com.sameerasw.essentials.utils.StatusBarManager import com.sameerasw.essentials.utils.RefreshRateUtils import kotlinx.coroutines.CoroutineScope @@ -36,7 +40,52 @@ class AppFlowHandler( private val service: AccessibilityService? = null ) { private val handler = Handler(Looper.getMainLooper()) - private val scope = CoroutineScope(Dispatchers.Main) + private var lastOrientation = context.resources.configuration.orientation + private val componentCallbacks = object : android.content.ComponentCallbacks2 { + override fun onConfigurationChanged(newConfig: Configuration) { + val newOrientation = newConfig.orientation + if (newOrientation != lastOrientation) { + lastOrientation = newOrientation + val currentPkg = currentPackage + if (currentPkg != null) { + checkPerAppRefreshRate(currentPkg) + } + } + } + override fun onLowMemory() {} + override fun onTrimMemory(level: Int) {} + } + + private val mediaReceiver = object : android.content.BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == "com.sameerasw.essentials.MEDIA_PLAYBACK_CHANGED") { + val pkg = intent.getStringExtra("package_name") + if (pkg != null && pkg == currentPackage) { + checkPerAppRefreshRate(pkg) + } + } + } + } + + init { + context.registerComponentCallbacks(componentCallbacks) + val filter = IntentFilter("com.sameerasw.essentials.MEDIA_PLAYBACK_CHANGED") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver(mediaReceiver, filter, Context.RECEIVER_EXPORTED) + } else { + context.registerReceiver(mediaReceiver, filter) + } + } + + fun destroy() { + try { + context.unregisterComponentCallbacks(componentCallbacks) + } catch (_: Exception) {} + try { + context.unregisterReceiver(mediaReceiver) + } catch (_: Exception) {} + } + private val scope = CoroutineScope(Dispatchers.Main.immediate) private val authenticatedPackages = mutableSetOf() private val lastLeaveTimes = mutableMapOf() @@ -692,6 +741,34 @@ class AppFlowHandler( } } + private fun isMediaPlaying(packageName: String): Boolean { + return try { + val msm = context.getSystemService(Context.MEDIA_SESSION_SERVICE) as? android.media.session.MediaSessionManager + val componentName = android.content.ComponentName(context, NotificationListener::class.java) + val sessions = msm?.getActiveSessions(componentName) + sessions?.any { + it.packageName == packageName && + it.playbackState?.state == android.media.session.PlaybackState.STATE_PLAYING + } ?: false + } catch (e: Exception) { + false + } + } + + private fun getTargetRefreshRateForConfig(config: AppRefreshRateConfig): Float { + val landscapeRate = config.landscapeRefreshRate + if (landscapeRate != null) { + val isLandscape = context.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + if (isLandscape) { + if (config.onlyOnMediaPlaying) { + return if (isMediaPlaying(config.packageName)) landscapeRate else config.refreshRate + } + return landscapeRate + } + } + return config.refreshRate + } + private fun checkPerAppRefreshRate(packageName: String) { if (ignoredSystemPackages.contains(packageName)) { return @@ -718,11 +795,12 @@ class AppFlowHandler( Log.d("AppFlowHandler", "per-app refresh rate: snapshotted state: $perAppRateSnapshot") } perAppCurrentPackage = packageName - Log.d("AppFlowHandler", "per-app refresh rate: applying ${config.refreshRate} Hz (isFixed=${config.isFixed}) for $packageName") + val targetRate = getTargetRefreshRateForConfig(config) + Log.d("AppFlowHandler", "per-app refresh rate: applying $targetRate Hz (isFixed=${config.isFixed}) for $packageName") if (config.isFixed) { - RefreshRateUtils.applyFixedRefreshRate(context, config.refreshRate) + RefreshRateUtils.applyFixedRefreshRate(context, targetRate) } else { - RefreshRateUtils.applyDynamicRefreshRate(context, config.refreshRate) + RefreshRateUtils.applyDynamicRefreshRate(context, targetRate) } // Re-apply after a short delay to beat OEM adaptive display controllers that @@ -730,11 +808,12 @@ class AppFlowHandler( cancelPendingRateRunnable() val runnable = Runnable { if (perAppCurrentPackage == packageName) { - Log.d("AppFlowHandler", "per-app refresh rate: delayed re-apply ${config.refreshRate} Hz (isFixed=${config.isFixed}) for $packageName") + val delayedRate = getTargetRefreshRateForConfig(config) + Log.d("AppFlowHandler", "per-app refresh rate: delayed re-apply $delayedRate Hz (isFixed=${config.isFixed}) for $packageName") if (config.isFixed) { - RefreshRateUtils.applyFixedRefreshRate(context, config.refreshRate) + RefreshRateUtils.applyFixedRefreshRate(context, delayedRate) } else { - RefreshRateUtils.applyDynamicRefreshRate(context, config.refreshRate) + RefreshRateUtils.applyDynamicRefreshRate(context, delayedRate) } } } diff --git a/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt b/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt index e91b10735..6219c11fb 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt @@ -211,6 +211,7 @@ class ScreenOffAccessibilityService : AccessibilityService() { omniGestureOverlayHandler.removeOverlay() statusBarIconHandler.unregister() stopInputEventListener() + appFlowHandler.destroy() serviceScope.cancel() getSharedPreferences("essentials_prefs", MODE_PRIVATE) .unregisterOnSharedPreferenceChangeListener(preferenceChangeListener) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/sheets/PerAppRefreshRateSettingsSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/sheets/PerAppRefreshRateSettingsSheet.kt index 477facf01..d5de854c4 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/components/sheets/PerAppRefreshRateSettingsSheet.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/components/sheets/PerAppRefreshRateSettingsSheet.kt @@ -49,7 +49,9 @@ fun PerAppRefreshRateSettingsSheet( packageName: String, currentRate: Float, isFixed: Boolean, - onSave: (Float, Boolean) -> Unit, + landscapeRate: Float?, + onlyOnMediaPlaying: Boolean, + onSave: (Float, Boolean, Float?, Boolean) -> Unit, onDelete: () -> Unit, onDismissRequest: () -> Unit ) { @@ -62,6 +64,10 @@ fun PerAppRefreshRateSettingsSheet( var selectedRate by remember { mutableStateOf(if (currentRate <= 0f) (rates.lastOrNull() ?: 120f) else currentRate) } var selectedIsFixed by remember { mutableStateOf(isFixed) } + var useLandscapeRate by remember { mutableStateOf(landscapeRate != null) } + var selectedLandscapeRate by remember { mutableStateOf(landscapeRate ?: rates.lastOrNull() ?: 120f) } + var selectedOnlyOnMediaPlaying by remember { mutableStateOf(onlyOnMediaPlaying) } + LaunchedEffect(packageName) { withContext(Dispatchers.IO) { val app = AppUtil.getAppsByPackageNames(context, listOf(packageName)).firstOrNull() @@ -118,7 +124,7 @@ fun PerAppRefreshRateSettingsSheet( } } ?: Spacer(modifier = Modifier.height(110.dp)) - // Refresh Rate Selection & Fixed Mode option + // Refresh Rate Selection, Fixed Mode & Landscape option Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp) @@ -149,6 +155,46 @@ fun PerAppRefreshRateSettingsSheet( modifier = Modifier.fillMaxWidth() ) } + + RoundedCardContainer( + spacing = 0.dp, + cornerRadius = 24.dp + ) { + Column(modifier = Modifier.fillMaxWidth()) { + IconToggleItem( + iconRes = R.drawable.rounded_mobile_rotate_24, + title = stringResource(R.string.refresh_rate_per_app_landscape_toggle), + description = stringResource(R.string.refresh_rate_per_app_landscape_toggle_desc), + isChecked = useLandscapeRate, + onCheckedChange = { useLandscapeRate = it }, + modifier = Modifier.fillMaxWidth() + ) + if (useLandscapeRate) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp, top = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + SegmentedPicker( + items = rates, + selectedItem = selectedLandscapeRate, + onItemSelected = { selectedLandscapeRate = it }, + labelProvider = { "${it.toInt()} Hz" }, + modifier = Modifier.fillMaxWidth() + ) + IconToggleItem( + iconRes = R.drawable.round_play_arrow_24, + title = stringResource(R.string.refresh_rate_per_app_only_media_toggle), + description = stringResource(R.string.refresh_rate_per_app_only_media_toggle_desc), + isChecked = selectedOnlyOnMediaPlaying, + onCheckedChange = { selectedOnlyOnMediaPlaying = it }, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } } // Action Buttons @@ -176,7 +222,12 @@ fun PerAppRefreshRateSettingsSheet( // Save Button Button( onClick = { - onSave(selectedRate, selectedIsFixed) + onSave( + selectedRate, + selectedIsFixed, + if (useLandscapeRate) selectedLandscapeRate else null, + if (useLandscapeRate) selectedOnlyOnMediaPlaying else false + ) onDismissRequest() }, modifier = Modifier.weight(1.5f), diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/RefreshRateSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/RefreshRateSettingsUI.kt index 361700518..a13942fad 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/RefreshRateSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/RefreshRateSettingsUI.kt @@ -61,6 +61,8 @@ fun RefreshRateSettingsUI( var editingPackageName by remember { mutableStateOf("") } var editingCurrentRate by remember { mutableStateOf(0f) } var editingIsFixed by remember { mutableStateOf(false) } + var editingLandscapeRate by remember { mutableStateOf(null) } + var editingOnlyOnMediaPlaying by remember { mutableStateOf(false) } Column( modifier = modifier @@ -319,9 +321,16 @@ fun RefreshRateSettingsUI( } } + val suffix = if (config.onlyOnMediaPlaying) " (Media Only)" else "" + val cardDesc = if (config.landscapeRefreshRate != null) { + "${config.refreshRate.toInt()} Hz (${if (config.isFixed) stringResource(R.string.refresh_rate_per_app_mode_fixed) else stringResource(R.string.refresh_rate_per_app_mode_dynamic)}) | ${config.landscapeRefreshRate.toInt()} Hz in landscape$suffix" + } else { + "${config.refreshRate.toInt()} Hz (${if (config.isFixed) stringResource(R.string.refresh_rate_per_app_mode_fixed) else stringResource(R.string.refresh_rate_per_app_mode_dynamic)})" + } + FeatureCard( title = appName, - description = "${config.refreshRate.toInt()} Hz (${if (config.isFixed) stringResource(R.string.refresh_rate_per_app_mode_fixed) else stringResource(R.string.refresh_rate_per_app_mode_dynamic)})", + description = cardDesc, isEnabled = config.isEnabled, showToggle = true, onToggle = { isChecked -> @@ -331,6 +340,8 @@ fun RefreshRateSettingsUI( editingPackageName = config.packageName editingCurrentRate = config.refreshRate editingIsFixed = config.isFixed + editingLandscapeRate = config.landscapeRefreshRate + editingOnlyOnMediaPlaying = config.onlyOnMediaPlaying isEditSheetOpen = true }, iconPainter = appIconPainter, @@ -364,6 +375,8 @@ fun RefreshRateSettingsUI( editingPackageName = app.packageName editingCurrentRate = 0f editingIsFixed = false + editingLandscapeRate = null + editingOnlyOnMediaPlaying = false isEditSheetOpen = true } ) @@ -374,12 +387,16 @@ fun RefreshRateSettingsUI( packageName = editingPackageName, currentRate = editingCurrentRate, isFixed = editingIsFixed, - onSave = { rate, isFixed -> + landscapeRate = editingLandscapeRate, + onlyOnMediaPlaying = editingOnlyOnMediaPlaying, + onSave = { rate, isFixed, landscapeRate, onlyOnMedia -> viewModel.updatePerAppRefreshRateConfig( AppRefreshRateConfig( packageName = editingPackageName, refreshRate = rate, isFixed = isFixed, + landscapeRefreshRate = landscapeRate, + onlyOnMediaPlaying = onlyOnMedia, isEnabled = true ) ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d36316eae..040b77c41 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -872,6 +872,10 @@ Locks the screen to this refresh rate. Turn off to allow the display to scale down and save battery. Accessibility service is required for Per-App Refresh Rate to detect active apps. Usage access permission is required for Per-App Refresh Rate to detect active apps. + Use different rate in landscape + Configure a specific refresh rate when the app is rotated to landscape mode + Only when media is playing + Only apply landscape rate when video or music is playing. Note: Some OTT apps (Netflix, Prime Video, Hotstar, etc.) do not expose media status so do not use this. Automation Service Automations Active From 52139874b9759cd5810a5cf58a516018a11e92ac Mon Sep 17 00:00:00 2001 From: Mudit200408 Date: Wed, 10 Jun 2026 12:31:17 +0530 Subject: [PATCH 3/4] per-app-rr: Create a new page for per app refresh rate in display --- .../essentials/FeatureSettingsActivity.kt | 10 + .../domain/registry/FeatureRegistry.kt | 24 ++ .../sheets/PerAppRefreshRateSettingsSheet.kt | 11 +- .../configs/PerAppRefreshRateSettingsUI.kt | 245 ++++++++++++++++++ .../configs/RefreshRateSettingsUI.kt | 212 --------------- .../res/drawable/ic_per_app_refresh_rate.xml | 24 ++ 6 files changed, 311 insertions(+), 215 deletions(-) create mode 100644 app/src/main/java/com/sameerasw/essentials/ui/composables/configs/PerAppRefreshRateSettingsUI.kt create mode 100644 app/src/main/res/drawable/ic_per_app_refresh_rate.xml diff --git a/app/src/main/java/com/sameerasw/essentials/FeatureSettingsActivity.kt b/app/src/main/java/com/sameerasw/essentials/FeatureSettingsActivity.kt index 2dbd2aea8..7f564d11c 100644 --- a/app/src/main/java/com/sameerasw/essentials/FeatureSettingsActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/FeatureSettingsActivity.kt @@ -73,6 +73,7 @@ import com.sameerasw.essentials.ui.composables.configs.NotificationLightingSetti import com.sameerasw.essentials.ui.composables.configs.OtherCustomizationsSettingsUI import com.sameerasw.essentials.ui.composables.configs.QuickSettingsTilesSettingsUI import com.sameerasw.essentials.ui.composables.configs.RefreshRateSettingsUI +import com.sameerasw.essentials.ui.composables.configs.PerAppRefreshRateSettingsUI import com.sameerasw.essentials.ui.composables.configs.RemoteLockSettingsUI import com.sameerasw.essentials.ui.composables.configs.ScreenLockedSecuritySettingsUI import com.sameerasw.essentials.ui.composables.configs.ScreenOffWidgetSettingsUI @@ -264,6 +265,7 @@ class FeatureSettingsActivity : AppCompatActivity() { "Location reached" -> !viewModel.isLocationPermissionGranted.value || !viewModel.isBackgroundLocationPermissionGranted.value "Quick settings tiles" -> !viewModel.isWriteSettingsEnabled.value "Screen refresh rate" -> !viewModel.isShizukuPermissionGranted.value + "Per app refresh rate" -> (if (viewModel.isUseUsageAccess.value) !viewModel.isUsageStatsPermissionGranted.value else !isAccessibilityEnabled) || !isShizukuPermissionGranted // Top level checks for other features (rarely hit if they are children, but safe to add) "Essentials On Display" -> !isAccessibilityEnabled || !isNotificationListenerEnabled "Call vibrations" -> !isReadPhoneStateEnabled || !isNotificationListenerEnabled @@ -497,6 +499,7 @@ class FeatureSettingsActivity : AppCompatActivity() { "Text and animations" -> !viewModel.isWriteSettingsEnabled.value || !isWriteSecureSettingsEnabled "Lock screen clock" -> !isWriteSecureSettingsEnabled "Screen refresh rate" -> !viewModel.isShizukuPermissionGranted.value + "Per app refresh rate" -> (if (viewModel.isUseUsageAccess.value) !viewModel.isUsageStatsPermissionGranted.value else !isAccessibilityEnabled) || !viewModel.isShizukuPermissionGranted.value "Shut-Up!" -> !isWriteSecureSettingsEnabled || !viewModel.isUsageStatsPermissionGranted.value else -> false } @@ -743,6 +746,13 @@ class FeatureSettingsActivity : AppCompatActivity() { ) } + "Per app refresh rate" -> { + PerAppRefreshRateSettingsUI( + viewModel = viewModel, + modifier = Modifier.padding(top = 16.dp), + highlightSetting = highlightSetting + ) + } "Always on Display" -> { AlwaysOnDisplaySettingsUI( viewModel = viewModel, diff --git a/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt index 47dd20bfd..7bc639a90 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt @@ -225,6 +225,30 @@ object FeatureRegistry { override fun isEnabled(viewModel: MainViewModel) = true override fun onToggle(viewModel: MainViewModel, context: Context, enabled: Boolean) {} }, + object : Feature( + id = "Per app refresh rate", + title = R.string.refresh_rate_per_app_enable_title, + iconRes = R.drawable.ic_per_app_refresh_rate, + category = R.string.cat_interface, + description = R.string.refresh_rate_per_app_enable_desc, + aboutDescription = R.string.refresh_rate_per_app_enable_desc, + showToggle = false, + parentFeatureId = "Display", + ) { + override val permissionKeys: List + get() = (if (com.sameerasw.essentials.data.repository.SettingsRepository(com.sameerasw.essentials.EssentialsApp.context) + .getBoolean(com.sameerasw.essentials.data.repository.SettingsRepository.KEY_USE_USAGE_ACCESS)) + listOf("USAGE_STATS") else listOf("ACCESSIBILITY")) + listOf("SHIZUKU") + + override fun isEnabled(viewModel: MainViewModel): Boolean = viewModel.isPerAppRefreshRateEnabled.value + + override fun isToggleEnabled(viewModel: MainViewModel, context: Context): Boolean = + (if (viewModel.isUseUsageAccess.value) viewModel.isUsageStatsPermissionGranted.value else viewModel.isAccessibilityEnabled.value) && viewModel.isShizukuPermissionGranted.value + + override fun onToggle(viewModel: MainViewModel, context: Context, enabled: Boolean) { + viewModel.setPerAppRefreshRateEnabled(enabled, context) + } + }, object : Feature( id = "Lock screen clock", title = R.string.feat_lock_screen_clock_title, diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/sheets/PerAppRefreshRateSettingsSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/sheets/PerAppRefreshRateSettingsSheet.kt index d5de854c4..6aa4de430 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/components/sheets/PerAppRefreshRateSettingsSheet.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/components/sheets/PerAppRefreshRateSettingsSheet.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button @@ -138,7 +139,8 @@ fun PerAppRefreshRateSettingsSheet( selectedItem = selectedRate, onItemSelected = { selectedRate = it }, labelProvider = { "${it.toInt()} Hz" }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + cornerShape = CornerSize(24.dp) ) } @@ -181,7 +183,8 @@ fun PerAppRefreshRateSettingsSheet( selectedItem = selectedLandscapeRate, onItemSelected = { selectedLandscapeRate = it }, labelProvider = { "${it.toInt()} Hz" }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + cornerShape = CornerSize(18.dp) ) IconToggleItem( iconRes = R.drawable.round_play_arrow_24, @@ -189,7 +192,9 @@ fun PerAppRefreshRateSettingsSheet( description = stringResource(R.string.refresh_rate_per_app_only_media_toggle_desc), isChecked = selectedOnlyOnMediaPlaying, onCheckedChange = { selectedOnlyOnMediaPlaying = it }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(18.dp)) ) } } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/PerAppRefreshRateSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/PerAppRefreshRateSettingsUI.kt new file mode 100644 index 000000000..be78bad84 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/PerAppRefreshRateSettingsUI.kt @@ -0,0 +1,245 @@ +package com.sameerasw.essentials.ui.composables.configs + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +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.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.sameerasw.essentials.R +import com.sameerasw.essentials.domain.model.AppRefreshRateConfig +import com.sameerasw.essentials.ui.components.cards.FeatureCard +import com.sameerasw.essentials.ui.components.containers.RoundedCardContainer +import com.sameerasw.essentials.ui.components.menus.SegmentedDropdownMenuItem +import com.sameerasw.essentials.ui.components.sheets.PerAppRefreshRateSettingsSheet +import com.sameerasw.essentials.ui.components.sheets.SingleAppSelectionSheet +import com.sameerasw.essentials.utils.AppUtil +import com.sameerasw.essentials.viewmodels.MainViewModel + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun PerAppRefreshRateSettingsUI( + viewModel: MainViewModel, + modifier: Modifier = Modifier, + highlightSetting: String? = null +) { + val context = LocalContext.current + var isAppSelectionSheetOpen by remember { mutableStateOf(false) } + var isEditSheetOpen by remember { mutableStateOf(false) } + var editingPackageName by remember { mutableStateOf("") } + var editingCurrentRate by remember { mutableStateOf(0f) } + var editingIsFixed by remember { mutableStateOf(false) } + var editingLandscapeRate by remember { mutableStateOf(null) } + var editingOnlyOnMediaPlaying by remember { mutableStateOf(false) } + + val configs by viewModel.perAppRefreshRateConfigs + + val checkPermissionAndRun: (onGranted: () -> Unit) -> Unit = { onGranted -> + val isUseUsageAccessVal = viewModel.isUseUsageAccess.value + val hasPermission = if (isUseUsageAccessVal) { + viewModel.isUsageStatsPermissionGranted.value + } else { + viewModel.isAccessibilityEnabled.value + } + + if (!hasPermission) { + if (isUseUsageAccessVal) { + com.sameerasw.essentials.utils.PermissionUtils.openUsageStatsSettings(context) + android.widget.Toast.makeText( + context, + context.getString(R.string.refresh_rate_per_app_usage_access_required), + android.widget.Toast.LENGTH_LONG + ).show() + } else { + com.sameerasw.essentials.utils.PermissionUtils.openAccessibilitySettings(context) + android.widget.Toast.makeText( + context, + context.getString(R.string.refresh_rate_per_app_accessibility_required), + android.widget.Toast.LENGTH_LONG + ).show() + } + } else if (!viewModel.isShizukuPermissionGranted.value) { + viewModel.requestShizukuPermission() + android.widget.Toast.makeText( + context, + context.getString(R.string.msg_refresh_rate_permission_required), + android.widget.Toast.LENGTH_LONG + ).show() + } else { + onGranted() + } + } + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + RoundedCardContainer( + modifier = Modifier, + spacing = 2.dp, + cornerRadius = 24.dp + ) { + FeatureCard( + title = stringResource(R.string.refresh_rate_per_app_add_app), + description = stringResource(R.string.refresh_rate_per_app_add_app_desc), + iconRes = R.drawable.rounded_add_24, + isEnabled = true, + showToggle = false, + hasMoreSettings = false, + onToggle = {}, + onClick = { + checkPermissionAndRun { + isAppSelectionSheetOpen = true + } + } + ) + } + + if (configs.isNotEmpty()) { + RoundedCardContainer( + modifier = Modifier, + spacing = 2.dp, + cornerRadius = 24.dp + ) { + configs.forEach { config -> + val appName = remember(config.packageName) { + try { + val appInfo = context.packageManager.getApplicationInfo(config.packageName, 0) + context.packageManager.getApplicationLabel(appInfo).toString() + } catch (e: Exception) { + config.packageName + } + } + + val appIconPainter = remember(config.packageName) { + try { + val drawable = context.packageManager.getApplicationIcon(config.packageName) + androidx.compose.ui.graphics.painter.BitmapPainter( + AppUtil.drawableToBitmap(drawable).asImageBitmap() + ) + } catch (e: Exception) { + null + } + } + + val suffix = if (config.onlyOnMediaPlaying) " (Media Only)" else "" + val cardDesc = if (config.landscapeRefreshRate != null) { + "${config.refreshRate.toInt()} Hz (${if (config.isFixed) stringResource(R.string.refresh_rate_per_app_mode_fixed) else stringResource(R.string.refresh_rate_per_app_mode_dynamic)}) | ${config.landscapeRefreshRate.toInt()} Hz in landscape$suffix" + } else { + "${config.refreshRate.toInt()} Hz (${if (config.isFixed) stringResource(R.string.refresh_rate_per_app_mode_fixed) else stringResource(R.string.refresh_rate_per_app_mode_dynamic)})" + } + + FeatureCard( + title = appName, + description = cardDesc, + isEnabled = config.isEnabled, + showToggle = true, + onToggle = { isChecked -> + if (isChecked) { + checkPermissionAndRun { + viewModel.updatePerAppRefreshRateConfig(config.copy(isEnabled = true)) + val anyEnabled = configs.any { it.packageName != config.packageName && it.isEnabled } || true + viewModel.setPerAppRefreshRateEnabled(anyEnabled, context) + } + } else { + viewModel.updatePerAppRefreshRateConfig(config.copy(isEnabled = false)) + val anyEnabled = configs.any { it.packageName != config.packageName && it.isEnabled } + viewModel.setPerAppRefreshRateEnabled(anyEnabled, context) + } + }, + onClick = { + editingPackageName = config.packageName + editingCurrentRate = config.refreshRate + editingIsFixed = config.isFixed + editingLandscapeRate = config.landscapeRefreshRate + editingOnlyOnMediaPlaying = config.onlyOnMediaPlaying + isEditSheetOpen = true + }, + iconPainter = appIconPainter, + hasMoreSettings = true, + additionalMenuItems = { onDismiss -> + SegmentedDropdownMenuItem( + text = { Text(stringResource(R.string.action_remove)) }, + onClick = { + onDismiss() + viewModel.removePerAppRefreshRateConfig(config.packageName) + val anyEnabled = configs.filter { it.packageName != config.packageName }.any { it.isEnabled } + viewModel.setPerAppRefreshRateEnabled(anyEnabled, context) + }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.rounded_delete_24), + contentDescription = null + ) + } + ) + } + ) + } + } + } + + if (isAppSelectionSheetOpen) { + SingleAppSelectionSheet( + onDismissRequest = { isAppSelectionSheetOpen = false }, + onAppSelected = { app -> + isAppSelectionSheetOpen = false + editingPackageName = app.packageName + editingCurrentRate = 0f + editingIsFixed = false + editingLandscapeRate = null + editingOnlyOnMediaPlaying = false + isEditSheetOpen = true + } + ) + } + + if (isEditSheetOpen) { + PerAppRefreshRateSettingsSheet( + packageName = editingPackageName, + currentRate = editingCurrentRate, + isFixed = editingIsFixed, + landscapeRate = editingLandscapeRate, + onlyOnMediaPlaying = editingOnlyOnMediaPlaying, + onSave = { rate, isFixed, landscapeRate, onlyOnMedia -> + viewModel.updatePerAppRefreshRateConfig( + AppRefreshRateConfig( + packageName = editingPackageName, + refreshRate = rate, + isFixed = isFixed, + landscapeRefreshRate = landscapeRate, + onlyOnMediaPlaying = onlyOnMedia, + isEnabled = true + ) + ) + viewModel.setPerAppRefreshRateEnabled(true, context) + }, + onDelete = { + viewModel.removePerAppRefreshRateConfig(editingPackageName) + val anyEnabled = configs.filter { it.packageName != editingPackageName }.any { it.isEnabled } + viewModel.setPerAppRefreshRateEnabled(anyEnabled, context) + }, + onDismissRequest = { isEditSheetOpen = false } + ) + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/RefreshRateSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/RefreshRateSettingsUI.kt index a13942fad..2f4e73ef2 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/RefreshRateSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/RefreshRateSettingsUI.kt @@ -28,20 +28,6 @@ import com.sameerasw.essentials.utils.HapticUtil import com.sameerasw.essentials.utils.RefreshRateUtils import com.sameerasw.essentials.viewmodels.MainViewModel import kotlin.math.roundToInt -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.res.painterResource -import com.sameerasw.essentials.domain.model.AppRefreshRateConfig -import com.sameerasw.essentials.ui.components.cards.FeatureCard -import com.sameerasw.essentials.ui.components.menus.SegmentedDropdownMenuItem -import com.sameerasw.essentials.ui.components.sheets.PerAppRefreshRateSettingsSheet -import com.sameerasw.essentials.ui.components.sheets.SingleAppSelectionSheet -import com.sameerasw.essentials.utils.AppUtil @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -56,14 +42,6 @@ fun RefreshRateSettingsUI( val isFixedMode = viewModel.refreshRateMode.value == RefreshRateUtils.MODE_FIXED val systemLabel = stringResource(R.string.refresh_rate_system_default) - var isAppSelectionSheetOpen by remember { mutableStateOf(false) } - var isEditSheetOpen by remember { mutableStateOf(false) } - var editingPackageName by remember { mutableStateOf("") } - var editingCurrentRate by remember { mutableStateOf(0f) } - var editingIsFixed by remember { mutableStateOf(false) } - var editingLandscapeRate by remember { mutableStateOf(null) } - var editingOnlyOnMediaPlaying by remember { mutableStateOf(false) } - Column( modifier = modifier .fillMaxWidth() @@ -217,196 +195,6 @@ fun RefreshRateSettingsUI( } } } - - Text( - text = stringResource(R.string.refresh_rate_section_per_app), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(start = 16.dp), - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - val onTogglePerAppRefreshRate: (Boolean) -> Unit = { enabled -> - if (enabled) { - val isUseUsageAccessVal = viewModel.isUseUsageAccess.value - val hasPermission = if (isUseUsageAccessVal) { - viewModel.isUsageStatsPermissionGranted.value - } else { - viewModel.isAccessibilityEnabled.value - } - - if (!hasPermission) { - if (isUseUsageAccessVal) { - com.sameerasw.essentials.utils.PermissionUtils.openUsageStatsSettings(context) - android.widget.Toast.makeText( - context, - context.getString(R.string.refresh_rate_per_app_usage_access_required), - android.widget.Toast.LENGTH_LONG - ).show() - } else { - com.sameerasw.essentials.utils.PermissionUtils.openAccessibilitySettings(context) - android.widget.Toast.makeText( - context, - context.getString(R.string.refresh_rate_per_app_accessibility_required), - android.widget.Toast.LENGTH_LONG - ).show() - } - } else { - viewModel.setPerAppRefreshRateEnabled(true, context) - } - } else { - viewModel.setPerAppRefreshRateEnabled(false, context) - } - } - - RoundedCardContainer( - modifier = Modifier, - spacing = 2.dp, - cornerRadius = 24.dp - ) { - FeatureCard( - title = stringResource(R.string.refresh_rate_per_app_enable_title), - description = stringResource(R.string.refresh_rate_per_app_enable_desc), - iconRes = R.drawable.rounded_shutter_speed_24, - isEnabled = viewModel.isPerAppRefreshRateEnabled.value, - showToggle = true, - hasMoreSettings = false, - onToggle = onTogglePerAppRefreshRate, - onClick = { onTogglePerAppRefreshRate(!viewModel.isPerAppRefreshRateEnabled.value) } - ) - } - - if (viewModel.isPerAppRefreshRateEnabled.value) { - RoundedCardContainer( - modifier = Modifier, - spacing = 2.dp, - cornerRadius = 24.dp - ) { - FeatureCard( - title = stringResource(R.string.refresh_rate_per_app_add_app), - description = stringResource(R.string.refresh_rate_per_app_add_app_desc), - iconRes = R.drawable.rounded_add_24, - isEnabled = true, - showToggle = false, - hasMoreSettings = false, - onToggle = {}, - onClick = { isAppSelectionSheetOpen = true } - ) - } - - val configs by viewModel.perAppRefreshRateConfigs - if (configs.isNotEmpty()) { - RoundedCardContainer( - modifier = Modifier, - spacing = 2.dp, - cornerRadius = 24.dp - ) { - configs.forEach { config -> - val appName = remember(config.packageName) { - try { - val appInfo = context.packageManager.getApplicationInfo(config.packageName, 0) - context.packageManager.getApplicationLabel(appInfo).toString() - } catch (e: Exception) { - config.packageName - } - } - - val appIconPainter = remember(config.packageName) { - try { - val drawable = context.packageManager.getApplicationIcon(config.packageName) - androidx.compose.ui.graphics.painter.BitmapPainter( - AppUtil.drawableToBitmap(drawable).asImageBitmap() - ) - } catch (e: Exception) { - null - } - } - - val suffix = if (config.onlyOnMediaPlaying) " (Media Only)" else "" - val cardDesc = if (config.landscapeRefreshRate != null) { - "${config.refreshRate.toInt()} Hz (${if (config.isFixed) stringResource(R.string.refresh_rate_per_app_mode_fixed) else stringResource(R.string.refresh_rate_per_app_mode_dynamic)}) | ${config.landscapeRefreshRate.toInt()} Hz in landscape$suffix" - } else { - "${config.refreshRate.toInt()} Hz (${if (config.isFixed) stringResource(R.string.refresh_rate_per_app_mode_fixed) else stringResource(R.string.refresh_rate_per_app_mode_dynamic)})" - } - - FeatureCard( - title = appName, - description = cardDesc, - isEnabled = config.isEnabled, - showToggle = true, - onToggle = { isChecked -> - viewModel.updatePerAppRefreshRateConfig(config.copy(isEnabled = isChecked)) - }, - onClick = { - editingPackageName = config.packageName - editingCurrentRate = config.refreshRate - editingIsFixed = config.isFixed - editingLandscapeRate = config.landscapeRefreshRate - editingOnlyOnMediaPlaying = config.onlyOnMediaPlaying - isEditSheetOpen = true - }, - iconPainter = appIconPainter, - hasMoreSettings = true, - additionalMenuItems = { onDismiss -> - SegmentedDropdownMenuItem( - text = { Text(stringResource(R.string.action_remove)) }, - onClick = { - onDismiss() - viewModel.removePerAppRefreshRateConfig(config.packageName) - }, - leadingIcon = { - Icon( - painter = painterResource(id = R.drawable.rounded_delete_24), - contentDescription = null - ) - } - ) - } - ) - } - } - } - } - - if (isAppSelectionSheetOpen) { - SingleAppSelectionSheet( - onDismissRequest = { isAppSelectionSheetOpen = false }, - onAppSelected = { app -> - isAppSelectionSheetOpen = false - editingPackageName = app.packageName - editingCurrentRate = 0f - editingIsFixed = false - editingLandscapeRate = null - editingOnlyOnMediaPlaying = false - isEditSheetOpen = true - } - ) - } - - if (isEditSheetOpen) { - PerAppRefreshRateSettingsSheet( - packageName = editingPackageName, - currentRate = editingCurrentRate, - isFixed = editingIsFixed, - landscapeRate = editingLandscapeRate, - onlyOnMediaPlaying = editingOnlyOnMediaPlaying, - onSave = { rate, isFixed, landscapeRate, onlyOnMedia -> - viewModel.updatePerAppRefreshRateConfig( - AppRefreshRateConfig( - packageName = editingPackageName, - refreshRate = rate, - isFixed = isFixed, - landscapeRefreshRate = landscapeRate, - onlyOnMediaPlaying = onlyOnMedia, - isEnabled = true - ) - ) - }, - onDelete = { - viewModel.removePerAppRefreshRateConfig(editingPackageName) - }, - onDismissRequest = { isEditSheetOpen = false } - ) - } } } diff --git a/app/src/main/res/drawable/ic_per_app_refresh_rate.xml b/app/src/main/res/drawable/ic_per_app_refresh_rate.xml new file mode 100644 index 000000000..70973177b --- /dev/null +++ b/app/src/main/res/drawable/ic_per_app_refresh_rate.xml @@ -0,0 +1,24 @@ + + + + + + \ No newline at end of file From 1c9cd161a86b1670f9b81f1ed35f71056922fd62 Mon Sep 17 00:00:00 2001 From: Mudit200408 Date: Tue, 23 Jun 2026 14:04:43 +0530 Subject: [PATCH 4/4] perfect --- .../com/sameerasw/essentials/services/handlers/AppFlowHandler.kt | 1 - 1 file changed, 1 deletion(-) 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 4297dbf7e..5ca94f92e 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 @@ -13,7 +13,6 @@ import android.os.Handler import android.os.Looper import android.provider.Settings import android.content.res.Configuration -import android.content.IntentFilter import android.os.Build import android.util.Log import androidx.core.app.NotificationCompat