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
-
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/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..3c56a050a
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/essentials/domain/model/AppRefreshRateConfig.kt
@@ -0,0 +1,10 @@
+package com.sameerasw.essentials.domain.model
+
+data class AppRefreshRateConfig(
+ val packageName: String,
+ val refreshRate: Float,
+ val isFixed: Boolean = false,
+ val isEnabled: Boolean = true,
+ val landscapeRefreshRate: Float? = null,
+ val onlyOnMediaPlaying: Boolean = false
+)
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/services/AppDetectionService.kt b/app/src/main/java/com/sameerasw/essentials/services/AppDetectionService.kt
index 0447434dc..03e62b4a9 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,
@@ -129,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 3bd36d3a9..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
@@ -12,15 +12,21 @@ import android.content.pm.PackageManager
import android.os.Handler
import android.os.Looper
import android.provider.Settings
+import android.content.res.Configuration
+import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
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.services.NotificationListener
import com.sameerasw.essentials.utils.StatusBarManager
+import com.sameerasw.essentials.utils.RefreshRateUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -33,7 +39,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()
@@ -78,6 +129,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 +175,7 @@ class AppFlowHandler(
checkHighlightNightLight(packageName)
checkAppAutomations(packageName)
checkGestureBarAutomation(packageName)
+ checkPerAppRefreshRate(packageName)
}
}
@@ -682,6 +740,129 @@ 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
+ }
+
+ 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
+ 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, targetRate)
+ } else {
+ RefreshRateUtils.applyDynamicRefreshRate(context, targetRate)
+ }
+
+ // 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) {
+ 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, delayedRate)
+ } else {
+ RefreshRateUtils.applyDynamicRefreshRate(context, delayedRate)
+ }
+ }
+ }
+ 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/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
new file mode 100644
index 000000000..6aa4de430
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/essentials/ui/components/sheets/PerAppRefreshRateSettingsSheet.kt
@@ -0,0 +1,248 @@
+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.CornerSize
+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,
+ landscapeRate: Float?,
+ onlyOnMediaPlaying: Boolean,
+ onSave: (Float, Boolean, 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) }
+
+ 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()
+ 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 & Landscape 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(),
+ cornerShape = CornerSize(24.dp)
+ )
+ }
+
+ 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()
+ )
+ }
+
+ 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(),
+ cornerShape = CornerSize(18.dp)
+ )
+ 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()
+ .clip(RoundedCornerShape(18.dp))
+ )
+ }
+ }
+ }
+ }
+ }
+
+ // 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,
+ if (useLandscapeRate) selectedLandscapeRate else null,
+ if (useLandscapeRate) selectedOnlyOnMediaPlaying else false
+ )
+ 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/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/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/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
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 7fe5b43d0..040b77c41 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -860,6 +860,23 @@
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.
+ 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
Monitoring system events for your automations