From f095649d261291618243974216dbf4bc03083e8b Mon Sep 17 00:00:00 2001 From: Mudit200408 Date: Sat, 30 May 2026 19:22:16 +0530 Subject: [PATCH 1/2] 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 f134da218..926c6e08b 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 debe3a5f9..da1cf6cd5 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 @@ -238,6 +239,9 @@ class SettingsRepository(private val context: Context) { const val KEY_SHUT_UP_ATTEMPT_SHIZUKU_RESTART = "shut_up_attempt_shizuku_restart" 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" @@ -552,6 +556,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 f554d622f..44c68a70b 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 @@ -77,6 +80,12 @@ class AppFlowHandler( var currentPackage: String? = null private set + // 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() @@ -113,6 +122,7 @@ class AppFlowHandler( checkAppAutomations(packageName) checkGestureBarAutomation(packageName) checkShutUpRestore(oldPackage, packageName) + checkPerAppRefreshRate(packageName) } } @@ -677,6 +687,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 088f7375a..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 d601756c4..785250347 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 @@ -160,6 +161,9 @@ class MainViewModel : ViewModel() { val isShutUpLoading = mutableStateOf(false) val isShutUpAttemptShizukuRestart = mutableStateOf(true) + val isPerAppRefreshRateEnabled = mutableStateOf(false) + val perAppRefreshRateConfigs = mutableStateOf>(emptyList()) + data class CalendarAccount( val id: Long, @@ -612,6 +616,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() @@ -661,6 +674,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() @@ -789,6 +824,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 d94517e5d..d69ee638c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -856,6 +856,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 3e784a29d54a9e8e3a4368c72b189a38c9694189 Mon Sep 17 00:00:00 2001 From: Mudit200408 Date: Mon, 1 Jun 2026 18:08:28 +0530 Subject: [PATCH 2/2] feat: Make sure that Systemui or keyboard does not affect the refresh rate --- .../services/handlers/AppFlowHandler.kt | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) 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 44c68a70b..83321a61e 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 @@ -14,6 +14,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 @@ -101,6 +102,17 @@ class AppFlowHandler( "com.google.android.inputmethod.latin" ) + private fun isSystemOrIme(packageName: String): Boolean { + if (ignoredSystemPackages.contains(packageName)) 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) @@ -213,8 +225,7 @@ class AppFlowHandler( pendingNLRunnable?.let { handler.removeCallbacks(it) } - if (ignoredSystemPackages.contains(packageName)) { - Log.d("NightLight", "Ignoring system package $packageName") + if (ignoredSystemPackages.contains(packageName)) { Log.d("NightLight", "Ignoring system package $packageName") return } @@ -688,7 +699,7 @@ class AppFlowHandler( } private fun checkPerAppRefreshRate(packageName: String) { - if (ignoredSystemPackages.contains(packageName)) { + if (isSystemOrIme(packageName)) { return }