diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4ab9ebda5..c35666276 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -822,6 +822,20 @@ + + + + + + + - \ No newline at end of file + diff --git a/app/src/main/java/com/sameerasw/essentials/FeatureSettingsActivity.kt b/app/src/main/java/com/sameerasw/essentials/FeatureSettingsActivity.kt index 37c7842c0..3c916ec36 100644 --- a/app/src/main/java/com/sameerasw/essentials/FeatureSettingsActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/FeatureSettingsActivity.kt @@ -69,6 +69,7 @@ import com.sameerasw.essentials.ui.composables.configs.MapsPowerSavingSettingsUI import com.sameerasw.essentials.ui.composables.configs.NotificationLightingSettingsUI 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.ScreenLockedSecuritySettingsUI import com.sameerasw.essentials.ui.composables.configs.ScreenOffWidgetSettingsUI import com.sameerasw.essentials.ui.composables.configs.SnoozeNotificationsSettingsUI @@ -207,6 +208,7 @@ class FeatureSettingsActivity : AppCompatActivity() { val isNotificationLightingAccessibilityEnabled by viewModel.isNotificationLightingAccessibilityEnabled val isNotificationListenerEnabled by viewModel.isNotificationListenerEnabled val isReadPhoneStateEnabled by viewModel.isReadPhoneStateEnabled + val isShizukuPermissionGranted by viewModel.isShizukuPermissionGranted // FAB State for Notification Lighting var fabExpanded by remember { mutableStateOf(true) } @@ -236,7 +238,8 @@ class FeatureSettingsActivity : AppCompatActivity() { isOverlayPermissionGranted, isNotificationLightingAccessibilityEnabled, isNotificationListenerEnabled, - isReadPhoneStateEnabled + isReadPhoneStateEnabled, + isShizukuPermissionGranted ) { val hasMissingPermissions = when (featureId) { "Screen off widget" -> !isAccessibilityEnabled @@ -253,6 +256,7 @@ class FeatureSettingsActivity : AppCompatActivity() { "Location reached" -> !viewModel.isLocationPermissionGranted.value || !viewModel.isBackgroundLocationPermissionGranted.value "Quick settings tiles" -> !viewModel.isWriteSettingsEnabled.value + "Screen refresh rate" -> !viewModel.isShizukuPermissionGranted.value // Top level checks for other features (rarely hit if they are children, but safe to add) "Ambient music glance" -> !isAccessibilityEnabled || !isNotificationListenerEnabled "Call vibrations" -> !isReadPhoneStateEnabled || !isNotificationListenerEnabled @@ -402,6 +406,7 @@ class FeatureSettingsActivity : AppCompatActivity() { "Caffeinate" -> !viewModel.isPostNotificationsEnabled.value "Battery notification" -> !viewModel.isPostNotificationsEnabled.value || (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !viewModel.isBluetoothPermissionGranted.value) "Text and animations" -> !viewModel.isWriteSettingsEnabled.value || !isWriteSecureSettingsEnabled + "Screen refresh rate" -> !viewModel.isShizukuPermissionGranted.value else -> false } @@ -628,6 +633,14 @@ class FeatureSettingsActivity : AppCompatActivity() { ) } + "Screen refresh rate" -> { + RefreshRateSettingsUI( + viewModel = viewModel, + modifier = Modifier.padding(top = 16.dp), + highlightSetting = highlightSetting + ) + } + "Always on Display" -> { AlwaysOnDisplaySettingsUI( viewModel = viewModel, @@ -672,4 +685,4 @@ class FeatureSettingsActivity : AppCompatActivity() { } } } -} \ No newline at end of file +} 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 8d8747c71..d9ca903c5 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 @@ -204,6 +204,11 @@ class SettingsRepository(private val context: Context) { const val KEY_SCALE_ANIMATIONS_MODE = "scale_animations_mode" const val KEY_SCALE_ANIMATIONS_DEFAULT_PROFILE = "scale_animations_default_profile" const val KEY_SCALE_ANIMATIONS_GLOVE_PROFILE = "scale_animations_glove_profile" + const val KEY_REFRESH_RATE_MODE = "refresh_rate_mode" + const val KEY_REFRESH_RATE_FIXED = "refresh_rate_fixed" + const val KEY_REFRESH_RATE_MIN = "refresh_rate_min" + const val KEY_REFRESH_RATE_PEAK = "refresh_rate_peak" + const val KEY_REFRESH_RATE_DEFAULT_PEAK_INFINITY = "refresh_rate_default_peak_infinity" } // Observe changes @@ -908,6 +913,24 @@ class SettingsRepository(private val context: Context) { if (contains(KEY_WINDOW_ANIMATION_SCALE)) { setAnimationScale(android.provider.Settings.Global.WINDOW_ANIMATION_SCALE, getFloat(KEY_WINDOW_ANIMATION_SCALE, 1.0f)) } + if (contains(KEY_REFRESH_RATE_FIXED) || contains(KEY_REFRESH_RATE_MIN) || contains(KEY_REFRESH_RATE_PEAK)) { + val mode = getRefreshRateMode() + val fixed = getFloat(KEY_REFRESH_RATE_FIXED, 0f) + val min = getFloat(KEY_REFRESH_RATE_MIN, 0f) + val peak = getFloat(KEY_REFRESH_RATE_PEAK, 0f) + + if (fixed <= 0f && min <= 0f && peak <= 0f) { + com.sameerasw.essentials.utils.RefreshRateUtils.resetRefreshRate( + shouldRestoreInfinityPeakOnRefreshRateReset() + ) + } else if (mode == com.sameerasw.essentials.utils.RefreshRateUtils.MODE_RANGE && min > 0f && peak > 0f) { + com.sameerasw.essentials.utils.RefreshRateUtils.applyRangeRefreshRate(min, peak) + } else if (fixed > 0f || peak > 0f) { + com.sameerasw.essentials.utils.RefreshRateUtils.applyFixedRefreshRate( + if (fixed > 0f) fixed else peak + ) + } + } } catch (e: Exception) { e.printStackTrace() } @@ -968,6 +991,25 @@ class SettingsRepository(private val context: Context) { fun getScaleAnimationsMode(): String = getString(KEY_SCALE_ANIMATIONS_MODE, "default") ?: "default" fun setScaleAnimationsMode(mode: String) = putString(KEY_SCALE_ANIMATIONS_MODE, mode) + fun getRefreshRateMode(): String = + getString(KEY_REFRESH_RATE_MODE, com.sameerasw.essentials.utils.RefreshRateUtils.MODE_FIXED) + ?: com.sameerasw.essentials.utils.RefreshRateUtils.MODE_FIXED + + fun setRefreshRateMode(mode: String) = putString(KEY_REFRESH_RATE_MODE, mode) + + fun saveRefreshRateState(mode: String, fixed: Float, min: Float, peak: Float) { + putString(KEY_REFRESH_RATE_MODE, mode) + putFloat(KEY_REFRESH_RATE_FIXED, fixed) + putFloat(KEY_REFRESH_RATE_MIN, min) + putFloat(KEY_REFRESH_RATE_PEAK, peak) + } + + fun shouldRestoreInfinityPeakOnRefreshRateReset(): Boolean = + getBoolean(KEY_REFRESH_RATE_DEFAULT_PEAK_INFINITY, false) + + fun setRestoreInfinityPeakOnRefreshRateReset(enabled: Boolean) = + putBoolean(KEY_REFRESH_RATE_DEFAULT_PEAK_INFINITY, enabled) + fun getScaleAnimationsProfile(mode: String): ScaleAnimationsProfile { val key = if (mode == "glove") KEY_SCALE_ANIMATIONS_GLOVE_PROFILE else KEY_SCALE_ANIMATIONS_DEFAULT_PROFILE val json = prefs.getString(key, null) 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 71412336b..48245d0cf 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 @@ -181,6 +181,43 @@ object FeatureRegistry { override fun isEnabled(viewModel: MainViewModel) = true override fun onToggle(viewModel: MainViewModel, context: Context, enabled: Boolean) {} }, + object : Feature( + id = "Screen refresh rate", + title = R.string.feat_screen_refresh_rate_title, + iconRes = R.drawable.rounded_shutter_speed_24, + category = R.string.cat_interface, + description = R.string.feat_screen_refresh_rate_desc, + aboutDescription = R.string.about_desc_screen_refresh_rate, + permissionKeys = listOf("SHIZUKU"), + searchableSettings = listOf( + SearchSetting( + R.string.search_refresh_rate_mode_title, + R.string.search_refresh_rate_mode_desc, + "refresh_rate_mode" + ), + SearchSetting( + R.string.search_refresh_rate_fixed_title, + R.string.search_refresh_rate_fixed_desc, + "refresh_rate_fixed" + ), + SearchSetting( + R.string.search_refresh_rate_range_title, + R.string.search_refresh_rate_range_desc, + "refresh_rate_range" + ), + SearchSetting( + R.string.search_refresh_rate_reset_title, + R.string.search_refresh_rate_reset_desc, + "refresh_rate_reset", + R.array.keywords_restore_default + ) + ), + showToggle = false, + parentFeatureId = "Display" + ) { + override fun isEnabled(viewModel: MainViewModel) = true + override fun onToggle(viewModel: MainViewModel, context: Context, enabled: Boolean) {} + }, object : Feature( id = "Watch", title = R.string.feat_watch_title, @@ -601,6 +638,13 @@ object FeatureRegistry { "USB Debugging", R.array.keywords_adb_debug, R.string.feat_qs_tiles_title + ), + SearchSetting( + R.string.search_qs_refresh_rate_title, + R.string.search_qs_refresh_rate_desc, + "Refresh Rate", + R.array.keywords_visual_style, + R.string.feat_qs_tiles_title ) ) ) { @@ -1065,4 +1109,4 @@ object FeatureRegistry { override fun onToggle(viewModel: MainViewModel, context: Context, enabled: Boolean) {} } ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt b/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt index c8681502e..271c4bfb7 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt @@ -34,6 +34,8 @@ fun initPermissionRegistry() { PermissionRegistry.register("SHIZUKU", R.string.feat_freeze_title) PermissionRegistry.register("SHIZUKU", R.string.feat_maps_power_saving_title) PermissionRegistry.register("SHIZUKU", R.string.feat_screen_locked_security_title) + PermissionRegistry.register("SHIZUKU", R.string.feat_screen_refresh_rate_title) + PermissionRegistry.register("SHIZUKU", R.string.tile_refresh_rate) PermissionRegistry.register("USAGE_STATS", R.string.feat_freeze_title) PermissionRegistry.register("USAGE_STATS", R.string.feat_app_lock_title) PermissionRegistry.register("USAGE_STATS", R.string.feat_dynamic_night_light_title) @@ -89,4 +91,4 @@ fun initPermissionRegistry() { // Default browser permission PermissionRegistry.register("DEFAULT_BROWSER", R.string.feat_link_actions_title) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/sameerasw/essentials/services/tiles/RefreshRateTileService.kt b/app/src/main/java/com/sameerasw/essentials/services/tiles/RefreshRateTileService.kt new file mode 100644 index 000000000..2db8aca4e --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/services/tiles/RefreshRateTileService.kt @@ -0,0 +1,69 @@ +package com.sameerasw.essentials.services.tiles + +import android.content.Intent +import android.graphics.drawable.Icon +import android.os.Build +import android.service.quicksettings.Tile +import androidx.annotation.RequiresApi +import com.sameerasw.essentials.FeatureSettingsActivity +import com.sameerasw.essentials.R +import com.sameerasw.essentials.data.repository.SettingsRepository +import com.sameerasw.essentials.utils.RefreshRateUtils +import com.sameerasw.essentials.utils.ShizukuUtils + +@RequiresApi(Build.VERSION_CODES.N) +class RefreshRateTileService : BaseTileService() { + + override fun onClick() { + if (!hasFeaturePermission()) { + val intent = Intent(this, FeatureSettingsActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + putExtra("feature", "Quick settings tiles") + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val pendingIntent = android.app.PendingIntent.getActivity( + this, + 0, + intent, + android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE + ) + startActivityAndCollapse(pendingIntent) + } else { + @Suppress("DEPRECATION") + startActivityAndCollapse(intent) + } + return + } + super.onClick() + } + + override fun onTileClick() { + val nextPreset = RefreshRateUtils.getNextPreset(this) + if (nextPreset <= 0) { + val settingsRepository = SettingsRepository(this) + RefreshRateUtils.resetRefreshRate( + settingsRepository.shouldRestoreInfinityPeakOnRefreshRateReset() + ) + } else { + RefreshRateUtils.applyFixedRefreshRate(nextPreset.toFloat()) + } + } + + override fun getTileLabel(): String = getString(R.string.tile_refresh_rate) + + override fun getTileSubtitle(): String = RefreshRateUtils.getDisplaySubtitle(this) + + override fun hasFeaturePermission(): Boolean = ShizukuUtils.hasPermission() + + override fun getTileIcon(): Icon { + return Icon.createWithResource(this, R.drawable.rounded_shutter_speed_24) + } + + override fun getTileState(): Int { + return if (RefreshRateUtils.hasCustomRefreshRate(this)) { + Tile.STATE_ACTIVE + } else { + Tile.STATE_INACTIVE + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/activities/QSPreferencesActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/QSPreferencesActivity.kt index 5747fb51d..5c08bfd32 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/activities/QSPreferencesActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/QSPreferencesActivity.kt @@ -80,6 +80,7 @@ class QSPreferencesActivity : ComponentActivity() { "com.sameerasw.essentials.services.tiles.StayAwakeTileService" -> "Quick settings tiles" "com.sameerasw.essentials.services.tiles.NfcTileService" -> "NFC" "com.sameerasw.essentials.services.tiles.AdaptiveBrightnessTileService" -> "Quick settings tiles" + "com.sameerasw.essentials.services.tiles.RefreshRateTileService" -> "Screen refresh rate" "com.sameerasw.essentials.services.tiles.MapsPowerSavingTileService" -> "Maps power saving mode" "com.sameerasw.essentials.services.tiles.UsbDebuggingTileService" -> "Quick settings tiles" "com.sameerasw.essentials.services.tiles.BatteryNotificationTileService" -> "Battery notification" diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/QuickSettingsTilesSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/QuickSettingsTilesSettingsUI.kt index dd63250e2..e8e9a11c9 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/QuickSettingsTilesSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/QuickSettingsTilesSettingsUI.kt @@ -64,6 +64,7 @@ import com.sameerasw.essentials.services.tiles.NfcTileService import com.sameerasw.essentials.services.tiles.NotificationLightingTileService import com.sameerasw.essentials.services.tiles.PrivateDnsTileService import com.sameerasw.essentials.services.tiles.PrivateNotificationsTileService +import com.sameerasw.essentials.services.tiles.RefreshRateTileService import com.sameerasw.essentials.services.tiles.ScreenLockedSecurityTileService import com.sameerasw.essentials.services.tiles.ScaleAnimationsTileService import com.sameerasw.essentials.services.tiles.SoundModeTileService @@ -272,6 +273,14 @@ fun QuickSettingsTilesSettingsUI( R.string.about_desc_scale_animations_tile, R.string.cat_visuals ), + QSTileInfo( + R.string.tile_refresh_rate, + R.drawable.rounded_shutter_speed_24, + RefreshRateTileService::class.java, + listOf("SHIZUKU"), + R.string.about_desc_refresh_rate_tile, + R.string.cat_visuals + ), QSTileInfo( R.string.feat_maps_power_saving_title, R.drawable.rounded_navigation_24, 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 new file mode 100644 index 000000000..27baf8a52 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/RefreshRateSettingsUI.kt @@ -0,0 +1,203 @@ +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.sameerasw.essentials.R +import com.sameerasw.essentials.ui.components.containers.RoundedCardContainer +import com.sameerasw.essentials.ui.components.pickers.SegmentedPicker +import com.sameerasw.essentials.ui.components.sliders.ConfigSliderItem +import com.sameerasw.essentials.utils.HapticUtil +import com.sameerasw.essentials.utils.RefreshRateUtils +import com.sameerasw.essentials.viewmodels.MainViewModel +import kotlin.math.roundToInt + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun RefreshRateSettingsUI( + viewModel: MainViewModel, + modifier: Modifier = Modifier, + highlightSetting: String? = null +) { + val context = LocalContext.current + val view = LocalView.current + val isEnabled = viewModel.isShizukuPermissionGranted.value + val isFixedMode = viewModel.refreshRateMode.value == RefreshRateUtils.MODE_FIXED + val systemLabel = stringResource(R.string.refresh_rate_system_default) + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = stringResource(R.string.refresh_rate_section_mode), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 16.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + RoundedCardContainer(spacing = 2.dp) { + SegmentedPicker( + items = listOf(RefreshRateUtils.MODE_FIXED, RefreshRateUtils.MODE_RANGE), + selectedItem = viewModel.refreshRateMode.value, + onItemSelected = { viewModel.setRefreshRateMode(it) }, + labelProvider = { + when (it) { + RefreshRateUtils.MODE_RANGE -> context.getString(R.string.refresh_rate_mode_range) + else -> context.getString(R.string.refresh_rate_mode_fixed) + } + }, + modifier = Modifier.fillMaxWidth() + ) + } + + Text( + text = stringResource(R.string.refresh_rate_section_values), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 16.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + RoundedCardContainer(spacing = 2.dp) { + if (isFixedMode) { + ConfigSliderItem( + title = stringResource(R.string.refresh_rate_fixed_title), + description = stringResource(R.string.refresh_rate_fixed_desc), + value = viewModel.fixedRefreshRate.floatValue, + onValueChange = { + viewModel.updateFixedRefreshRate(it.roundToInt().toFloat()) + HapticUtil.performSliderHaptic(view) + }, + onValueChangeFinished = { + viewModel.applyFixedRefreshRate(context) + }, + valueRange = 0f..120f, + steps = 11, + increment = 10f, + valueFormatter = { formatRefreshRateLabel(it, systemLabel) }, + icon = R.drawable.rounded_shutter_speed_24, + enabled = isEnabled + ) + } else { + ConfigSliderItem( + title = stringResource(R.string.refresh_rate_min_title), + description = stringResource(R.string.refresh_rate_min_desc), + value = viewModel.minRefreshRate.floatValue, + onValueChange = { + viewModel.updateMinRefreshRate(it.roundToInt().toFloat()) + HapticUtil.performSliderHaptic(view) + }, + onValueChangeFinished = { + viewModel.applyRefreshRateRange(context) + }, + valueRange = 0f..120f, + steps = 11, + increment = 10f, + valueFormatter = { formatRefreshRateLabel(it, systemLabel) }, + icon = R.drawable.rounded_keyboard_arrow_down_24, + enabled = isEnabled + ) + + ConfigSliderItem( + title = stringResource(R.string.refresh_rate_peak_title), + description = stringResource(R.string.refresh_rate_peak_desc), + value = viewModel.peakRefreshRate.floatValue, + onValueChange = { + viewModel.updatePeakRefreshRate(it.roundToInt().toFloat()) + HapticUtil.performSliderHaptic(view) + }, + onValueChangeFinished = { + viewModel.applyRefreshRateRange(context) + }, + valueRange = 0f..120f, + steps = 11, + increment = 10f, + valueFormatter = { formatRefreshRateLabel(it, systemLabel) }, + icon = R.drawable.rounded_keyboard_arrow_up_24, + enabled = isEnabled + ) + } + + Row( + modifier = Modifier + .background( + color = MaterialTheme.colorScheme.surfaceBright, + shape = RoundedCornerShape(MaterialTheme.shapes.extraSmall.bottomEnd) + ) + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = if (isEnabled) Arrangement.SpaceBetween else Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + if (isEnabled) { + Text( + text = stringResource(R.string.refresh_rate_reset_desc), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f).padding(end = 8.dp) + ) + Button( + onClick = { + viewModel.resetRefreshRate(context) + HapticUtil.performSliderHaptic(view) + }, + colors = ButtonDefaults.filledTonalButtonColors(), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp) + ) { + Text( + text = stringResource(R.string.label_reset_default), + style = MaterialTheme.typography.labelSmall + ) + } + } else { + Text( + text = stringResource(R.string.msg_refresh_rate_permission_required), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.weight(1f).padding(end = 8.dp) + ) + Button( + onClick = { + viewModel.requestShizukuPermission() + HapticUtil.performSliderHaptic(view) + }, + colors = ButtonDefaults.filledTonalButtonColors(), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp) + ) { + Text( + text = stringResource(R.string.label_grant_permission), + style = MaterialTheme.typography.labelSmall + ) + } + } + } + } + } +} + +private fun formatRefreshRateLabel(value: Float, systemLabel: String): String { + return if (value <= 0f) { + systemLabel + } else { + "${value.roundToInt()} Hz" + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/utils/RefreshRateUtils.kt b/app/src/main/java/com/sameerasw/essentials/utils/RefreshRateUtils.kt new file mode 100644 index 000000000..3e0206126 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/utils/RefreshRateUtils.kt @@ -0,0 +1,207 @@ +package com.sameerasw.essentials.utils + +import android.content.Context +import android.hardware.display.DisplayManager +import android.provider.Settings +import android.view.Display +import com.sameerasw.essentials.R +import java.util.Locale +import kotlin.math.roundToInt + +object RefreshRateUtils { + const val MODE_FIXED = "fixed" + const val MODE_RANGE = "range" + + private const val KEY_PEAK_REFRESH_RATE = "peak_refresh_rate" + private const val KEY_MIN_REFRESH_RATE = "min_refresh_rate" + private const val DEFAULT_SYSTEM_REFRESH_RATE = 60f + + val PRESET_RATES = listOf(10, 30, 60, 90, 120) + + data class RefreshRateState( + val min: Float, + val peak: Float, + val isSystemManaged: Boolean, + val usesInfinityDefaultPeak: Boolean + ) + + fun getPeakRefreshRate(context: Context): Float { + return getCurrentState(context).peak + } + + fun getMinRefreshRate(context: Context): Float { + return getCurrentState(context).min + } + + fun hasCustomRefreshRate(context: Context): Boolean { + val state = getCurrentState(context) + return !state.isSystemManaged && (state.peak > 0f || state.min > 0f) + } + + fun getDisplayValue(context: Context): Float { + val state = getCurrentState(context) + if (state.isSystemManaged) return 0f + + val min = state.min + val peak = state.peak + return when { + peak > 0f -> peak + min > 0f -> min + else -> 0f + } + } + + fun getDisplaySubtitle(context: Context): String { + val state = getCurrentState(context) + if (state.isSystemManaged) { + return context.getString(R.string.refresh_rate_system_default) + } + + val min = state.min + val peak = state.peak + return when { + min > 0f && peak > 0f && min.roundToInt() != peak.roundToInt() -> + "${min.roundToInt()}-${peak.roundToInt()} Hz" + else -> "${getDisplayValue(context).roundToInt()} Hz" + } + } + + fun applyFixedRefreshRate(value: Float): Boolean { + if (!ShizukuUtils.hasPermission()) return false + + val clamped = normalizeRate(value) + val formatted = formatRate(clamped) + ShizukuUtils.runCommand("settings put system $KEY_PEAK_REFRESH_RATE $formatted") + ShizukuUtils.runCommand("settings put system $KEY_MIN_REFRESH_RATE $formatted") + return true + } + + fun applyRangeRefreshRate(minValue: Float, peakValue: Float): Boolean { + if (!ShizukuUtils.hasPermission()) return false + + val safeMin = normalizeRate(minValue) + val safePeak = normalizeRate(maxOf(minValue, peakValue)) + ShizukuUtils.runCommand("settings put system $KEY_MIN_REFRESH_RATE ${formatRate(safeMin)}") + ShizukuUtils.runCommand("settings put system $KEY_PEAK_REFRESH_RATE ${formatRate(safePeak)}") + return true + } + + fun resetRefreshRate(restoreInfinityPeak: Boolean = false): Boolean { + if (!ShizukuUtils.hasPermission()) return false + + // Clear both namespaces first, then restore the original system-managed peak behavior. + ShizukuUtils.runCommand("settings delete system $KEY_MIN_REFRESH_RATE") + ShizukuUtils.runCommand("settings delete system $KEY_PEAK_REFRESH_RATE") + ShizukuUtils.runCommand("settings delete global $KEY_PEAK_REFRESH_RATE") + ShizukuUtils.runCommand("settings delete global $KEY_MIN_REFRESH_RATE") + if (restoreInfinityPeak) { + ShizukuUtils.runCommand("settings put system $KEY_PEAK_REFRESH_RATE Infinity") + } + return true + } + + fun getNextPreset(context: Context): Int { + val currentValue = getDisplayValue(context).roundToInt() + val currentIndex = PRESET_RATES.indexOf(currentValue) + return when { + currentIndex != -1 -> PRESET_RATES.getOrElse(currentIndex + 1) { 0 } + currentValue <= 0 -> PRESET_RATES.first() + else -> PRESET_RATES.firstOrNull { it > currentValue } ?: 0 + } + } + + fun normalizeRate(value: Float): Float { + val rounded = value.roundToInt() + return rounded.coerceIn(10, 120).toFloat() + } + + fun getCurrentState(context: Context): RefreshRateState { + val maxRefreshRate = getHighestSupportedRefreshRate(context) + val rawMin = getSystemString(context, KEY_MIN_REFRESH_RATE) + val rawPeak = getSystemString(context, KEY_PEAK_REFRESH_RATE) + + val min = parseRefreshRate(rawMin, maxRefreshRate) + val peak = parseRefreshRate(rawPeak, maxRefreshRate) + val isSystemManaged = isSystemManagedState(rawMin, min, rawPeak, peak) + + return if (isSystemManaged) { + RefreshRateState( + min = 0f, + peak = 0f, + isSystemManaged = true, + usesInfinityDefaultPeak = isInfinityValue(rawPeak) + ) + } else { + RefreshRateState( + min = min, + peak = peak, + isSystemManaged = false, + usesInfinityDefaultPeak = false + ) + } + } + + private fun getSystemString(context: Context, key: String): String? { + return try { + Settings.System.getString(context.contentResolver, key) + } catch (_: Exception) { + null + } + } + + private fun parseRefreshRate(rawValue: String?, fallbackForInfinity: Float): Float { + val trimmed = rawValue?.trim().orEmpty() + if (trimmed.isEmpty()) return 0f + if (trimmed.equals("Infinity", ignoreCase = true)) return fallbackForInfinity + + val parsed = trimmed.toFloatOrNull() ?: return 0f + return when { + !parsed.isFinite() -> fallbackForInfinity + parsed <= 0f -> 0f + else -> parsed + } + } + + private fun isSystemManagedState( + rawMin: String?, + min: Float, + rawPeak: String?, + peak: Float + ): Boolean { + val isMinUnset = min <= 0f && isUnsetValue(rawMin) + if (!isMinUnset) return false + + return isUnsetValue(rawPeak) || + isInfinityValue(rawPeak) || + peak <= 0f || + peak.roundToInt() == DEFAULT_SYSTEM_REFRESH_RATE.roundToInt() + } + + private fun isUnsetValue(rawValue: String?): Boolean { + val trimmed = rawValue?.trim().orEmpty() + return trimmed.isEmpty() || trimmed == "0" || trimmed == "0.0" + } + + private fun isInfinityValue(rawValue: String?): Boolean { + val trimmed = rawValue?.trim().orEmpty() + return trimmed.equals("Infinity", ignoreCase = true) || + trimmed.equals("inf", ignoreCase = true) + } + + private fun getHighestSupportedRefreshRate(context: Context): Float { + return try { + val displayManager = context.getSystemService(DisplayManager::class.java) + val display = displayManager?.getDisplay(Display.DEFAULT_DISPLAY) + display?.supportedModes + ?.maxOfOrNull { it.refreshRate } + ?.takeIf { it.isFinite() && it > 0f } + ?: DEFAULT_SYSTEM_REFRESH_RATE + } catch (_: Exception) { + DEFAULT_SYSTEM_REFRESH_RATE + } + } + + private fun formatRate(value: Float): String { + return String.format(Locale.US, "%.0f", value) + } +} 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 2dcda07da..8be07607d 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -56,6 +56,7 @@ import com.sameerasw.essentials.services.tiles.ScreenOffAccessibilityService import com.sameerasw.essentials.utils.AppUtil import com.sameerasw.essentials.utils.DeviceUtils import com.sameerasw.essentials.utils.PermissionUtils +import com.sameerasw.essentials.utils.RefreshRateUtils import com.sameerasw.essentials.utils.RootUtils import com.sameerasw.essentials.utils.ShellUtils import com.sameerasw.essentials.utils.ShizukuUtils @@ -239,6 +240,10 @@ class MainViewModel : ViewModel() { val isTouchSensitivityEnabled = mutableStateOf(false) val isAutoRotateEnabled = mutableStateOf(false) val screenTimeout = mutableStateOf(30000L) + val refreshRateMode = mutableStateOf(RefreshRateUtils.MODE_FIXED) + val fixedRefreshRate = mutableFloatStateOf(0f) + val minRefreshRate = mutableFloatStateOf(0f) + val peakRefreshRate = mutableFloatStateOf(0f) val fontScale = mutableFloatStateOf(1.0f) val fontWeight = mutableIntStateOf(0) val animatorDurationScale = mutableFloatStateOf(1.0f) @@ -284,6 +289,10 @@ class MainViewModel : ViewModel() { Settings.Secure.getUriFor("sysui_qs_tiles") -> { appContext?.let { updateAddedQSTiles(it) } } + Settings.System.getUriFor("peak_refresh_rate"), + Settings.System.getUriFor("min_refresh_rate") -> { + appContext?.let { syncRefreshRateState(it) } + } } } } @@ -467,6 +476,12 @@ class MainViewModel : ViewModel() { SettingsRepository.KEY_TRANSITION_ANIMATION_SCALE -> transitionAnimationScale.floatValue = settingsRepository.getAnimationScale(android.provider.Settings.Global.TRANSITION_ANIMATION_SCALE) SettingsRepository.KEY_WINDOW_ANIMATION_SCALE -> windowAnimationScale.floatValue = settingsRepository.getAnimationScale(android.provider.Settings.Global.WINDOW_ANIMATION_SCALE) SettingsRepository.KEY_SMALLEST_WIDTH -> smallestWidth.intValue = settingsRepository.getSmallestWidth() + SettingsRepository.KEY_REFRESH_RATE_MODE -> refreshRateMode.value = settingsRepository.getRefreshRateMode() + SettingsRepository.KEY_REFRESH_RATE_FIXED, + SettingsRepository.KEY_REFRESH_RATE_MIN, + SettingsRepository.KEY_REFRESH_RATE_PEAK -> { + appContext?.let { syncRefreshRateState(it) } + } SettingsRepository.KEY_NOTIFICATION_GLANCE_ENABLED -> isNotificationGlanceEnabled.value = settingsRepository.getBoolean(key) SettingsRepository.KEY_AOD_FORCE_TURN_OFF_ENABLED -> isAodForceTurnOffEnabled.value = settingsRepository.getBoolean(key) SettingsRepository.KEY_NOTIFICATION_GLANCE_SAME_AS_LIGHTING -> isNotificationGlanceSameAsLightingEnabled.value = settingsRepository.getBoolean(key, true) @@ -631,6 +646,16 @@ class MainViewModel : ViewModel() { false, contentObserver ) + context.contentResolver.registerContentObserver( + Settings.System.getUriFor("peak_refresh_rate"), + false, + contentObserver + ) + context.contentResolver.registerContentObserver( + Settings.System.getUriFor("min_refresh_rate"), + false, + contentObserver + ) try { context.contentResolver.registerContentObserver( @@ -692,6 +717,8 @@ class MainViewModel : ViewModel() { transitionAnimationScale.floatValue = settingsRepository.getAnimationScale(android.provider.Settings.Global.TRANSITION_ANIMATION_SCALE) windowAnimationScale.floatValue = settingsRepository.getAnimationScale(android.provider.Settings.Global.WINDOW_ANIMATION_SCALE) smallestWidth.intValue = settingsRepository.getSmallestWidth() + refreshRateMode.value = settingsRepository.getRefreshRateMode() + syncRefreshRateState(context) hasShizukuPermission.value = ShizukuUtils.hasPermission() || RootUtils.isRootAvailable() isMapsPowerSavingEnabled.value = @@ -1488,6 +1515,187 @@ class MainViewModel : ViewModel() { settingsRepository.setScreenTimeout(timeoutMs) } + fun setRefreshRateMode(mode: String) { + refreshRateMode.value = mode + if (mode == RefreshRateUtils.MODE_RANGE) { + if (minRefreshRate.floatValue <= 0f && peakRefreshRate.floatValue <= 0f) { + val seedValue = when { + fixedRefreshRate.floatValue > 0f -> RefreshRateUtils.normalizeRate(fixedRefreshRate.floatValue) + else -> 60f + } + minRefreshRate.floatValue = seedValue + peakRefreshRate.floatValue = seedValue + } + } else if (fixedRefreshRate.floatValue <= 0f) { + fixedRefreshRate.floatValue = when { + peakRefreshRate.floatValue > 0f -> RefreshRateUtils.normalizeRate(peakRefreshRate.floatValue) + minRefreshRate.floatValue > 0f -> RefreshRateUtils.normalizeRate(minRefreshRate.floatValue) + else -> 60f + } + } + settingsRepository.setRefreshRateMode(mode) + } + + fun updateFixedRefreshRate(value: Float) { + fixedRefreshRate.floatValue = value + } + + fun updateMinRefreshRate(value: Float) { + val safeMin = value.coerceAtMost(peakRefreshRate.floatValue.takeIf { it > 0f } ?: value) + minRefreshRate.floatValue = safeMin + if (peakRefreshRate.floatValue > 0f && peakRefreshRate.floatValue < safeMin) { + peakRefreshRate.floatValue = safeMin + } + } + + fun updatePeakRefreshRate(value: Float) { + val safePeak = value.coerceAtLeast(minRefreshRate.floatValue.takeIf { it > 0f } ?: value) + peakRefreshRate.floatValue = safePeak + if (minRefreshRate.floatValue > safePeak) { + minRefreshRate.floatValue = safePeak + } + } + + fun applyFixedRefreshRate(context: Context) { + val value = fixedRefreshRate.floatValue + if (value <= 0f) { + resetRefreshRate(context) + return + } + + if (RefreshRateUtils.applyFixedRefreshRate(value)) { + val normalized = RefreshRateUtils.normalizeRate(value) + fixedRefreshRate.floatValue = normalized + minRefreshRate.floatValue = normalized + peakRefreshRate.floatValue = normalized + refreshRateMode.value = RefreshRateUtils.MODE_FIXED + persistRefreshRateStateIfNeeded( + mode = RefreshRateUtils.MODE_FIXED, + fixed = normalized, + min = normalized, + peak = normalized + ) + } else { + syncRefreshRateState(context) + } + } + + fun applyRefreshRateRange(context: Context) { + val minValue = minRefreshRate.floatValue + val peakValue = peakRefreshRate.floatValue + if (minValue <= 0f || peakValue <= 0f) { + persistRefreshRateStateIfNeeded( + mode = RefreshRateUtils.MODE_RANGE, + fixed = fixedRefreshRate.floatValue, + min = minValue, + peak = peakValue + ) + return + } + + if (RefreshRateUtils.applyRangeRefreshRate(minValue, peakValue)) { + val normalizedMin = RefreshRateUtils.normalizeRate(minValue) + val normalizedPeak = RefreshRateUtils.normalizeRate(maxOf(minValue, peakValue)) + minRefreshRate.floatValue = normalizedMin + peakRefreshRate.floatValue = normalizedPeak + fixedRefreshRate.floatValue = normalizedPeak + refreshRateMode.value = RefreshRateUtils.MODE_RANGE + persistRefreshRateStateIfNeeded( + mode = RefreshRateUtils.MODE_RANGE, + fixed = normalizedPeak, + min = normalizedMin, + peak = normalizedPeak + ) + } else { + syncRefreshRateState(context) + } + } + + fun resetRefreshRate(context: Context) { + val restoreInfinityPeak = settingsRepository.shouldRestoreInfinityPeakOnRefreshRateReset() + if (RefreshRateUtils.resetRefreshRate(restoreInfinityPeak)) { + fixedRefreshRate.floatValue = 0f + minRefreshRate.floatValue = 0f + peakRefreshRate.floatValue = 0f + persistRefreshRateStateIfNeeded( + mode = refreshRateMode.value, + fixed = 0f, + min = 0f, + peak = 0f + ) + } else { + syncRefreshRateState(context) + } + } + + private fun syncRefreshRateState(context: Context) { + val refreshRateState = RefreshRateUtils.getCurrentState(context) + if (refreshRateState.isSystemManaged) { + settingsRepository.setRestoreInfinityPeakOnRefreshRateReset( + refreshRateState.usesInfinityDefaultPeak + ) + } + val actualMin = refreshRateState.min + val actualPeak = refreshRateState.peak + val hasCustom = !refreshRateState.isSystemManaged && (actualMin > 0f || actualPeak > 0f) + val storedMode = settingsRepository.getRefreshRateMode() + + if (!hasCustom) { + fixedRefreshRate.floatValue = 0f + minRefreshRate.floatValue = 0f + peakRefreshRate.floatValue = 0f + persistRefreshRateStateIfNeeded( + mode = storedMode, + fixed = 0f, + min = 0f, + peak = 0f + ) + return + } + + val resolvedMin = if (actualMin > 0f) actualMin else actualPeak + val resolvedPeak = if (actualPeak > 0f) actualPeak else actualMin + val resolvedMode = + if (resolvedMin > 0f && resolvedPeak > 0f && resolvedMin != resolvedPeak) { + RefreshRateUtils.MODE_RANGE + } else { + storedMode + } + + refreshRateMode.value = resolvedMode + fixedRefreshRate.floatValue = resolvedPeak + minRefreshRate.floatValue = resolvedMin + peakRefreshRate.floatValue = resolvedPeak + persistRefreshRateStateIfNeeded( + mode = resolvedMode, + fixed = resolvedPeak, + min = resolvedMin, + peak = resolvedPeak + ) + } + + private fun persistRefreshRateStateIfNeeded(mode: String, fixed: Float, min: Float, peak: Float) { + val storedMode = settingsRepository.getRefreshRateMode() + val storedFixed = settingsRepository.getFloat(SettingsRepository.KEY_REFRESH_RATE_FIXED, 0f) + val storedMin = settingsRepository.getFloat(SettingsRepository.KEY_REFRESH_RATE_MIN, 0f) + val storedPeak = settingsRepository.getFloat(SettingsRepository.KEY_REFRESH_RATE_PEAK, 0f) + + if (storedMode == mode && + storedFixed == fixed && + storedMin == min && + storedPeak == peak + ) { + return + } + + settingsRepository.saveRefreshRateState( + mode = mode, + fixed = fixed, + min = min, + peak = peak + ) + } + fun updateFontScale(scale: Float) { fontScale.floatValue = scale } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4e6d1de7e..7497ff061 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -229,6 +229,8 @@ Inactive Developer Options Toggle system Developer Options from a QS tile easily. This may reset some of the developer settings you have modified. + Refresh Rate + Cycle between the system default and preset refresh rates directly from a Quick Settings tile using Shizuku. NFC Private DNS Auto @@ -537,6 +539,8 @@ Cycle Private DNS modes (Off/Auto/Hostname) USB Debugging Toggle USB Debugging developer option + Refresh Rate + Cycle between the system default and preset screen refresh rates Enable Button Remap Master toggle for volume button remapping Remap Haptic Feedback @@ -1342,6 +1346,8 @@ Apps Scale and Animations Adjust system scale and animations + Screen Refresh Rate + Set a fixed or ranged screen refresh rate Text Font Scale Font Weight @@ -1355,6 +1361,28 @@ Transition animation scale Window animation scale Adjust system-wide font scale, weight, and animation speeds. Note that some settings may require advanced permissions or a device reboot for certain apps to reflect changes. \n\nAdditional shizuku or root permission may be necessary for scale adjustments + Set a custom screen refresh rate using Shizuku. Fixed mode locks both the minimum and peak refresh rate to one value, while range mode lets you define separate minimum and peak values. Reset clears the custom override and returns the device to its system-managed refresh behavior. + Mode + Refresh Rate + Fixed + Range + Fixed refresh rate + Set both minimum and peak refresh rate to the same value + Minimum refresh rate + Choose the lowest refresh rate the display may use + Peak refresh rate + Choose the highest refresh rate the display may use + System + Remove the custom override and return to the system default refresh behavior + Shizuku permission required to adjust refresh rate + Refresh Rate Mode + Switch between fixed and ranged refresh rate control + Fixed Refresh Rate + Lock the display to one custom refresh rate + Refresh Rate Range + Configure separate minimum and peak refresh rates + Reset Refresh Rate + Clear the custom refresh rate override Force turn off AOD Force turn off the AOD when no notifications. Requires accessibility permission. Auto accessibility diff --git a/codex-analysis-notes.md b/codex-analysis-notes.md new file mode 100644 index 000000000..4a7c01d4a --- /dev/null +++ b/codex-analysis-notes.md @@ -0,0 +1,583 @@ +# Essentials Analysis Notes + +## Project shape + +- Android app module only: `:app` +- Stack: + - Kotlin + Jetpack Compose + - Material 3 / Material 3 Expressive APIs + - SharedPreferences-heavy persistence + - Services, receivers, tiles, widgets, IME, accessibility, WorkManager + - Shizuku / root for privileged operations + - GitHub API for updates + - Jsoup for GSMArena scraping + - Wearable Data Layer for calendar sync +- Min SDK 26, target/compile SDK 36 +- App version in source: `13.1` (`versionCode = 40`) + +## What the app is + +This is not a single-feature utility. It is a platform-style Android toolbox for Pixel-oriented and power-user features: + +- Quick Settings tiles +- notification-driven effects +- accessibility-driven behaviors +- app lock / freezing +- DIY automations +- a custom keyboard/IME +- widgets +- Wear OS calendar sync +- GitHub-based app update tracking +- EXIF/photo watermarking +- device info/spec scraping + +The product model is "many independent utilities behind one shell" rather than one coherent workflow app. + +## High-level architecture + +### 1. App shell + +- `EssentialsApp.kt` + - stores global `context` + - initializes Shizuku helpers + - initializes logging + - initializes DIY automation repository/manager + - initializes calendar sync manager + - manually initializes Sentry depending on user preference + - registers a global screen/user-present security receiver + +- `MainActivity.kt` + - enormous top-level UI coordinator + - handles splash animation, edge-to-edge setup, onboarding, tabs, update UI, GitHub auth UI, repo import/export, and multiple bottom sheets + - uses a pager over `DIYTabs` + +### 2. State + persistence + +- `MainViewModel.kt` + - central app state hub + - ~2357 lines + - owns permission state, feature toggles, update checks, search state, keyboard prefs, app lock/freeze state, lighting state, calendar sync state, export/import helpers, and many side effects + - `check(context)` acts like a hydration/bootstrap routine: + - recreates repositories + - reloads permissions + system settings + - registers content observers + - registers power-save receiver + - recalculates blur/theme/update/onboarding state + - updates dependent services like app detection + +- `SettingsRepository.kt` + - main persistence layer + - SharedPreferences-backed + - stores nearly every feature setting + - contains migration logic and some typed helpers + +- `DIYRepository.kt` + - separate SharedPreferences store for DIY automations + - serializes sealed trigger/state/action models via Gson custom adapter + +### 3. Feature metadata layer + +- `FeatureRegistry.kt` + - declarative catalog of app capabilities + - each feature is an anonymous object extending `Feature` + - defines: + - feature id + - strings/icons/categories + - permissions required + - searchable settings + - visibility + - toggle behavior + - click behavior + +- `PermissionRegistry.kt` + - maps permission keys to dependent features + +- `SearchRegistry.kt` + - builds a search index at runtime from features + sub-settings + status bar icon registry + +This metadata-driven layer is one of the most important implementation patterns in the app. + +## Navigation / information architecture + +### Main tabs + +`DIYTabs.kt` + +- `ESSENTIALS` +- `FREEZE` +- `DIY` +- `APPS` + +The app does not appear to use Navigation Compose. Instead, tab state is owned directly in `MainActivity` and large screen sections are composed conditionally. + +### Secondary activities + +Manifest shows dedicated activities for: + +- feature settings +- app settings +- app updates +- device info (`Your Android`) +- location alarm +- automation editor +- app freezing +- color picker +- flashlight intensity +- private DNS settings +- watermarking +- tile auth / preferences +- link picker +- app lock + +## Runtime entry points outside the main UI + +The app uses many Android system surfaces: + +- Launcher activity +- share targets for text/image +- link interception activity +- input method service +- notification listener service +- accessibility service +- foreground services +- Quick Settings tiles +- app widgets +- condition provider service +- dream service +- broadcast receivers + +This app is highly event-driven and service-driven, not just activity-driven. + +## Feature implementation patterns + +### Permission-sensitive / privileged features + +Many capabilities branch between: + +- ordinary Android API path +- `WRITE_SECURE_SETTINGS` +- accessibility service +- notification listener +- usage stats +- overlays +- Shizuku +- root + +`ShellUtils.kt` abstracts the privileged execution source: + +- if root mode enabled -> use `RootUtils` +- else -> use `ShizukuUtils` + +This lets features like freezing, maps power saving, security, and some tile actions share a common privileged execution path. + +Also notable: + +- the app sometimes attempts to auto-enable its accessibility service when both: + - the user opted into auto accessibility + - `WRITE_SECURE_SETTINGS` is available + +That behavior is coordinated inside `MainViewModel.check()`. + +### Services and handlers + +There is a recurring pattern: + +- Android component receives the event +- a dedicated handler class owns the feature behavior + +Examples: + +- `ScreenOffAccessibilityService` + - composes multiple handlers: + - `FlashlightHandler` + - `NotificationLightingHandler` + - `ButtonRemapHandler` + - `AppFlowHandler` + - `SecurityHandler` + - `AmbientGlanceHandler` + - `AodForceTurnOffHandler` + - `OmniGestureOverlayHandler` + +- `NotificationListener` + - reacts to posted/removed notifications + - discovers channels for snoozing/maps + - powers ambient glance / like-song flows + +This is one of the cleaner parts of the codebase: behavior is often split into handler classes even though top-level activities/view models are very large. + +## Key subsystems + +### Accessibility service core + +`ScreenOffAccessibilityService.kt` + +- central runtime orchestrator for several features +- listens for: + - screen on/off + - user present + - package/window changes + - external volume long-press broadcasts + - ambient glance intents + - force-AOD-off intents +- also registers proximity sensor +- drives: + - screen-off widget behavior + - app lock flow + - app automations + - flashlight enhancements + - notification lighting overlays + - gesture overlay / circle-to-search behavior + - freeze-on-lock scheduling + +### App lock / app-aware automations / dynamic night light + +`AppFlowHandler.kt` + +- tracks current foreground package +- supports two upstream sources: + - accessibility window changes + - usage-stats polling via `AppDetectionService` +- responsibilities: + - app lock launch/auth state + - dynamic night light toggling for selected apps + - app-based DIY automations (entry/exit actions) + - gesture-bar related automation behavior + +### App detection fallback service + +`AppDetectionService.kt` + +- foreground service +- polls `UsageStatsManager` every 500ms +- used when usage-access mode is enabled for app lock / related features + +This is a pragmatic but potentially battery-expensive design choice. + +### Volume button remap + +Two paths exist: + +- accessibility key filtering in `ButtonRemapHandler` +- shell/input-event path via `InputEventListenerService` + +`InputEventListenerService.kt` + +- foreground special-use service +- scans Linux input devices for volume event sources +- listens for long presses via low-level input stream +- only acts when screen is fully off and not in AOD +- can also adjust flashlight intensity on short presses + +This is one of the more advanced/power-user parts of the app. + +### Notification lighting + +`NotificationLightingHandler.kt` + +- queue-based overlay display manager +- reads lighting config from intents +- supports multiple modes: + - stroke + - glow + - indicator + - sweep + - system mode +- can show ambient background behavior when screen is off +- uses `OverlayHelper` + +### DIY automations + +Core types: + +- `Automation` +- `Trigger` +- `State` +- `Action` + +Supported trigger/state model observed: + +- triggers: + - screen off + - screen on + - unlock + - charger connected/disconnected + - scheduled time +- states: + - charging + - screen on + - time period +- app automations: + - enter/exit actions for selected packages +- actions: + - flashlight on/off/toggle + - haptic vibration + - dim wallpaper + - sound mode + - low power on/off + - device effects + - notification placeholders + +Execution: + +- `DIYRepository` persists automations +- `AutomationManager` watches repository flow +- it enables module instances depending on active automation types: + - `PowerModule` + - `DisplayModule` + - `TimeModule` +- actions execute through `CombinedActionExecutor` + +Current limitation observed: + +- some action types like notification show/remove are still placeholders in `CombinedActionExecutor` + +Important observation: + +- DIY is modular at runtime, but the persistence model is still simple SharedPreferences JSON rather than a richer rules engine/data store. + +### Keyboard / IME + +`EssentialsInputMethodService.kt` + +- Compose-based IME +- owns lifecycle/viewmodel store plumbing manually for Compose inside `InputMethodService` +- loads prefs live for keyboard appearance and behavior +- tracks clipboard history +- uses `SuggestionEngine` +- uses `UndoRedoManager` + +`SuggestionEngine.kt` + +- combines Android spell checker session + SymSpell +- copies a frequency dictionary asset on first use +- supports learned words from a local text file + +`KeyboardInputView.kt` + +- giant Compose file (~106 KB) +- contains actual keyboard layout and interactions +- custom key shapes, popup accents, haptics, clipboard UI, emoji and kaomoji switching + +This IME is substantial and not a toy add-on. + +### Watermarking + +`WatermarkEngine.kt` + +- decodes bitmap +- optionally rotates +- extracts EXIF via `MetadataProvider` +- draws either: + - overlay watermark + - frame watermark +- supports: + - brand text + - EXIF rows + - custom text + - OEM logo + - border stroke/corners + - accent-based color modes +- writes output JPG +- copies selected EXIF tags to result + +`WatermarkViewModel.kt` + +- manages preview generation separately from final save +- derives accent color from image palette +- auto-detects OEM logo from EXIF make/model + +### App updates + +There are two distinct update concerns: + +1. Self-update check for Essentials + - `UpdateRepository.kt` + - checks GitHub releases for this app + - compares semantic versions + +2. Generic GitHub APK tracking for sideloaded apps + - `GitHubRepository.kt` + - `AppUpdatesViewModel.kt` + - stores `TrackedRepo` list + - maps repos to installed packages/apps + - supports README display, release notes, export/import, APK download/install + +The "Apps" tab in `MainActivity` is effectively a mini package/update manager UI for GitHub-hosted APKs rather than a standard app list. + +### Device info / "Your Android" + +`YourAndroidActivity.kt` + +- fetches local device info via `DeviceUtils` +- fetches online marketing specs via `GSMArenaService` +- caches specs and downloaded images via `DeviceSpecsCache` +- supports pull-to-refresh + +This is a hybrid local+scraped feature. + +### Wear OS calendar sync + +`CalendarSyncManager.kt` + +- observes calendar provider changes +- queries next 7 days, up to 10 events +- sends event payloads to wearable via Google Play Services Data Layer +- also ships dynamic theme colors + +`CalendarSyncWorker.kt` + +- periodic backup sync path using WorkManager + +### Widgets + +- `ScreenOffWidgetProvider` + - minimal widget trigger surface +- `BatteriesWidget` + - Glance widget + - combines phone battery, optional Mac battery via AirSync bridge, and Bluetooth battery readings + +### Dream / ambient media glance + +- `AmbientDreamService` +- `AmbientGlanceHandler` + +The codebase suggests a strong focus on ambient, lockscreen, and screen-off experiences. + +## UI / design system observations + +### Theme approach + +`EssentialsTheme` + +- Material 3 +- dynamic color on Android 12+ +- optional "pitch black" override for dark mode by forcing several surfaces/backgrounds to black +- fallback static palette still uses default-ish purple material seed values + +### Typography + +- uses bundled `google_sans_flex.ttf` +- typography is customized globally +- also defines a rounded variation using font variation settings (`ROND`) + +### Shapes + +- global rounded corners +- many local components push corner radii further (24dp, 32dp) + +### Visual language + +Observed patterns: + +- big rounded containers/cards +- strong use of `surfaceBright`, `surfaceContainer`, `primaryContainer` +- bottom floating toolbar used as a signature shell component +- blurred/frosted top/bottom overlays via custom AGSL progressive blur shader +- colorful deterministic pastel/vibrant icon badges based on feature title hash +- large onboarding with animation, gestures, and custom content +- strong emphasis on expressive but still Material-based UI +- occasional playful personality touches, including Easter eggs in onboarding + +### Core reusable UI primitives + +- `EssentialsFloatingToolbar` + - signature bottom toolbar + - used for tab mode and back/title mode + - adaptive label hiding on compact/large-font situations + +- `RoundedCardContainer` + - stacked card grouping primitive + +- `FeatureCard` + - primary feature list item + - long-press menu, blur/de-emphasis when menus are active, inline toggle, help/pin actions + +- `FavoriteCarousel` + - pinned features rendered in a Material 3 carousel + +- `progressiveBlur` + - custom shader-based blur edge effect for top/bottom chrome + +### Design conclusion + +The design is not a custom design system from scratch; it is a heavily customized Material 3 system with: + +- Google Sans branding +- stronger rounding +- expressive floating-bottom navigation/action chrome +- blur/frost effects +- personalized color chips +- onboarding/playful personality touches + +It feels handcrafted and brand-driven, but still anchored in Material semantics and Compose defaults. + +## Build / dependency observations + +Notable dependencies: + +- Compose BOM + Material 3 alpha +- Shizuku +- HiddenApiBypass +- Google Play Services Location/Wearable +- WorkManager +- Gson +- Jsoup +- Palette +- Coil + GIF support +- SymSpell +- Sentry +- Glance widgets + +Potential concern: + +- some dependency declarations are duplicated or mixed across BOM-managed and explicit versions +- Material 3 alpha is intentionally forced + +## Codebase scale indicators + +- feature definitions in `FeatureRegistry`: 43 +- files under `services/tiles`: 28 +- config UI composables: 25 +- top large files: + - `MainViewModel.kt` + - `KeyboardInputView.kt` + - `MainActivity.kt` + - `SetupFeatures.kt` + - `AutomationEditorActivity.kt` + +## Testing state + +- only placeholder example unit/instrumentation tests are present +- no meaningful automated coverage found yet + +## Architectural strengths + +- broad feature ambition with many Android surface integrations +- metadata-driven feature catalog is useful +- handler pattern helps isolate runtime behaviors +- Compose UI is fairly reusable in many places +- advanced Android integration work is real and non-trivial +- strong product personality in UX + +## Architectural weaknesses / likely maintenance pressure + +- `MainViewModel` is overloaded +- `MainActivity` is overloaded +- several feature screens are very large +- SharedPreferences is doing the job of a wider application state/data layer +- behavior can be hard to reason about because features interact across services, listeners, handlers, and settings keys +- low automated test coverage +- some features depend on hidden APIs / shell / OEM behavior / special permissions, so long-term reliability may vary by device and Android version +- some content observers / listeners are registered from central hydration code, which increases the chance of duplicated mental ownership when debugging lifecycle issues + +## Likely mental model for the app + +Think of Essentials as: + +- a feature registry +- backed by a giant settings repository +- rendered through Compose screens +- with background behavior carried out by Android services/receivers +- and privileged actions routed through Shizuku/root when needed + +It behaves more like a bundled suite of device mods than a traditional app.