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
-
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..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,13 +14,17 @@ 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
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 +81,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()
@@ -92,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)
@@ -113,6 +134,7 @@ class AppFlowHandler(
checkAppAutomations(packageName)
checkGestureBarAutomation(packageName)
checkShutUpRestore(oldPackage, packageName)
+ checkPerAppRefreshRate(packageName)
}
}
@@ -203,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
}
@@ -677,6 +698,99 @@ class AppFlowHandler(
}
}
+ private fun checkPerAppRefreshRate(packageName: String) {
+ if (isSystemOrIme(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