diff --git a/.gitignore b/.gitignore index 3f88340a4..e88900855 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ # Package Files # *.jar +!gradle/wrapper/gradle-wrapper.jar *.war *.nar *.ear diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 95fb9c0dd..f9b222b64 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -88,6 +88,9 @@ + + + - - + + + + + + + + + + + + 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 282243c51..b1bd0a236 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 @@ -22,8 +22,11 @@ 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.TrackedRepo import com.sameerasw.essentials.domain.model.github.GitHubUser +import com.sameerasw.essentials.domain.model.ShutUpAppConfig + import com.sameerasw.essentials.utils.RootUtils import com.sameerasw.essentials.utils.ShizukuUtils import kotlinx.coroutines.channels.awaitClose @@ -34,7 +37,7 @@ class SettingsRepository(private val context: Context) { private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - private val gson = Gson() + private val gson = com.google.gson.GsonBuilder().create() init { migrateUsageAccessKey() @@ -275,6 +278,8 @@ class SettingsRepository(private val context: Context) { const val LIVE_WALLPAPER_TRIGGER_UNLOCK = "unlock" const val LIVE_WALLPAPER_TRIGGER_SCREEN_ON = "screen_on" + const val KEY_DISABLE_ROTATION_SUGGESTION = "disable_rotation_suggestion" + const val KEY_SHUT_UP_SELECTED_APPS = "shut_up_selected_apps" const val KEY_SHUT_UP_ORIGINAL_SETTINGS = "shut_up_original_settings" const val KEY_SHUT_UP_ATTEMPT_SHIZUKU_RESTART = "shut_up_attempt_shizuku_restart" @@ -282,7 +287,6 @@ class SettingsRepository(private val context: Context) { const val KEY_SHUT_UP_RESTORE_MODE = "shut_up_restore_mode" const val KEY_SHIZUKU_AUTH_TOKEN = "shizuku_auth_token" 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_ALLOW_OVERLAYS_IN_SETTINGS = "allow_overlays_in_settings" const val KEY_NETWORK_DOWNLOAD_RATE_LIMIT = "network_download_rate_limit" const val KEY_MOBILE_DATA_ALWAYS_ON = "mobile_data_always_on" @@ -306,6 +310,7 @@ class SettingsRepository(private val context: Context) { const val KEY_PIXEL_SEARCHBAR_MUSIC_ARTIST = "pixel_searchbar_music_artist" const val KEY_PIXEL_SEARCHBAR_MUSIC_PACKAGE = "pixel_searchbar_music_package" + 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" @@ -321,8 +326,8 @@ class SettingsRepository(private val context: Context) { const val KEY_POCKET_MODE_LOCK_SCREEN_ONLY = "pocket_mode_lock_screen_only" const val KEY_KEEP_PREFS = "keep_prefs" const val KEY_TRANSLATION_MODE_DO_NOT_SHOW_WARNING = "translation_mode_do_not_show_warning" - const val KEY_LOCKDOWN_MODE = "lockdown_mode" + const val KEY_SHUT_UP_SERVICE_ENABLED = "shutup_service_enabled" } /** @@ -927,76 +932,8 @@ class SettingsRepository(private val context: Context) { fun updatePocketModeExcludedAppSelection(packageName: String, enabled: Boolean) = updateAppSelection(KEY_POCKET_MODE_EXCLUDED_APPS, packageName, enabled) - /** - * Executes the load shut up configs operation. - * @return The resulting List { - val json = prefs.getString(KEY_SHUT_UP_SELECTED_APPS, null) - return if (json != null) { - try { - gson.fromJson( - json, - Array::class.java - ).toList() - } catch (e: Exception) { - emptyList() - } - } else { - emptyList() - } - } - - /** - * Executes the save shut up configs operation. - * - * @param configs [List] Target configs. - */ - fun saveShutUpConfigs(configs: List) { - val json = gson.toJson(configs) - putString(KEY_SHUT_UP_SELECTED_APPS, json) - } - - /** - * Executes the update shut up config operation. - * - * @param config [com.sameerasw.essentials.domain.model.ShutUpAppConfig] Target config. - */ - fun updateShutUpConfig(config: com.sameerasw.essentials.domain.model.ShutUpAppConfig) { - val current = loadShutUpConfigs().toMutableList() - val index = current.indexOfFirst { it.packageName == config.packageName } - if (index != -1) { - current[index] = config - } else { - current.add(config) - } - saveShutUpConfigs(current) - } - /** - * Executes the save shut up original settings operation. - * - * @param settings [Map Target string. - */ - fun saveShutUpOriginalSettings(settings: Map) { - val json = gson.toJson(settings) - putString(KEY_SHUT_UP_ORIGINAL_SETTINGS, json) - } - /** - * Executes the get shut up original settings operation. - * @return The resulting Map data. - */ - fun getShutUpOriginalSettings(): Map { - val json = prefs.getString(KEY_SHUT_UP_ORIGINAL_SETTINGS, null) ?: return emptyMap() - return try { - @Suppress("UNCHECKED_CAST") - gson.fromJson(json, Map::class.java) as Map - } catch (e: Exception) { - emptyMap() - } - } private fun updateAppSelection(key: String, packageName: String, enabled: Boolean) { val current = loadAppSelection(key).toMutableList() @@ -2820,5 +2757,60 @@ class SettingsRepository(private val context: Context) { * @param value [Int] Target value. */ fun setLockScreenClockSeedColor(value: Int) = putInt(KEY_LOCK_SCREEN_CLOCK_SEED_COLOR, value) + + fun loadShutUpConfigs(): List { + val json = prefs.getString(KEY_SHUT_UP_SELECTED_APPS, null) + return if (json != null) { + try { + gson.fromJson( + json, + Array::class.java + ).toList() + } catch (e: Exception) { + emptyList() + } + } else { + emptyList() + } + } + + fun saveShutUpConfigs(configs: List) { + val json = gson.toJson(configs) + putString(KEY_SHUT_UP_SELECTED_APPS, json) + } + + fun updateShutUpConfig(config: ShutUpAppConfig) { + val current = loadShutUpConfigs().toMutableList() + val index = current.indexOfFirst { it.packageName == config.packageName } + if (index != -1) { + current[index] = config + } else { + current.add(config) + } + saveShutUpConfigs(current) + } + + fun isShutUpServiceEnabled(): Boolean { + return prefs.getBoolean(KEY_SHUT_UP_SERVICE_ENABLED, false) + } + + fun setShutUpServiceEnabled(enabled: Boolean) { + putBoolean(KEY_SHUT_UP_SERVICE_ENABLED, enabled) + } + + fun saveShutUpOriginalSettings(settings: Map) { + val json = gson.toJson(settings) + putString(KEY_SHUT_UP_ORIGINAL_SETTINGS, json) + } + + fun getShutUpOriginalSettings(): Map { + val json = prefs.getString(KEY_SHUT_UP_ORIGINAL_SETTINGS, null) ?: return emptyMap() + return try { + @Suppress("UNCHECKED_CAST") + gson.fromJson(json, Map::class.java) as Map + } catch (e: Exception) { + emptyMap() + } + } } diff --git a/app/src/main/java/com/sameerasw/essentials/domain/model/AppSetting.kt b/app/src/main/java/com/sameerasw/essentials/domain/model/AppSetting.kt new file mode 100644 index 000000000..62ae8eee5 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/domain/model/AppSetting.kt @@ -0,0 +1,10 @@ +package com.sameerasw.essentials.domain.model + +data class AppSetting( + val enabled: Boolean = true, + val settingType: String, // "GLOBAL", "SECURE", "SYSTEM" + val key: String, + val valueOnLaunch: String, + val valueOnRevert: String, + val label: String +) diff --git a/app/src/main/java/com/sameerasw/essentials/domain/model/Feature.kt b/app/src/main/java/com/sameerasw/essentials/domain/model/Feature.kt index 771d0cfb5..11da9d832 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/model/Feature.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/model/Feature.kt @@ -48,7 +48,8 @@ abstract class Feature( @StringRes val aboutDescription: Int? = null, @androidx.annotation.RawRes val animationRes: Int = 0 ) { - val requiresAuth: Boolean = category == com.sameerasw.essentials.R.string.cat_protection + open val requiresAuth: Boolean + get() = category == com.sameerasw.essentials.R.string.cat_protection abstract fun isEnabled(viewModel: MainViewModel): Boolean diff --git a/app/src/main/java/com/sameerasw/essentials/domain/model/ShutUpAppConfig.kt b/app/src/main/java/com/sameerasw/essentials/domain/model/ShutUpAppConfig.kt index b20ce265a..1e3f21bf9 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/model/ShutUpAppConfig.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/model/ShutUpAppConfig.kt @@ -12,9 +12,65 @@ package com.sameerasw.essentials.domain.model data class ShutUpAppConfig( val packageName: String, val isEnabled: Boolean = true, - val disableDevOptions: Boolean = true, - val disableUsbDebugging: Boolean = true, - val disableWirelessDebugging: Boolean = true, - val disableAccessibility: Boolean = false, + val settings: List = emptyList(), + val attemptShizukuRestart: Boolean = false, val autoArchive: Boolean = false ) + +val ShutUpAppConfig.disableDevOptions: Boolean + get() = settings.any { it.key == "development_settings_enabled" && it.enabled } + +val ShutUpAppConfig.disableUsbDebugging: Boolean + get() = settings.any { it.key == "adb_enabled" && it.enabled } + +val ShutUpAppConfig.disableWirelessDebugging: Boolean + get() = settings.any { it.key == "adb_wifi_enabled" && it.enabled } + +val ShutUpAppConfig.disableAccessibility: Boolean + get() = settings.any { it.key == "accessibility_enabled" && it.enabled } + +fun ShutUpAppConfig.copy( + packageName: String = this.packageName, + isEnabled: Boolean = this.isEnabled, + attemptShizukuRestart: Boolean = this.attemptShizukuRestart, + autoArchive: Boolean = this.autoArchive, + disableDevOptions: Boolean = this.disableDevOptions, + disableUsbDebugging: Boolean = this.disableUsbDebugging, + disableWirelessDebugging: Boolean = this.disableWirelessDebugging, + disableAccessibility: Boolean = this.disableAccessibility +): ShutUpAppConfig { + val newList = settings.toMutableList() + + fun updateKey(key: String, label: String, enabled: Boolean) { + val existing = newList.find { it.key == key } + if (existing != null) { + newList[newList.indexOf(existing)] = existing.copy(enabled = enabled) + } else if (enabled) { + val type = if (key == "accessibility_enabled") "SECURE" else "GLOBAL" + newList.add( + AppSetting( + label = label, + settingType = type, + key = key, + valueOnLaunch = "0", + valueOnRevert = "1", + enabled = true + ) + ) + } + } + + updateKey("development_settings_enabled", "Hide Developer Options", disableDevOptions) + updateKey("adb_enabled", "Hide USB Debugging", disableUsbDebugging) + updateKey("adb_wifi_enabled", "Hide Wireless Debugging", disableWirelessDebugging) + updateKey("accessibility_enabled", "Hide Accessibility Services", disableAccessibility) + + return ShutUpAppConfig( + packageName = packageName, + isEnabled = isEnabled, + settings = newList, + attemptShizukuRestart = attemptShizukuRestart, + autoArchive = autoArchive + ) +} + 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 cd9f0ab0c..b6ef3b536 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 @@ -966,10 +966,6 @@ object FeatureRegistry { category = R.string.cat_interaction, description = R.string.feat_button_remap_desc, aboutDescription = R.string.about_desc_button_remap, - permissionKeys = if (ShellUtils.isRootEnabled(EssentialsApp.context)) listOf( - "ACCESSIBILITY", - "ROOT" - ) else listOf("ACCESSIBILITY", "SHIZUKU"), showToggle = true, searchableSettings = listOf( SearchSetting( @@ -1000,6 +996,20 @@ object FeatureRegistry { parentFeatureId = "Input", animationRes = R.raw.button_animation ) { + override val permissionKeys: List + get() { + val baseKeys = if (ShellUtils.isRootEnabled(EssentialsApp.context)) listOf( + "ACCESSIBILITY", + "ROOT" + ) else listOf("ACCESSIBILITY", "SHIZUKU") + val repository = com.sameerasw.essentials.data.repository.SettingsRepository(EssentialsApp.context) + val needsRecordAudio = repository.getString("button_remap_vol_up_action_off", "None") == "Toggle audio recording" || + repository.getString("button_remap_vol_down_action_off", "None") == "Toggle audio recording" || + repository.getString("button_remap_vol_up_action_on", "None") == "Toggle audio recording" || + repository.getString("button_remap_vol_down_action_on", "None") == "Toggle audio recording" + return if (needsRecordAudio) baseKeys + "RECORD_AUDIO" else baseKeys + } + override fun isEnabled(viewModel: MainViewModel) = viewModel.isButtonRemapEnabled.value override fun isToggleEnabled(viewModel: MainViewModel, context: Context) = viewModel.isAccessibilityEnabled.value @@ -1182,23 +1192,36 @@ object FeatureRegistry { override fun onToggle(viewModel: MainViewModel, context: Context, enabled: Boolean) = viewModel.setAppLockEnabled(enabled, context) }, + object : Feature( id = "Shut-Up!", title = R.string.feat_shut_up_title, - iconRes = R.drawable.rounded_domino_mask_24, - category = R.string.cat_system, + iconRes = R.drawable.rounded_shield_lock_24, + category = R.string.cat_protection, description = R.string.feat_shut_up_desc, aboutDescription = R.string.shut_up_description, - permissionKeys = listOf("WRITE_SECURE_SETTINGS", "USAGE_STATS"), + permissionKeys = listOf("WRITE_SECURE_SETTINGS", "WRITE_SETTINGS", "USAGE_STATS", "POST_NOTIFICATIONS"), showToggle = false, hasMoreSettings = true, parentFeatureId = "Security", animationRes = R.raw.shutup_animation ) { - override fun isEnabled(viewModel: MainViewModel) = true - override fun onToggle(viewModel: MainViewModel, context: Context, enabled: Boolean) {} + override val requiresAuth: Boolean = false + + override fun isEnabled(viewModel: MainViewModel) = + viewModel.isShutUpServiceEnabled.value + + override fun isToggleEnabled(viewModel: MainViewModel, context: Context) = + viewModel.isWriteSecureSettingsEnabled.value && + viewModel.isWriteSettingsEnabled.value && + viewModel.isUsageStatsPermissionGranted.value && + viewModel.isPostNotificationsEnabled.value + + override fun onToggle(viewModel: MainViewModel, context: Context, enabled: Boolean) = + viewModel.setShutUpServiceEnabled(enabled, context) }, + object : Feature( id = "Pocket mode", title = R.string.feat_pocket_mode_title, 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 f063f7f42..b577f9a70 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 @@ -124,10 +124,12 @@ fun initPermissionRegistry() { // Default browser permission PermissionRegistry.register("DEFAULT_BROWSER", R.string.feat_link_actions_title) - // Shut-Up! feature + // Shut-Up! permissions PermissionRegistry.register("WRITE_SECURE_SETTINGS", R.string.feat_shut_up_title) PermissionRegistry.register("WRITE_SETTINGS", R.string.feat_shut_up_title) PermissionRegistry.register("USAGE_STATS", R.string.feat_shut_up_title) + PermissionRegistry.register("POST_NOTIFICATIONS", R.string.feat_shut_up_title) + PermissionRegistry.register("RECORD_AUDIO", R.string.feat_button_remap_title) // Power and Battery feature PermissionRegistry.register("WRITE_SECURE_SETTINGS", R.string.feat_power_battery_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 923988a63..b6379493c 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/AppDetectionService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/AppDetectionService.kt @@ -62,7 +62,7 @@ class AppDetectionService : Service() { override fun onCreate() { super.onCreate() isRunning = true - appFlowHandler = AppFlowHandler(this) + appFlowHandler = AppFlowHandler.getInstance(this) createNotificationChannel() val filter = IntentFilter().apply { @@ -143,6 +143,12 @@ class AppDetectionService : Service() { unregisterReceiver(authReceiver) } catch (_: Exception) { } + try { + if (::appFlowHandler.isInitialized) { + appFlowHandler.destroy() + } + } catch (_: Exception) { + } super.onDestroy() } diff --git a/app/src/main/java/com/sameerasw/essentials/services/ShutUpForegroundService.kt b/app/src/main/java/com/sameerasw/essentials/services/ShutUpForegroundService.kt new file mode 100644 index 000000000..c23afdfad --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/services/ShutUpForegroundService.kt @@ -0,0 +1,342 @@ +package com.sameerasw.essentials.services + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.app.usage.UsageEvents +import android.app.usage.UsageStatsManager +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import com.sameerasw.essentials.R +import com.sameerasw.essentials.data.repository.SettingsRepository +import com.sameerasw.essentials.domain.model.ShutUpAppConfig +import com.sameerasw.essentials.utils.FreezeManager +import com.sameerasw.essentials.utils.ShutUpManager +import kotlinx.coroutines.* + +class ShutUpForegroundService : Service() { + + private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private lateinit var settingsRepository: SettingsRepository + @Volatile private var monitorJob: Job? = null + @Volatile private var lastPackageName: String? = null + private var lastQueryTime = System.currentTimeMillis() - 5000 + + @Volatile private var pendingRestoreJob: Job? = null + private var pendingRestorePackage: String? = null + @Volatile private var freezeCountdownJob: Job? = null + + // Active config for the currently monitored target app (used for periodic re-enforcement) + private var activeTargetConfig: com.sameerasw.essentials.domain.model.ShutUpAppConfig? = null + private var enforceTickCount = 0 + + // Cached system service and reusable event object (only accessed from monitorJob / Default dispatcher) + private val usageStatsManager by lazy { getSystemService(USAGE_STATS_SERVICE) as UsageStatsManager } + private val reusableEvent = UsageEvents.Event() + + companion object { + private const val TAG = "ShutUpForegroundService" + private const val CHANNEL_ID = "shutup_service_channel" + private const val NOTIFICATION_ID = 1002 + private const val NOTIFICATION_FREEZE_ID = 1003 + + const val ACTION_STOP_SERVICE = "ACTION_STOP_SERVICE" + const val ACTION_FREEZE_NOW = "ACTION_FREEZE_NOW" + const val ACTION_ABORT_FREEZE = "ACTION_ABORT_FREEZE" + const val EXTRA_PACKAGE_NAME = "package_name" + + @Volatile var isRunning = false + } + + override fun onCreate() { + super.onCreate() + isRunning = true + settingsRepository = SettingsRepository(this) + createNotificationChannel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_STOP_SERVICE -> { + stopForeground(true) + stopSelf() + return START_NOT_STICKY + } + ACTION_FREEZE_NOW -> { + val pkg = intent.getStringExtra(EXTRA_PACKAGE_NAME) + if (pkg != null) { + freezeCountdownJob?.cancel() + val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + notificationManager.cancel(NOTIFICATION_FREEZE_ID) + serviceScope.launch { + FreezeManager.freezeApp(this@ShutUpForegroundService, pkg) + } + } + return START_STICKY + } + ACTION_ABORT_FREEZE -> { + freezeCountdownJob?.cancel() + val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + notificationManager.cancel(NOTIFICATION_FREEZE_ID) + return START_STICKY + } + } + + startForeground( + NOTIFICATION_ID, + createServiceNotification(), + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE else 0 + ) + + // Recover backup before monitoring starts. Avoid startup restore/apply race. + serviceScope.launch { + val backup = settingsRepository.getShutUpOriginalSettings() + if (backup.isNotEmpty()) { + Log.d(TAG, "Found pending ShutUp backup on startup — checking if restore is needed") + val now = System.currentTimeMillis() + val foregroundPackage = getForegroundPackage(now - 5000, now) + val foregroundShutUp = settingsRepository.loadShutUpConfigs().any { + it.isEnabled && it.packageName == foregroundPackage + } + if (!foregroundShutUp) { + Log.d(TAG, "No shut-up app running — restoring backup on startup") + ShutUpManager.restoreOriginalSettings(this@ShutUpForegroundService, settingsRepository) + } + } + startMonitoring() + } + return START_STICKY + } + + private fun startMonitoring() { + if (monitorJob != null) return + monitorJob = serviceScope.launch { + while (isActive) { + val now = System.currentTimeMillis() + val currentPkg = getForegroundPackage(lastQueryTime, now) + if (currentPkg != null) { + lastQueryTime = now + if (currentPkg != lastPackageName) { + val previousPkg = lastPackageName + lastPackageName = currentPkg + onPackageChanged(previousPkg, currentPkg) + enforceTickCount = 0 + } else { + // Re-enforce settings every ~2s while target app stays in foreground + // This ensures settings stay hidden even if something re-enables them between opens + enforceTickCount++ + if (enforceTickCount % 5 == 0) { + activeTargetConfig?.let { config -> + Log.d(TAG, "Re-enforcing ShutUp settings for ${config.packageName}") + ShutUpManager.applyShutUpSettings( + this@ShutUpForegroundService, + config, + settingsRepository, + reinforcement = true + ) + } + } + } + } else { + lastQueryTime = now - 500 + } + delay(400) + } + } + } + + private fun getForegroundPackage(startTime: Long, endTime: Long): String? { + try { + val events = usageStatsManager.queryEvents(startTime, endTime) + var lastResumedPackage: String? = null + while (events.hasNextEvent()) { + events.getNextEvent(reusableEvent) + if (reusableEvent.eventType == UsageEvents.Event.ACTIVITY_RESUMED) { + lastResumedPackage = reusableEvent.packageName + } + } + if (lastResumedPackage != null) { + return lastResumedPackage + } + } catch (e: Exception) { + Log.e(TAG, "Failed to query usage events", e) + } + return null + } + + private suspend fun onPackageChanged(oldPkg: String?, newPkg: String?) { + if (newPkg == null || ShutUpManager.isPackageIgnored(newPkg)) return + + val configs = settingsRepository.loadShutUpConfigs() + + val newConfig = configs.find { it.packageName == newPkg && it.isEnabled } + + // 1. Leaving a Shut-Up app. Keep the session snapshot when moving directly to another + // Shut-Up app; restoring between targets would briefly re-enable protected settings. + if (oldPkg != null && configs.any { it.packageName == oldPkg && it.isEnabled }) { + pendingRestoreJob?.cancel() + if (newConfig == null) { + activeTargetConfig = null + pendingRestorePackage = oldPkg + pendingRestoreJob = serviceScope.launch { + delay(settingsRepository.getShutUpRestoreDelay().coerceAtLeast(0) * 1000L) + val config = settingsRepository.loadShutUpConfigs().find { it.packageName == oldPkg } + if (config != null && config.isEnabled && lastPackageName != oldPkg && activeTargetConfig == null) { + ShutUpManager.restoreOriginalSettings(this@ShutUpForegroundService, settingsRepository) + if (config.attemptShizukuRestart) { + ShutUpManager.restartShizuku(this@ShutUpForegroundService) + } + if (config.autoArchive) { + showAutoFreezeNotification(config.packageName) + } + } else { + Log.d(TAG, "Skipping restore for $oldPkg — another target is active or package returned") + } + pendingRestorePackage = null + pendingRestoreJob = null + } + } else { + pendingRestorePackage = null + } + } + + // 2. Entering a Shut-Up app + if (newConfig != null) { + activeTargetConfig = newConfig + freezeCountdownJob?.cancel() + val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + notificationManager.cancel(NOTIFICATION_FREEZE_ID) + + // Apply inline — no extra coroutine spawn, runs directly in monitoring coroutine + ShutUpManager.applyShutUpSettings(this@ShutUpForegroundService, newConfig, settingsRepository) + } else { + activeTargetConfig = null + } + } + + private fun showAutoFreezeNotification(packageName: String) { + freezeCountdownJob?.cancel() + val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + val appName = try { + val appInfo = packageManager.getApplicationInfo(packageName, 0) + packageManager.getApplicationLabel(appInfo).toString() + } catch (e: Exception) { + packageName + } + + // Build PendingIntents once — they do not change between countdown ticks + val freezePendingIntent = PendingIntent.getService( + this@ShutUpForegroundService, + 101, + Intent(this@ShutUpForegroundService, ShutUpForegroundService::class.java).apply { + action = ACTION_FREEZE_NOW + putExtra(EXTRA_PACKAGE_NAME, packageName) + }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + val abortPendingIntent = PendingIntent.getService( + this@ShutUpForegroundService, + 102, + Intent(this@ShutUpForegroundService, ShutUpForegroundService::class.java).apply { + action = ACTION_ABORT_FREEZE + }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + freezeCountdownJob = serviceScope.launch { + var secondsRemaining = 5 + while (secondsRemaining > 0) { + val notification = NotificationCompat.Builder(this@ShutUpForegroundService, CHANNEL_ID) + .setContentTitle(getString(R.string.shut_up_auto_archive_notif_title)) + .setContentText(getString(R.string.shut_up_auto_archive_notif_text, appName, secondsRemaining)) + .setSmallIcon(R.drawable.rounded_snowflake_24) + .setOngoing(true) + .addAction(R.drawable.rounded_snowflake_24, getString(R.string.shut_up_auto_archive_action_freeze), freezePendingIntent) + .addAction(R.drawable.rounded_close_24, getString(R.string.shut_up_auto_archive_action_abort), abortPendingIntent) + .build() + + notificationManager.notify(NOTIFICATION_FREEZE_ID, notification) + delay(1000) + secondsRemaining-- + } + + FreezeManager.freezeApp(this@ShutUpForegroundService, packageName) + notificationManager.cancel(NOTIFICATION_FREEZE_ID) + } + } + + override fun onDestroy() { + isRunning = false + monitorJob?.cancel() + pendingRestoreJob?.cancel() + freezeCountdownJob?.cancel() + + val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + notificationManager.cancel(NOTIFICATION_FREEZE_ID) + + // Best-effort synchronous restore if we were mid-restore cycle when the service was stopped. + // Only restore if no shut-up app is actively in the foreground (activeTargetConfig == null means + // we left the target app and were waiting for it to close before reverting). + val pkg = pendingRestorePackage + if (pkg != null && activeTargetConfig == null) { + try { + val configs = settingsRepository.loadShutUpConfigs() + val config = configs.find { it.packageName == pkg && it.isEnabled } + if (config != null && !ShutUpManager.isAppRunning(this, pkg)) { + runBlocking(Dispatchers.IO) { + ShutUpManager.restoreOriginalSettings(this@ShutUpForegroundService, settingsRepository) + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed sync restore on destroy", e) + } + } + + serviceScope.cancel() + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + getString(R.string.shut_up_service_name), + NotificationManager.IMPORTANCE_LOW + ).apply { + description = getString(R.string.shut_up_service_desc) + } + val manager = getSystemService(NotificationManager::class.java) + manager.createNotificationChannel(channel) + } + } + + private fun createServiceNotification(): Notification { + val stopIntent = Intent(this, ShutUpForegroundService::class.java).apply { + action = ACTION_STOP_SERVICE + } + val stopPendingIntent = PendingIntent.getService( + this, + 201, + stopIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(getString(R.string.shut_up_service_notification_title)) + .setContentText(getString(R.string.shut_up_service_notification_desc)) + .setSmallIcon(R.drawable.rounded_shield_lock_24) + .setOngoing(true) + .addAction(R.drawable.rounded_close_24, getString(R.string.action_stop), stopPendingIntent) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .build() + } +} 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 b33687719..6dc2cbfc6 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/handlers/AppFlowHandler.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/AppFlowHandler.kt @@ -13,6 +13,7 @@ import android.accessibilityservice.AccessibilityService import android.app.NotificationManager import android.app.PendingIntent import android.content.BroadcastReceiver +import android.media.AudioManager import android.content.ComponentName import android.content.Context import android.content.Intent @@ -21,15 +22,23 @@ 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 android.view.inputmethod.InputMethodManager import com.google.gson.Gson import com.sameerasw.essentials.domain.diy.Automation import com.sameerasw.essentials.domain.diy.DIYRepository import com.sameerasw.essentials.domain.model.AppSelection +import com.sameerasw.essentials.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.ShutUpManager +import com.sameerasw.essentials.domain.model.ShutUpAppConfig +import com.sameerasw.essentials.utils.RefreshRateUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -37,67 +46,68 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -class AppFlowHandler( - private val context: Context, - private val service: AccessibilityService? = null +class AppFlowHandler private constructor( + context: Context ) { + private val context = context.applicationContext private val handler = Handler(Looper.getMainLooper()) - private val scope = CoroutineScope(Dispatchers.Main) - - private val authenticatedPackages = mutableSetOf() - private val lastLeaveTimes = mutableMapOf() - private val activeCountdowns = mutableMapOf() - - private val shutUpReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - when (intent?.action) { - ACTION_FREEZE_NOW -> { - val packageName = intent.getStringExtra(EXTRA_PACKAGE_NAME) ?: return - activeCountdowns[packageName]?.cancel() - activeCountdowns.remove(packageName) - context?.let { FreezeManager.freezeApp(it, packageName) } - cancelNotification(packageName) - } - - ACTION_ABORT_FREEZE -> { - val packageName = intent.getStringExtra(EXTRA_PACKAGE_NAME) ?: return - activeCountdowns[packageName]?.cancel() - activeCountdowns.remove(packageName) - cancelNotification(packageName) - } - - ACTION_RESTORE_NOW -> { - cancelRestoreNotification() - val autoPkg = intent.getStringExtra(EXTRA_AUTO_ARCHIVE_PACKAGE) - val pkgName = intent.getStringExtra(EXTRA_PACKAGE_NAME) - val settingsRepo = com.sameerasw.essentials.data.repository.SettingsRepository( - context ?: return - ) - val config = if (pkgName != null) { - settingsRepo.loadShutUpConfigs().find { it.packageName == pkgName } - } else null - restoreShutUpSettings(settingsRepo, config, autoPkg, forceRestore = true) - } + 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 } } + override fun onLowMemory() {} + override fun onTrimMemory(level: Int) {} + } + + private val prefsChangeListener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, _ -> } + + private val mediaReceiver = object : android.content.BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) {} } init { - val filter = IntentFilter().apply { - addAction(ACTION_FREEZE_NOW) - addAction(ACTION_ABORT_FREEZE) - addAction(ACTION_RESTORE_NOW) - } - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { - context.registerReceiver(shutUpReceiver, filter, Context.RECEIVER_EXPORTED) + this.context.registerComponentCallbacks(componentCallbacks) + val filter = IntentFilter("com.sameerasw.essentials.MEDIA_PLAYBACK_CHANGED") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + this.context.registerReceiver(mediaReceiver, filter, Context.RECEIVER_EXPORTED) } else { - context.registerReceiver(shutUpReceiver, filter) + this.context.registerReceiver(mediaReceiver, filter) } + val prefs = this.context.getSharedPreferences(SettingsRepository.PREFS_NAME, Context.MODE_PRIVATE) + prefs.registerOnSharedPreferenceChangeListener(prefsChangeListener) + } + + fun destroy() { + try { + val prefs = this.context.getSharedPreferences(SettingsRepository.PREFS_NAME, Context.MODE_PRIVATE) + prefs.unregisterOnSharedPreferenceChangeListener(prefsChangeListener) + } catch (_: Exception) {} + try { + context.unregisterComponentCallbacks(componentCallbacks) + } catch (_: Exception) {} + try { + context.unregisterReceiver(mediaReceiver) + } catch (_: Exception) {} + } + private val scope = CoroutineScope(Dispatchers.Main.immediate) + + private val settingsRepository by lazy { SettingsRepository(context) } + private val prefs by lazy { context.getSharedPreferences(SettingsRepository.PREFS_NAME, Context.MODE_PRIVATE) } + private val notificationListenerComponent by lazy { + ComponentName(context, NotificationListener::class.java) } + private val authenticatedPackages = mutableSetOf() + private val lastLeaveTimes = mutableMapOf() + // App Lock State private var lockingPackage: String? = null private var lastLockRequestTime: Long = 0 + @Volatile var currentPackage: String? = null private set private var currentUsageStatsPackage: String? = null @@ -114,28 +124,83 @@ class AppFlowHandler( private val ignoredSystemPackages = listOf( "android", "com.android.systemui", - "com.google.android.inputmethod.latin" + "com.google.android.inputmethod.latin", + "com.google.android.gms" ) + private fun isIgnoredPackage(packageName: String): Boolean { + if (packageName == context.packageName) return true + if (ignoredSystemPackages.contains(packageName)) return true + + val lowerPkg = packageName.lowercase() + if (lowerPkg.contains("systemui") || + lowerPkg.contains("keyguard") || + lowerPkg.contains("volume") || + lowerPkg.contains("soundassistant") || + lowerPkg.contains("dialer") || + lowerPkg.contains("telecom") || + lowerPkg.contains("phone") || + lowerPkg.contains("incallui") || + lowerPkg.contains("packageinstaller") || + lowerPkg.contains("permissioncontroller") + ) { + return true + } + + // Check active call state via AudioManager mode + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager + if (audioManager != null) { + val mode = audioManager.mode + if (mode == AudioManager.MODE_IN_CALL || + mode == AudioManager.MODE_IN_COMMUNICATION || + mode == AudioManager.MODE_RINGTONE + ) { + return true + } + } + + return try { + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + val ims = imm?.enabledInputMethodList + ims?.any { it.packageName == packageName } == true + } catch (_: Exception) { + false + } + } + fun onPackageChanged(packageName: String, isFromUsageStats: Boolean = false) { - val prefs = context.getSharedPreferences("essentials_prefs", Context.MODE_PRIVATE) - val useUsageAccess = prefs.getBoolean("use_usage_access", false) + val useUsageAccess = settingsRepository.getBoolean(SettingsRepository.KEY_USE_USAGE_ACCESS, false) && + com.sameerasw.essentials.services.AppDetectionService.isRunning + + Log.d("AppFlowHandler", "onPackageChanged: packageName=$packageName, isFromUsageStats=$isFromUsageStats, useUsageAccess=$useUsageAccess, currentPackage=$currentPackage") + // If the new foreground window belongs to a system overlay (status bar, quick settings, + // notifications), a keyboard (IME), a volume dialog, or a phone call, completely ignore it. + // We do NOT update currentPackage so that state-dependent features remain stable. + if (isIgnoredPackage(packageName)) { + Log.d("AppFlowHandler", "onPackageChanged: Ignoring system/IME/volume/call package $packageName") + return + } val oldPackage = currentPackage + currentPackage = packageName + if (oldPackage != null && oldPackage != packageName) { + lastLeaveTimes[oldPackage] = System.currentTimeMillis() + } + if (packageName != context.packageName && packageName != lockingPackage) { + lockingPackage = null + } + if (isFromUsageStats == useUsageAccess) { - currentPackage = packageName - if (oldPackage != null && oldPackage != packageName) { - lastLeaveTimes[oldPackage] = System.currentTimeMillis() - checkShutUpRestore(oldPackage, packageName) - } - if (packageName != context.packageName && packageName != lockingPackage) { - lockingPackage = null - } + Log.d("AppFlowHandler", "onPackageChanged: Processing package change because isFromUsageStats matches useUsageAccess") checkAppLock(packageName) checkHighlightNightLight(packageName) checkAppAutomations(packageName) checkGestureBarAutomation(packageName) } + + // Accessibility events are the fastest automatic launch signal. The manager serializes + // this with the foreground-service fallback and periodic enforcement. + checkShutUp(packageName) } fun onAuthenticated(packageName: String) { @@ -149,6 +214,26 @@ class AppFlowHandler( authenticatedPackages.clear() } + private fun checkShutUp(packageName: String) { + val prefs = context.getSharedPreferences("essentials_prefs", Context.MODE_PRIVATE) + val serviceEnabled = prefs.getBoolean("shutup_service_enabled", false) + if (!serviceEnabled) return + + val json = prefs.getString("shut_up_selected_apps", null) ?: return + val configs: List = try { + Gson().fromJson(json, Array::class.java).toList() + } catch (_: Exception) { + return + } + + val config = configs.find { it.packageName == packageName && it.isEnabled } ?: return + + scope.launch(Dispatchers.IO) { + Log.d("AppFlowHandler", "checkShutUp: Immediately applying ShutUp settings for $packageName via accessibility event") + ShutUpManager.applyShutUpSettings(context, config, settingsRepository) + } + } + private fun checkAppLock(packageName: String) { val prefs = context.getSharedPreferences("essentials_prefs", Context.MODE_PRIVATE) val isEnabled = prefs.getBoolean("app_lock_enabled", false) @@ -224,7 +309,7 @@ class AppFlowHandler( pendingNLRunnable?.let { handler.removeCallbacks(it) } - if (ignoredSystemPackages.contains(packageName)) { + if (isIgnoredPackage(packageName)) { Log.d("NightLight", "Ignoring system package $packageName") return } @@ -296,6 +381,10 @@ class AppFlowHandler( } private fun checkAppAutomations(packageName: String) { + if (isIgnoredPackage(packageName)) { + Log.d("AppFlowHandler", "checkAppAutomations: Ignoring system/IME package $packageName") + return + } scope.launch { val automations = DIYRepository.automations.value val appAutomations = @@ -383,221 +472,7 @@ class AppFlowHandler( return launchers.any { it.activityInfo.packageName == packageName } } - private fun checkShutUpRestore(oldPackage: String?, newPackage: String?) { - Log.d("AppFlowHandler", "checkShutUpRestore: old=$oldPackage, new=$newPackage") - if (oldPackage == null || oldPackage == newPackage) return - - val settingsRepository = - com.sameerasw.essentials.data.repository.SettingsRepository(context) - val shutUpConfigs = settingsRepository.loadShutUpConfigs() - - val wasShutUpConfig = shutUpConfigs.find { it.packageName == oldPackage && it.isEnabled } - - // Check if it was already frozen to avoid duplicate triggers (e.g. on screen off) - val isAlreadyFrozen = oldPackage.let { FreezeManager.isAppFrozen(context, it) } - - // We consider the new app a Shut-Up app if it's in the list OR if it's the shortcut activity - val isNewAppShutUp = shutUpConfigs.any { it.packageName == newPackage && it.isEnabled } || - newPackage == "com.sameerasw.essentials.ShutUpShortcutActivity" - - Log.d( - "AppFlowHandler", - "checkShutUpRestore: wasShutUpConfig=${wasShutUpConfig != null}, isNewAppShutUp=$isNewAppShutUp, isAlreadyFrozen=$isAlreadyFrozen" - ) - - // If it's already frozen, we've already handled it - if (isAlreadyFrozen) return - - // If we are entering a Shut-Up app, cancel ANY pending countdowns for other apps - if (isNewAppShutUp) { - if (activeCountdowns.isNotEmpty()) { - Log.d( - "AppFlowHandler", - "checkShutUpRestore: Entering Shut-Up app, cancelling all pending countdowns" - ) - activeCountdowns.values.forEach { it.cancel() } - activeCountdowns.keys.forEach { cancelNotification(it) } - activeCountdowns.clear() - } - } - - if (wasShutUpConfig != null && !isNewAppShutUp) { - Log.d("AppFlowHandler", "checkShutUpRestore: Triggering restoration for $oldPackage") - restoreShutUpSettings( - settingsRepository, - wasShutUpConfig, - if (wasShutUpConfig.autoArchive) wasShutUpConfig.packageName else null - ) - } - } - - private fun startAutoArchiveCountdown(packageName: String) { - Log.d("AppFlowHandler", "startAutoArchiveCountdown: $packageName") - // Cancel existing countdown for this app if any - activeCountdowns[packageName]?.cancel() - val appName = try { - val appInfo = context.packageManager.getApplicationInfo(packageName, 0) - context.packageManager.getApplicationLabel(appInfo).toString() - } catch (e: Exception) { - Log.e("AppFlowHandler", "Failed to get app name for $packageName", e) - packageName - } - - val job = scope.launch { - Log.d("AppFlowHandler", "Countdown job started for $packageName") - for (i in 10 downTo 1) { - Log.d("AppFlowHandler", "Countdown for $packageName: $i") - showCountdownNotification(packageName, appName, i) - delay(1000) - } - // countdown finished - Log.d("AppFlowHandler", "Countdown finished for $packageName, freezing...") - val success = withContext(Dispatchers.IO) { - FreezeManager.freezeApp(context, packageName) - } - Log.d("AppFlowHandler", "Freeze result for $packageName: $success") - cancelNotification(packageName) - activeCountdowns.remove(packageName) - } - activeCountdowns[packageName] = job - } - - private fun showCountdownNotification(packageName: String, appName: String, secondsLeft: Int) { - createNotificationChannel() - val notificationManager = - context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - val freezeIntent = Intent(ACTION_FREEZE_NOW).apply { - `package` = context.packageName - putExtra(EXTRA_PACKAGE_NAME, packageName) - } - val freezePendingIntent = PendingIntent.getBroadcast( - context, - packageName.hashCode() + 1, - freezeIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - - val abortIntent = Intent(ACTION_ABORT_FREEZE).apply { - `package` = context.packageName - putExtra(EXTRA_PACKAGE_NAME, packageName) - } - val abortPendingIntent = PendingIntent.getBroadcast( - context, - packageName.hashCode() + 2, - abortIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - - val title = - context.getString(com.sameerasw.essentials.R.string.shut_up_auto_archive_notif_title) - val text = context.getString( - com.sameerasw.essentials.R.string.shut_up_auto_archive_notif_text, - appName, - secondsLeft - ) - val criticalText = secondsLeft.toString() - - val notification = - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { - val builder = android.app.Notification.Builder(context, "shutup_alerts_channel") - .setSmallIcon(com.sameerasw.essentials.R.drawable.rounded_snowflake_24) - .setContentTitle(title) - .setContentText(text) - .setOngoing(true) - .setOnlyAlertOnce(true) - .setCategory(android.app.Notification.CATEGORY_SERVICE) - .setShowWhen(false) - .setGroup("shutup_auto_archive") - .setColorized(false) - - if (android.os.Build.VERSION.SDK_INT >= 31) { - builder.setForegroundServiceBehavior(android.app.Notification.FOREGROUND_SERVICE_IMMEDIATE) - } - - builder.addAction( - android.app.Notification.Action.Builder( - android.graphics.drawable.Icon.createWithResource( - context, - com.sameerasw.essentials.R.drawable.rounded_snowflake_24 - ), - context.getString(com.sameerasw.essentials.R.string.shut_up_auto_archive_action_freeze), - freezePendingIntent - ).build() - ) - builder.addAction( - android.app.Notification.Action.Builder( - android.graphics.drawable.Icon.createWithResource( - context, - com.sameerasw.essentials.R.drawable.rounded_close_24 - ), - context.getString(com.sameerasw.essentials.R.string.shut_up_auto_archive_action_abort), - abortPendingIntent - ).build() - ) - - // Live Update Status Chip - try { - val setRequestPromotedOngoing = builder.javaClass.getMethod( - "setRequestPromotedOngoing", - Boolean::class.javaPrimitiveType - ) - setRequestPromotedOngoing.invoke(builder, true) - - val setShortCriticalText = builder.javaClass.getMethod( - "setShortCriticalText", - CharSequence::class.java - ) - setShortCriticalText.invoke(builder, criticalText) - } catch (_: Throwable) { - } - - val extras = android.os.Bundle() - extras.putBoolean("android.requestPromotedOngoing", true) - extras.putString("android.shortCriticalText", criticalText) - builder.addExtras(extras) - - builder.setProgress(10, secondsLeft, false) - - builder.build() - } else { - NotificationCompat.Builder(context, "shutup_alerts_channel") - .setSmallIcon(com.sameerasw.essentials.R.drawable.rounded_snowflake_24) - .setContentTitle(title) - .setContentText(text) - .setPriority(NotificationCompat.PRIORITY_MAX) - .setCategory(NotificationCompat.CATEGORY_SERVICE) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setOnlyAlertOnce(true) - .setOngoing(true) - .setProgress(10, secondsLeft, false) - .addAction( - com.sameerasw.essentials.R.drawable.rounded_snowflake_24, - context.getString(com.sameerasw.essentials.R.string.shut_up_auto_archive_action_freeze), - freezePendingIntent - ) - .addAction( - com.sameerasw.essentials.R.drawable.rounded_close_24, - context.getString(com.sameerasw.essentials.R.string.shut_up_auto_archive_action_abort), - abortPendingIntent - ) - .addExtras(android.os.Bundle().apply { - putBoolean("android.requestPromotedOngoing", true) - putString("android.shortCriticalText", criticalText) - }) - .build() - } - - Log.d("AppFlowHandler", "Showing notification for $packageName, secondsLeft=$secondsLeft") - notificationManager.notify(packageName.hashCode(), notification) - } - - private fun cancelNotification(packageName: String) { - val notificationManager = - context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.cancel(packageName.hashCode()) - } private fun createNotificationChannel() { if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { @@ -613,241 +488,31 @@ class AppFlowHandler( } notificationManager.createNotificationChannel(channel) - val alertChannel = android.app.NotificationChannel( - "shutup_alerts_channel", - "Shut-Up! Alerts", - NotificationManager.IMPORTANCE_MAX - ).apply { - description = "Live update notifications for auto archiving" - enableVibration(false) - setSound(null, null) - lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC - } - notificationManager.createNotificationChannel(alertChannel) - - val restoreChannel = android.app.NotificationChannel( - "shutup_restore_channel", - "Shut-Up! Restore", - NotificationManager.IMPORTANCE_MAX - ).apply { - description = "Notifications for restoring Shut-Up settings" - enableVibration(false) - setSound(null, null) - lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC - } - notificationManager.createNotificationChannel(restoreChannel) - } - } - - private fun showRestoreNotification( - wasShutUpConfig: com.sameerasw.essentials.domain.model.ShutUpAppConfig?, - autoArchivePackage: String? - ) { - createNotificationChannel() - val notificationManager = - context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - val restoreIntent = Intent(ACTION_RESTORE_NOW).apply { - `package` = context.packageName - if (autoArchivePackage != null) { - putExtra(EXTRA_AUTO_ARCHIVE_PACKAGE, autoArchivePackage) - } - if (wasShutUpConfig != null) { - putExtra(EXTRA_PACKAGE_NAME, wasShutUpConfig.packageName) - } } - val restorePendingIntent = PendingIntent.getBroadcast( - context, - 12345, - restoreIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - - val title = "Shut-Up active" - val text = "Do you want to restore now?" - val criticalText = "Restore Now" - - val notification = - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { - val builder = android.app.Notification.Builder(context, "shutup_restore_channel") - .setSmallIcon(com.sameerasw.essentials.R.drawable.rounded_code_24) - .setContentTitle(title) - .setContentText(text) - .setCategory(android.app.Notification.CATEGORY_SERVICE) - .setVisibility(android.app.Notification.VISIBILITY_PUBLIC) - .setOnlyAlertOnce(true) - .setOngoing(true) - .addAction( - android.app.Notification.Action.Builder( - android.graphics.drawable.Icon.createWithResource( - context, - com.sameerasw.essentials.R.drawable.rounded_code_24 - ), - "Restore Now", - restorePendingIntent - ).build() - ) - - try { - val setShortCriticalText = builder.javaClass.getMethod( - "setShortCriticalText", - CharSequence::class.java - ) - setShortCriticalText.invoke(builder, criticalText) - } catch (_: Throwable) { - } - - val extras = android.os.Bundle() - extras.putBoolean("android.requestPromotedOngoing", true) - extras.putString("android.shortCriticalText", criticalText) - builder.addExtras(extras) - builder.build() - } else { - NotificationCompat.Builder(context, "shutup_restore_channel") - .setSmallIcon(com.sameerasw.essentials.R.drawable.rounded_code_24) - .setContentTitle(title) - .setContentText(text) - .setPriority(NotificationCompat.PRIORITY_MAX) - .setCategory(NotificationCompat.CATEGORY_SERVICE) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setOnlyAlertOnce(true) - .setOngoing(true) - .addAction( - com.sameerasw.essentials.R.drawable.rounded_code_24, - "Restore Now", - restorePendingIntent - ) - .addExtras(android.os.Bundle().apply { - putBoolean("android.requestPromotedOngoing", true) - putString("android.shortCriticalText", criticalText) - }) - .build() - } - - notificationManager.notify(NOTIFICATION_ID_SHUTUP_RESTORE, notification) } - private fun cancelRestoreNotification() { - val notificationManager = - context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.cancel(NOTIFICATION_ID_SHUTUP_RESTORE) - } - - private fun restoreShutUpSettings( - repository: com.sameerasw.essentials.data.repository.SettingsRepository, - wasShutUpConfig: com.sameerasw.essentials.domain.model.ShutUpAppConfig?, - autoArchivePackage: String? = null, - forceRestore: Boolean = false - ) { - val originalSettings = repository.getShutUpOriginalSettings() - if (originalSettings.isEmpty()) { - if (autoArchivePackage != null) { - startAutoArchiveCountdown(autoArchivePackage) - } - return - } - - val mode = repository.getShutUpRestoreMode() - if (mode == "Notify" && !forceRestore) { - scope.launch { - val delaySeconds = repository.getShutUpRestoreDelay() - delay(delaySeconds * 1000L) - showRestoreNotification(wasShutUpConfig, autoArchivePackage) - } - return - } - - scope.launch { - if (!forceRestore) { - // Delay to ensure the app has fully settled before restoring system settings - val delaySeconds = repository.getShutUpRestoreDelay() - delay(delaySeconds * 1000L) - } - - val canWriteSecure = - com.sameerasw.essentials.utils.PermissionUtils.canWriteSecureSettings(context) - val canWriteSystem = Settings.System.canWrite(context) - - originalSettings.forEach { (prefixedKey, value) -> - try { - val parts = prefixedKey.split(":", limit = 2) - if (parts.size < 2) return@forEach - - val table = parts[0] - val key = parts[1] - - when (table) { - "global" -> { - if (canWriteSecure) { - Settings.Global.putString(context.contentResolver, key, value) - } - } - - "secure" -> { - if (canWriteSecure) { - Settings.Secure.putString(context.contentResolver, key, value) - } - } - - "system" -> { - if (canWriteSystem) { - Settings.System.putString(context.contentResolver, key, value) - } - } - } - } catch (e: Exception) { - Log.e("AppFlowHandler", "Failed to restore setting $prefixedKey", e) - } - } - - // Clear original settings after restoration - repository.saveShutUpOriginalSettings(emptyMap()) - - // Wait a bit and Restart Shizuku as ADB might have been toggled back on - if (wasShutUpConfig != null && wasShutUpConfig.disableWirelessDebugging && repository.isShutUpAttemptShizukuRestartEnabled()) { - delay(1000) - restartShizuku() - } - - android.widget.Toast.makeText( - context, - context.getString(com.sameerasw.essentials.R.string.shut_up_toast_restored), - android.widget.Toast.LENGTH_SHORT - ).show() - - // Start auto-archive countdown AFTER everything is restored and Shizuku is starting - if (autoArchivePackage != null) { - startAutoArchiveCountdown(autoArchivePackage) - } - } - } - private fun restartShizuku() { - val settingsRepository = - com.sameerasw.essentials.data.repository.SettingsRepository(context) - val token = settingsRepository.getShizukuAuthToken() - if (token.isEmpty()) { - Log.w("AppFlowHandler", "Shizuku auth token is missing, cannot restart Shizuku") - return - } - try { - val intent = Intent("moe.shizuku.privileged.api.START").apply { - `package` = "moe.shizuku.privileged.api" - putExtra("auth", token) - addFlags(Intent.FLAG_INCLUDE_STOPPED_PACKAGES) - } - context.sendBroadcast(intent) + private fun isMediaPlaying(packageName: String): Boolean { + return try { + val msm = context.getSystemService(Context.MEDIA_SESSION_SERVICE) as? android.media.session.MediaSessionManager + val sessions = msm?.getActiveSessions(notificationListenerComponent) + sessions?.any { + it.packageName == packageName && + it.playbackState?.state == android.media.session.PlaybackState.STATE_PLAYING + } ?: false } catch (e: Exception) { - Log.e("AppFlowHandler", "Failed to restart Shizuku", e) + false } } companion object { - const val ACTION_FREEZE_NOW = "com.sameerasw.essentials.ACTION_FREEZE_NOW" - const val ACTION_ABORT_FREEZE = "com.sameerasw.essentials.ACTION_ABORT_FREEZE" - const val ACTION_RESTORE_NOW = "com.sameerasw.essentials.ACTION_RESTORE_NOW" - const val EXTRA_PACKAGE_NAME = "package_name" - const val EXTRA_AUTO_ARCHIVE_PACKAGE = "auto_archive_package" - const val NOTIFICATION_ID_SHUTUP_RESTORE = 9999 + @Volatile + private var INSTANCE: AppFlowHandler? = null + + fun getInstance(context: Context): AppFlowHandler { + return INSTANCE ?: synchronized(this) { + INSTANCE ?: AppFlowHandler(context.applicationContext).also { INSTANCE = it } + } + } } } 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 363d44654..883fbb50e 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 @@ -224,7 +224,7 @@ class ScreenOffAccessibilityService : AccessibilityService(), SensorEventListene flashlightHandler = FlashlightHandler(this, serviceScope) notificationLightingHandler = NotificationLightingHandler(this) buttonRemapHandler = ButtonRemapHandler(this, flashlightHandler) - appFlowHandler = AppFlowHandler(this, this) + appFlowHandler = AppFlowHandler.getInstance(this) ambientGlanceHandler = AmbientGlanceHandler(this) aodForceTurnOffHandler = AodForceTurnOffHandler(this) omniGestureOverlayHandler = OmniGestureOverlayHandler(this) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/activities/FeatureSettingsActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/FeatureSettingsActivity.kt index 8ebca97cc..606f629e1 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/activities/FeatureSettingsActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/FeatureSettingsActivity.kt @@ -234,6 +234,9 @@ class FeatureSettingsActivity : AppCompatActivity() { val isNotificationListenerEnabled by viewModel.isNotificationListenerEnabled val isReadPhoneStateEnabled by viewModel.isReadPhoneStateEnabled val isShizukuPermissionGranted by viewModel.isShizukuPermissionGranted + val isWriteSettingsEnabled by viewModel.isWriteSettingsEnabled + val isUsageStatsPermissionGranted by viewModel.isUsageStatsPermissionGranted + val isPostNotificationsEnabled by viewModel.isPostNotificationsEnabled var watchAdbWifiEnabled by remember { mutableStateOf(prefs.getBoolean("watch_adb_wifi_enabled", false)) @@ -311,7 +314,10 @@ class FeatureSettingsActivity : AppCompatActivity() { isNotificationLightingAccessibilityEnabled, isNotificationListenerEnabled, isReadPhoneStateEnabled, - isShizukuPermissionGranted + isShizukuPermissionGranted, + isWriteSettingsEnabled, + isUsageStatsPermissionGranted, + isPostNotificationsEnabled ) { val hasMissingPermissions = when (featureId) { "Screen off widget" -> !isAccessibilityEnabled @@ -332,6 +338,8 @@ class FeatureSettingsActivity : AppCompatActivity() { "Screen refresh rate" -> !com.sameerasw.essentials.utils.ShellUtils.hasPermission( context ) + "Shut-Up!" -> !isWriteSecureSettingsEnabled || !isWriteSettingsEnabled || !isUsageStatsPermissionGranted || !isPostNotificationsEnabled + "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 @@ -348,7 +356,6 @@ class FeatureSettingsActivity : AppCompatActivity() { context ) - "Shut-Up!" -> !isWriteSecureSettingsEnabled || !viewModel.isUsageStatsPermissionGranted.value "Power and Battery" -> !isWriteSecureSettingsEnabled "Networks" -> !isWriteSecureSettingsEnabled && !com.sameerasw.essentials.utils.ShellUtils.hasPermission( context @@ -525,7 +532,6 @@ class FeatureSettingsActivity : AppCompatActivity() { modifier = Modifier.padding(top = 16.dp) ) } - val children = FeatureRegistry.getFilteredFeatures( context, viewModel.isEnableUnsupportedFeatures.value @@ -544,6 +550,7 @@ class FeatureSettingsActivity : AppCompatActivity() { listOf( "Text and animations", "Screen refresh rate", + "Per app refresh rate", "Navigation" ), listOf( @@ -687,7 +694,8 @@ class FeatureSettingsActivity : AppCompatActivity() { context ) - "Shut-Up!" -> !isWriteSecureSettingsEnabled || !viewModel.isUsageStatsPermissionGranted.value + "Shut-Up!" -> !isWriteSecureSettingsEnabled || !viewModel.isWriteSettingsEnabled.value || !viewModel.isUsageStatsPermissionGranted.value || !viewModel.isPostNotificationsEnabled.value + "Per app refresh rate" -> (if (viewModel.isUseUsageAccess.value) !viewModel.isUsageStatsPermissionGranted.value else !isAccessibilityEnabled) || !viewModel.isShizukuPermissionGranted.value "Power and Battery" -> !isWriteSecureSettingsEnabled "Networks" -> !isWriteSecureSettingsEnabled && !com.sameerasw.essentials.utils.ShellUtils.hasPermission( context @@ -1003,7 +1011,13 @@ class FeatureSettingsActivity : AppCompatActivity() { highlightSetting = highlightSetting ) } - + "Shut-Up!" -> { + ShutUpSettingsUI( + viewModel = viewModel, + modifier = Modifier.padding(top = 16.dp), + highlightSetting = highlightSetting + ) + } "Always on Display" -> { AlwaysOnDisplaySettingsUI( viewModel = viewModel, @@ -1043,12 +1057,11 @@ class FeatureSettingsActivity : AppCompatActivity() { highlightSetting = highlightSetting ) } - "Shut-Up!" -> { ShutUpSettingsUI( viewModel = viewModel, modifier = Modifier.padding(top = 16.dp), - highlightKey = highlightSetting + highlightSetting = highlightSetting ) } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/activities/ShutUpShortcutActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/ShutUpShortcutActivity.kt index 860749caa..73a837a85 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/activities/ShutUpShortcutActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/ShutUpShortcutActivity.kt @@ -9,11 +9,10 @@ package com.sameerasw.essentials -import android.content.ContentResolver + import android.content.Intent import android.os.Bundle -import android.provider.Settings -import android.util.Log + import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -28,8 +27,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.scale import androidx.lifecycle.lifecycleScope import com.sameerasw.essentials.data.repository.SettingsRepository -import com.sameerasw.essentials.domain.model.ShutUpAppConfig + import com.sameerasw.essentials.ui.theme.EssentialsTheme + +import com.sameerasw.essentials.utils.ShutUpManager import com.sameerasw.essentials.utils.PermissionUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -86,7 +87,7 @@ class ShutUpShortcutActivity : ComponentActivity() { if (config != null && config.isEnabled) { if (PermissionUtils.canWriteSecureSettings(this@ShutUpShortcutActivity)) { - applyShutUpSettings(config, settingsRepository) + ShutUpManager.applyShutUpSettings(this@ShutUpShortcutActivity, config) withContext(Dispatchers.Main) { Toast.makeText( this@ShutUpShortcutActivity, @@ -105,146 +106,8 @@ class ShutUpShortcutActivity : ComponentActivity() { } } - private suspend fun applyShutUpSettings( - config: ShutUpAppConfig, - repository: SettingsRepository - ) { - withContext(Dispatchers.IO) { - val originalSettings = mutableMapOf() - - if (config.disableDevOptions) { - // Backup all relevant dev settings because disabling the main toggle might reset them - val secureSettings = listOf( - "anr_show_background", - "bugreport_in_power_menu", - "display_density_forced", - "mock_location", - "secure_overlay_settings", - "usb_audio_automatic_routing_disabled" - ) - val systemSettings = listOf("show_touches", "show_key_presses") - val globalSettings = listOf( - "adb_allowed_connection_time", - "adb_enabled", - "adb_wifi_enabled", - "always_finish_activities", - "animator_duration_scale", - "app_standby_enabled", - "cached_apps_freezer", - "default_install_location", - "development_settings_enabled", - "disable_window_blurs", - "enable_freeform_support", - "enable_non_resizable_multi_window", - "force_allow_on_external", - "force_desktop_mode_on_external_displays", - "force_resizable_activities", - "mobile_data_always_on", - "stay_on_while_plugged_in", - "usb_mass_storage_enabled", - "wait_for_debugger", - "wifi_display_certification_on", - "wifi_display_on", - "wifi_scan_always_enabled", - "window_animation_scale" - ) - - secureSettings.forEach { key -> - safeReadSetting(contentResolver, SettingsTable.SECURE, key) - ?.let { originalSettings["secure:$key"] = it } - } - systemSettings.forEach { key -> - safeReadSetting(contentResolver, SettingsTable.SYSTEM, key) - ?.let { originalSettings["system:$key"] = it } - } - globalSettings.forEach { key -> - safeReadSetting(contentResolver, SettingsTable.GLOBAL, key) - ?.let { originalSettings["global:$key"] = it } - } - - // Disable dev options - Settings.Global.putString( - contentResolver, - Settings.Global.DEVELOPMENT_SETTINGS_ENABLED, - "0" - ) - } - - if (config.disableUsbDebugging || config.disableWirelessDebugging) { - com.sameerasw.essentials.utils.ShizukuUtils.stopShizuku(this@ShutUpShortcutActivity) - } - - // Always explicitly disable USB debugging if requested, even if dev options were already disabled - // as some apps check this specific setting directly. - if (config.disableUsbDebugging) { - val current = - safeReadSetting( - contentResolver, - SettingsTable.GLOBAL, - Settings.Global.ADB_ENABLED - ) - ?: "0" - if (current == "1") { - if (!originalSettings.containsKey("global:${Settings.Global.ADB_ENABLED}")) { - originalSettings["global:${Settings.Global.ADB_ENABLED}"] = "1" - } - Settings.Global.putString(contentResolver, Settings.Global.ADB_ENABLED, "0") - } - } - if (config.disableWirelessDebugging) { - val current = - safeReadSetting(contentResolver, SettingsTable.GLOBAL, "adb_wifi_enabled") - ?: "0" - if (current == "1") { - if (!originalSettings.containsKey("global:adb_wifi_enabled")) { - originalSettings["global:adb_wifi_enabled"] = "1" - } - Settings.Global.putString(contentResolver, "adb_wifi_enabled", "0") - } - } - if (config.disableAccessibility) { - val current = safeReadSetting( - contentResolver, - SettingsTable.SECURE, - Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES - ) - if (!current.isNullOrEmpty()) { - originalSettings["secure:${Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES}"] = - current - Settings.Secure.putString( - contentResolver, - Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES, - "" - ) - } - } - - if (originalSettings.isNotEmpty()) { - repository.saveShutUpOriginalSettings(originalSettings) - } - } - } - - private enum class SettingsTable { SYSTEM, SECURE, GLOBAL } - - // Android 12+ throws SecurityException reading @hide settings that aren't - // @Readable (e.g. show_key_presses). WRITE_SECURE_SETTINGS doesn't cover reads. - private fun safeReadSetting( - resolver: ContentResolver, - table: SettingsTable, - key: String - ): String? = try { - when (table) { - SettingsTable.SYSTEM -> Settings.System.getString(resolver, key) - SettingsTable.SECURE -> Settings.Secure.getString(resolver, key) - SettingsTable.GLOBAL -> Settings.Global.getString(resolver, key) - } - } catch (e: SecurityException) { - Log.w("ShutUpShortcut", "Skipping unreadable setting $table:$key", e) - null - } private fun launchApp(packageName: String) { val intent = packageManager.getLaunchIntentForPackage(packageName) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/SetupFeatures.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/SetupFeatures.kt index bfa4f0fd7..8752640cc 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/composables/SetupFeatures.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/SetupFeatures.kt @@ -88,6 +88,7 @@ import com.sameerasw.essentials.FeatureSettingsActivity import com.sameerasw.essentials.R import com.sameerasw.essentials.domain.registry.FeatureRegistry import com.sameerasw.essentials.domain.registry.PermissionRegistry +import com.sameerasw.essentials.utils.PermissionUtils import com.sameerasw.essentials.ui.activities.YourAndroidActivity import com.sameerasw.essentials.ui.components.FavoriteCarousel import com.sameerasw.essentials.ui.components.buttons.ListExpandToggleButton @@ -403,47 +404,7 @@ fun SetupFeatures( } } - R.string.feat_shut_up_title -> { - if (!isWriteSecureSettingsEnabled) { - missing.add( - PermissionItem( - iconRes = R.drawable.rounded_security_24, - title = R.string.perm_write_secure_title, - description = R.string.perm_write_secure_desc_common, - dependentFeatures = PermissionRegistry.getFeatures("WRITE_SECURE_SETTINGS"), - actionLabel = R.string.perm_action_grant, - action = { viewModel.requestWriteSecureSettingsPermission(context) }, - isGranted = isWriteSecureSettingsEnabled - ) - ) - } - if (!isWriteSettingsEnabled) { - missing.add( - PermissionItem( - iconRes = R.drawable.rounded_settings_24, - title = R.string.perm_write_settings_title, - description = R.string.perm_write_settings_desc, - dependentFeatures = PermissionRegistry.getFeatures("WRITE_SETTINGS"), - actionLabel = R.string.perm_action_grant, - action = { viewModel.requestWriteSettingsPermission(context) }, - isGranted = isWriteSettingsEnabled - ) - ) - } - if (!viewModel.isUsageStatsPermissionGranted.value) { - missing.add( - PermissionItem( - iconRes = R.drawable.rounded_app_registration_24, - title = R.string.perm_usage_stats_title, - description = R.string.perm_usage_stats_desc, - dependentFeatures = PermissionRegistry.getFeatures("USAGE_STATS"), - actionLabel = R.string.perm_action_grant, - action = { viewModel.requestUsageStatsPermission(context) }, - isGranted = viewModel.isUsageStatsPermissionGranted.value - ) - ) - } - } + R.string.feat_screen_locked_security_title -> { if (isRootEnabled) { @@ -516,6 +477,84 @@ fun SetupFeatures( } } + R.string.feat_shut_up_title -> { + if (!isWriteSecureSettingsEnabled) { + missing.add( + PermissionItem( + iconRes = R.drawable.rounded_security_24, + title = R.string.perm_write_secure_title, + description = R.string.perm_write_secure_desc_common, + dependentFeatures = PermissionRegistry.getFeatures("WRITE_SECURE_SETTINGS"), + actionLabel = R.string.perm_action_copy_adb, + action = { + val adbCommand = + "adb shell pm grant com.sameerasw.essentials android.permission.WRITE_SECURE_SETTINGS" + val clipboard = + context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("adb_command", adbCommand) + clipboard.setPrimaryClip(clip) + }, + secondaryActionLabel = R.string.perm_action_check, + secondaryAction = { + viewModel.isWriteSecureSettingsEnabled.value = + PermissionUtils.canWriteSecureSettings(context) + }, + isGranted = isWriteSecureSettingsEnabled + ) + ) + } + if (!isWriteSettingsEnabled) { + missing.add( + PermissionItem( + iconRes = R.drawable.rounded_settings_24, + title = R.string.perm_write_settings_title, + description = R.string.perm_write_settings_desc, + dependentFeatures = PermissionRegistry.getFeatures("WRITE_SETTINGS"), + actionLabel = R.string.perm_action_enable, + action = { + PermissionUtils.openWriteSettings(context) + }, + isGranted = isWriteSettingsEnabled + ) + ) + } + if (!viewModel.isUsageStatsPermissionGranted.value) { + missing.add( + PermissionItem( + iconRes = R.drawable.rounded_data_usage_24, + title = R.string.perm_usage_stats_title, + description = R.string.perm_usage_stats_desc, + dependentFeatures = PermissionRegistry.getFeatures("USAGE_STATS"), + actionLabel = R.string.perm_action_grant, + action = { + com.sameerasw.essentials.utils.PermissionUtils.openUsageStatsSettings(context) + }, + isGranted = viewModel.isUsageStatsPermissionGranted.value + ) + ) + } + if (!viewModel.isPostNotificationsEnabled.value) { + missing.add( + PermissionItem( + iconRes = R.drawable.rounded_notifications_unread_24, + title = R.string.permission_post_notifications_title, + description = R.string.permission_post_notifications_desc, + dependentFeatures = PermissionRegistry.getFeatures("POST_NOTIFICATIONS"), + actionLabel = R.string.perm_action_grant, + action = { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + (context as? Activity)?.requestPermissions( + arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), + 1 + ) + } + }, + isGranted = viewModel.isPostNotificationsEnabled.value + ) + ) + } + } + R.string.feat_call_vibrations_title -> { if (!viewModel.isReadPhoneStateEnabled.value) { missing.add( @@ -792,6 +831,68 @@ fun SetupFeatures( ) ) + R.string.feat_shut_up_title -> listOf( + PermissionItem( + iconRes = R.drawable.rounded_security_24, + title = R.string.perm_write_secure_title, + description = R.string.perm_write_secure_desc_common, + dependentFeatures = PermissionRegistry.getFeatures("WRITE_SECURE_SETTINGS"), + actionLabel = R.string.perm_action_copy_adb, + action = { + val adbCommand = + "adb shell pm grant com.sameerasw.essentials android.permission.WRITE_SECURE_SETTINGS" + val clipboard = + context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("adb_command", adbCommand) + clipboard.setPrimaryClip(clip) + }, + secondaryActionLabel = R.string.perm_action_check, + secondaryAction = { + viewModel.isWriteSecureSettingsEnabled.value = + PermissionUtils.canWriteSecureSettings(context) + }, + isGranted = isWriteSecureSettingsEnabled + ), + PermissionItem( + iconRes = R.drawable.rounded_settings_24, + title = R.string.perm_write_settings_title, + description = R.string.perm_write_settings_desc, + dependentFeatures = PermissionRegistry.getFeatures("WRITE_SETTINGS"), + actionLabel = R.string.perm_action_enable, + action = { + PermissionUtils.openWriteSettings(context) + }, + isGranted = isWriteSettingsEnabled + ), + PermissionItem( + iconRes = R.drawable.rounded_data_usage_24, + title = R.string.perm_usage_stats_title, + description = R.string.perm_usage_stats_desc, + dependentFeatures = PermissionRegistry.getFeatures("USAGE_STATS"), + actionLabel = R.string.perm_action_grant, + action = { + com.sameerasw.essentials.utils.PermissionUtils.openUsageStatsSettings(context) + }, + isGranted = viewModel.isUsageStatsPermissionGranted.value + ), + PermissionItem( + iconRes = R.drawable.rounded_notifications_unread_24, + title = R.string.permission_post_notifications_title, + description = R.string.permission_post_notifications_desc, + dependentFeatures = PermissionRegistry.getFeatures("POST_NOTIFICATIONS"), + actionLabel = R.string.perm_action_grant, + action = { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + (context as? Activity)?.requestPermissions( + arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), + 1 + ) + } + }, + isGranted = viewModel.isPostNotificationsEnabled.value + ) + ) + R.string.feat_call_vibrations_title -> listOf( PermissionItem( iconRes = R.drawable.rounded_mobile_24, diff --git a/app/src/main/java/com/sameerasw/essentials/ui/core/cards/FeatureCard.kt b/app/src/main/java/com/sameerasw/essentials/ui/core/cards/FeatureCard.kt index bfcf5cab2..7aa0a262f 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/core/cards/FeatureCard.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/core/cards/FeatureCard.kt @@ -124,10 +124,12 @@ fun FeatureCard( HapticUtil.performVirtualKeyHaptic(view) onClick() }, - onLongClick = { - HapticUtil.performVirtualKeyHaptic(view) - showMenu = true - }, + onLongClick = if (onPinToggle != null || onHelpClick != null || additionalMenuItems != null) { + { + HapticUtil.performVirtualKeyHaptic(view) + showMenu = true + } + } else null, verticalAlignment = Alignment.CenterVertically, modifier = modifier .alpha(alpha) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/audio/ShutUpSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/audio/ShutUpSettingsUI.kt index b437fdbd3..87e9a6c36 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/audio/ShutUpSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/audio/ShutUpSettingsUI.kt @@ -9,6 +9,13 @@ package com.sameerasw.essentials.ui.features.system +import android.Manifest +import android.content.Context +import android.content.Intent +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth @@ -18,14 +25,13 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton 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.material3.ToggleButton +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -38,8 +44,13 @@ import com.sameerasw.essentials.ui.core.cards.FeatureCard import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer import com.sameerasw.essentials.ui.core.pickers.RestoreModePicker import com.sameerasw.essentials.ui.core.sheets.AppSelectionSheet +import com.sameerasw.essentials.ui.core.sheets.PermissionItem +import com.sameerasw.essentials.ui.core.sheets.PermissionsBottomSheet +import com.sameerasw.essentials.ui.core.sheets.SingleAppSelectionSheet import com.sameerasw.essentials.ui.core.sheets.ShutUpPerAppSettingsSheet import com.sameerasw.essentials.utils.AppUtil +import com.sameerasw.essentials.utils.HapticUtil +import com.sameerasw.essentials.utils.PermissionUtils import com.sameerasw.essentials.viewmodels.MainViewModel @OptIn(ExperimentalMaterial3ExpressiveApi::class) @@ -47,20 +58,127 @@ import com.sameerasw.essentials.viewmodels.MainViewModel fun ShutUpSettingsUI( viewModel: MainViewModel, modifier: Modifier = Modifier, - highlightKey: String? = null + highlightSetting: String? = null ) { val context = LocalContext.current + val view = LocalView.current + + var showPermissionSheet by remember { mutableStateOf(false) } var isAppSelectionSheetOpen by remember { mutableStateOf(false) } - var selectedConfigForEditing by remember { mutableStateOf(null) } + var isEditSheetOpen by remember { mutableStateOf(false) } + var editingPackageName by remember { mutableStateOf("") } + var editingConfig by remember { mutableStateOf(null) } + + // Permission states checked on composition and changes + var hasSecureSettings by remember { mutableStateOf(PermissionUtils.canWriteSecureSettings(context)) } + var hasWriteSettings by remember { mutableStateOf(PermissionUtils.canWriteSystemSettings(context)) } + var hasUsageStats by remember { mutableStateOf(PermissionUtils.hasUsageStatsPermission(context)) } + var hasNotifications by remember { mutableStateOf(PermissionUtils.isPostNotificationsEnabled(context)) } - val configs by viewModel.shutUpConfigs + val updatePermissionStates = { + viewModel.check(context) + hasSecureSettings = PermissionUtils.canWriteSecureSettings(context) + hasWriteSettings = PermissionUtils.canWriteSystemSettings(context) + hasUsageStats = PermissionUtils.hasUsageStatsPermission(context) + hasNotifications = PermissionUtils.isPostNotificationsEnabled(context) + } + + val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = androidx.lifecycle.LifecycleEventObserver { _, event -> + if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) { + updatePermissionStates() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + LaunchedEffect(Unit) { + updatePermissionStates() + } + + if (showPermissionSheet) { + val missingPermissions = mutableListOf().apply { + if (!hasSecureSettings) add("WRITE_SECURE_SETTINGS") + if (!hasWriteSettings) add("WRITE_SETTINGS") + if (!hasUsageStats) add("USAGE_STATS") + if (!hasNotifications) add("POST_NOTIFICATIONS") + } + + if (missingPermissions.isNotEmpty()) { + PermissionsBottomSheet( + onDismissRequest = { showPermissionSheet = false }, + featureTitle = R.string.feat_shut_up_title, + permissions = com.sameerasw.essentials.utils.PermissionUIHelper.getPermissionItems( + missingPermissions, + context, + viewModel, + context as? android.app.Activity + ) + ) + } else { + showPermissionSheet = false + } + } Column( modifier = modifier .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { + Text( + text = "Monitoring Service", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 16.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + val onToggleShutUpService: (Boolean) -> Unit = { enabled -> + HapticUtil.performVirtualKeyHaptic(view) + if (enabled) { + // Recheck permissions + hasSecureSettings = PermissionUtils.canWriteSecureSettings(context) + hasWriteSettings = PermissionUtils.canWriteSystemSettings(context) + hasUsageStats = PermissionUtils.hasUsageStatsPermission(context) + hasNotifications = PermissionUtils.isPostNotificationsEnabled(context) + + if (hasSecureSettings && hasWriteSettings && hasUsageStats && hasNotifications) { + viewModel.setShutUpServiceEnabled(true, context) + } else { + showPermissionSheet = true + } + } else { + viewModel.setShutUpServiceEnabled(false, context) + } + } + + RoundedCardContainer( + modifier = Modifier, + spacing = 2.dp, + cornerRadius = 24.dp + ) { + FeatureCard( + title = "Enable Shut-Up! Service", + description = "Runs in the background and applies security rules on target app launch", + iconRes = R.drawable.rounded_security_24, + isEnabled = viewModel.isShutUpServiceEnabled.value, + showToggle = true, + hasMoreSettings = false, + onToggle = onToggleShutUpService, + onClick = { onToggleShutUpService(!viewModel.isShutUpServiceEnabled.value) } + ) + } + + Text( + text = "App Configurations", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 16.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) RoundedCardContainer( modifier = Modifier, @@ -89,113 +207,141 @@ fun ShutUpSettingsUI( iconRes = R.drawable.rounded_app_registration_24, isEnabled = true, showToggle = false, - hasMoreSettings = true, + hasMoreSettings = false, onToggle = {}, - onClick = { isAppSelectionSheetOpen = true } + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + isAppSelectionSheetOpen = true + } ) } - - RoundedCardContainer( - modifier = Modifier, - spacing = 2.dp, - cornerRadius = 24.dp - ) { - configs.forEach { config -> - val appName = remember(config.packageName) { - try { - val appInfo = - context.packageManager.getApplicationInfo(config.packageName, 0) - context.packageManager.getApplicationLabel(appInfo).toString() - } catch (e: Exception) { - config.packageName - } - } - - val appIconPainter = remember(config.packageName) { - try { - val drawable = context.packageManager.getApplicationIcon(config.packageName) - androidx.compose.ui.graphics.painter.BitmapPainter( - AppUtil.drawableToBitmap(drawable).asImageBitmap() - ) - } catch (e: Exception) { - null - } - } - - FeatureCard( - title = appName, - description = config.packageName, - isEnabled = true, - onToggle = {}, - onClick = { selectedConfigForEditing = config }, - iconPainter = appIconPainter, - showToggle = false, - hasMoreSettings = true, - customTrailingContent = { - IconButton( - onClick = { - viewModel.createShutUpShortcut(context, config) - } - ) { - Icon( - painter = painterResource(id = R.drawable.rounded_add_24), - contentDescription = stringResource(R.string.action_create_shortcut), - tint = MaterialTheme.colorScheme.primary - ) + val configs by viewModel.shutUpConfigs + if (configs.isNotEmpty()) { + RoundedCardContainer( + modifier = Modifier, + spacing = 2.dp, + cornerRadius = 24.dp + ) { + configs.forEach { config -> + ShutUpAppItem( + config = config, + viewModel = viewModel, + onEditClick = { packageName, cfg -> + editingPackageName = packageName + editingConfig = cfg + isEditSheetOpen = true } - }, - additionalMenuItems = { onDismiss -> - SegmentedDropdownMenuItem( - text = { Text(stringResource(R.string.action_remove)) }, - onClick = { - onDismiss() - viewModel.removeShutUpConfig(config.packageName) - }, - leadingIcon = { - Icon( - painter = painterResource(id = R.drawable.rounded_delete_24), - contentDescription = null - ) - } - ) - } - ) + ) + } } } - Text( - text = stringResource(R.string.shut_up_description), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(16.dp), - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - if (isAppSelectionSheetOpen) { - AppSelectionSheet( + SingleAppSelectionSheet( onDismissRequest = { isAppSelectionSheetOpen = false }, - onLoadApps = { ctx -> - viewModel.shutUpConfigs.value.map { AppSelection(it.packageName, true) } - }, - onSaveApps = { ctx, apps -> viewModel.saveShutUpSelectedApps(ctx, apps) } + onAppSelected = { app -> + isAppSelectionSheetOpen = false + editingPackageName = app.packageName + editingConfig = configs.find { it.packageName == app.packageName } + isEditSheetOpen = true + } ) } - if (selectedConfigForEditing != null) { - val frozenApps = remember { viewModel.loadFreezeSelectedApps(context) } - val isFrozen = remember(selectedConfigForEditing) { - frozenApps.any { it.packageName == selectedConfigForEditing?.packageName } + if (isEditSheetOpen) { + val isFrozen = remember(editingPackageName) { + com.sameerasw.essentials.utils.FreezeManager.isAppFrozen(context, editingPackageName) } - ShutUpPerAppSettingsSheet( - onDismissRequest = { selectedConfigForEditing = null }, - config = configs.find { it.packageName == selectedConfigForEditing?.packageName } - ?: selectedConfigForEditing!!, - onConfigChanged = { viewModel.updateShutUpConfig(it) }, - onCreateShortcut = { viewModel.createShutUpShortcut(context, it) }, + onDismissRequest = { isEditSheetOpen = false }, + config = editingConfig ?: ShutUpAppConfig(packageName = editingPackageName), + onConfigChanged = { updatedConfig -> + viewModel.updateShutUpConfig(updatedConfig) + editingConfig = updatedConfig + }, + onCreateShortcut = { config -> + viewModel.createShutUpShortcut(context, config) + }, isFrozen = isFrozen, viewModel = viewModel ) } } } + +@Composable +private fun ShutUpAppItem( + config: ShutUpAppConfig, + viewModel: MainViewModel, + onEditClick: (String, ShutUpAppConfig) -> Unit +) { + val context = LocalContext.current + 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 enabledCount = config.settings.count { it.enabled } + val descText = "${enabledCount} settings configured" + + (if (config.autoArchive) " • Auto-Freeze" else "") + + (if (config.attemptShizukuRestart) " • Shizuku restart" else "") + + FeatureCard( + title = appName, + description = descText, + isEnabled = config.isEnabled, + showToggle = true, + onToggle = { isChecked -> + viewModel.updateShutUpConfig(config.copy(isEnabled = isChecked)) + }, + onClick = { + onEditClick(config.packageName, config) + }, + iconPainter = appIconPainter, + hasMoreSettings = true, + additionalMenuItems = { onDismiss -> + SegmentedDropdownMenuItem( + text = { Text("Create Shortcut") }, + onClick = { + onDismiss() + viewModel.createShutUpShortcut(context, config) + }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.rounded_link_24), + contentDescription = null + ) + } + ) + SegmentedDropdownMenuItem( + text = { Text(stringResource(R.string.action_remove)) }, + onClick = { + onDismiss() + viewModel.removeShutUpConfig(config.packageName) + }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.rounded_delete_24), + contentDescription = null + ) + } + ) + } + ) +} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/audio/sheets/ShutUpPerAppSettingsSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/audio/sheets/ShutUpPerAppSettingsSheet.kt index f269c30ed..2de59b343 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/audio/sheets/ShutUpPerAppSettingsSheet.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/audio/sheets/ShutUpPerAppSettingsSheet.kt @@ -38,6 +38,11 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.sameerasw.essentials.R import com.sameerasw.essentials.domain.model.ShutUpAppConfig +import com.sameerasw.essentials.domain.model.copy +import com.sameerasw.essentials.domain.model.disableAccessibility +import com.sameerasw.essentials.domain.model.disableDevOptions +import com.sameerasw.essentials.domain.model.disableUsbDebugging +import com.sameerasw.essentials.domain.model.disableWirelessDebugging import com.sameerasw.essentials.ui.core.cards.IconToggleItem import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer import com.sameerasw.essentials.viewmodels.MainViewModel @@ -57,7 +62,7 @@ fun ShutUpPerAppSettingsSheet( var currentConfig by remember(config) { mutableStateOf(config) } var showShizukuRestartWarning by remember { mutableStateOf(false) } - val isAttemptShizukuRestart by viewModel.isShutUpAttemptShizukuRestart + val isAttemptShizukuRestart = currentConfig.attemptShizukuRestart if (showShizukuRestartWarning) { AlertDialog( @@ -67,7 +72,7 @@ fun ShutUpPerAppSettingsSheet( confirmButton = { TextButton(onClick = { showShizukuRestartWarning = false - val newConfig = currentConfig.copy(autoArchive = true) + val newConfig = currentConfig.copy(autoArchive = true, attemptShizukuRestart = true) currentConfig = newConfig onConfigChanged(newConfig) }) { @@ -140,14 +145,9 @@ fun ShutUpPerAppSettingsSheet( title = stringResource(R.string.shut_up_attempt_shizuku_restart), isChecked = isAttemptShizukuRestart, onCheckedChange = { - viewModel.setShutUpAttemptShizukuRestartEnabled(it) - if (it && viewModel.shizukuAuthToken.value.isEmpty()) { - android.widget.Toast.makeText( - context, - "Please enter the Shizuku auth token in Essentials settings", - android.widget.Toast.LENGTH_LONG - ).show() - } + val newConfig = currentConfig.copy(attemptShizukuRestart = it) + currentConfig = newConfig + onConfigChanged(newConfig) } ) } 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 2ce27e153..609ef94a2 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/ServiceUtils.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/ServiceUtils.kt @@ -34,7 +34,7 @@ object ServiceUtils { */ fun startRequiredServices(context: Context) { val settingsRepository = SettingsRepository(context) - + startShutUpServiceIfNeeded(context, settingsRepository) startAppDetectionServiceIfNeeded(context, settingsRepository) startBatteryNotificationServiceIfNeeded(context, settingsRepository) schedulePeriodicAppUpdateCheck(context, settingsRepository) @@ -99,6 +99,7 @@ object ServiceUtils { } } + fun schedulePeriodicAppUpdateCheck( context: Context, settingsRepository: SettingsRepository @@ -123,4 +124,22 @@ object ServiceUtils { ) } } + private fun startShutUpServiceIfNeeded( + context: Context, + settingsRepository: SettingsRepository + ) { + val isShutUpEnabled = settingsRepository.isShutUpServiceEnabled() + val intent = Intent(context, com.sameerasw.essentials.services.ShutUpForegroundService::class.java) + if (isShutUpEnabled) { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } } diff --git a/app/src/main/java/com/sameerasw/essentials/utils/ShutUpManager.kt b/app/src/main/java/com/sameerasw/essentials/utils/ShutUpManager.kt new file mode 100644 index 000000000..1390af3ff --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/utils/ShutUpManager.kt @@ -0,0 +1,324 @@ +package com.sameerasw.essentials.utils + +import android.content.Context +import android.content.Intent +import android.provider.Settings +import android.util.Log +import android.widget.Toast +import com.sameerasw.essentials.R +import com.sameerasw.essentials.data.repository.SettingsRepository +import com.sameerasw.essentials.domain.model.ShutUpAppConfig +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext + +object ShutUpManager { + private const val TAG = "ShutUpManager" + + // Every caller (shortcut, accessibility event, and foreground service) uses the same + // serialized setting transaction. This prevents an old restore from racing a new apply. + private val settingsMutex = Mutex() + + private val ignoredSystemPackages = listOf( + "android", + "com.android.systemui", + "com.google.android.inputmethod.latin", + "com.google.android.gms" + ) + + fun isPackageIgnored(packageName: String): Boolean { + return ignoredSystemPackages.contains(packageName) || + packageName.startsWith("com.android.inputmethod") || + packageName.startsWith("com.google.android.inputmethod") || + packageName.contains("autofill") + } + + fun isAppRunning(context: Context, packageName: String): Boolean { + if (ShellUtils.isAvailable(context) && ShellUtils.hasPermission(context)) { + try { + val output = ShellUtils.runCommandWithOutput(context, "pidof $packageName") + if (!output.isNullOrBlank()) { + return true + } + } catch (e: Exception) { + Log.w(TAG, "pidof check failed for $packageName", e) + } + try { + val output = ShellUtils.runCommandWithOutput(context, "pgrep -f $packageName") + if (!output.isNullOrBlank()) { + return true + } + } catch (e: Exception) { + Log.w(TAG, "pgrep check failed for $packageName", e) + } + } + + try { + val am = context.getSystemService(Context.ACTIVITY_SERVICE) as android.app.ActivityManager + val processes = am.runningAppProcesses + if (processes != null) { + for (process in processes) { + if (process.processName == packageName) { + return true + } + } + } + } catch (e: Exception) { + Log.w(TAG, "ActivityManager check failed for $packageName", e) + } + + return false + } + + fun safeWriteSetting(context: Context, type: String, key: String, value: String): Boolean { + return safeWriteSettingInternal(context, type, key, value) + } + + suspend fun safeWriteSettingSync(context: Context, type: String, key: String, value: String): Boolean = withContext(Dispatchers.IO) { + safeWriteSettingInternal(context, type, key, value) + } + + private fun safeWriteSettingInternal(context: Context, type: String, key: String, value: String): Boolean { + val resolver = context.contentResolver + val resolverSuccess = try { + val result = when (type.uppercase()) { + "GLOBAL" -> Settings.Global.putString(resolver, key, value) + "SECURE" -> Settings.Secure.putString(resolver, key, value) + "SYSTEM" -> Settings.System.putString(resolver, key, value) + else -> false + } + Log.d(TAG, "Wrote setting via ContentResolver: [$type] $key = $value (success=$result)") + result + } catch (e: SecurityException) { + Log.e(TAG, "SecurityException writing setting via ContentResolver: [$type] $key = $value", e) + false + } catch (e: Exception) { + Log.e(TAG, "Error writing setting via ContentResolver: [$type] $key = $value", e) + false + } + + // Special handling for wireless debugging key: write to both global and secure tables + if (key == "adb_wifi_enabled") { + try { + Settings.Global.putString(resolver, key, value) + Settings.Secure.putString(resolver, key, value) + } catch (e: Exception) { } + } + + var shellSuccess = false + if (ShellUtils.isAvailable(context) && ShellUtils.hasPermission(context)) { + try { + val shellType = type.lowercase() + ShellUtils.runCommand(context, "settings put $shellType $key $value") + shellSuccess = true + if (key == "adb_wifi_enabled") { + val otherType = if (shellType == "global") "secure" else "global" + ShellUtils.runCommand(context, "settings put $otherType $key $value") + } + Log.d(TAG, "Executed setting put via Shell: [$type] $key = $value (success=$shellSuccess)") + } catch (e: Exception) { + Log.w(TAG, "Failed to write setting via Shell: [$type] $key = $value", e) + } + } + + return resolverSuccess || shellSuccess + } + + fun safeReadSetting(context: Context, type: String, key: String): String? { + val resolver = context.contentResolver + return try { + when (type.uppercase()) { + "GLOBAL" -> Settings.Global.getString(resolver, key) + "SECURE" -> Settings.Secure.getString(resolver, key) + "SYSTEM" -> Settings.System.getString(resolver, key) + else -> null + } + } catch (e: Exception) { + null + } + } + + suspend fun applyShutUpSettings( + context: Context, + config: ShutUpAppConfig, + repository: SettingsRepository? = null, + reinforcement: Boolean = false + ) = settingsMutex.withLock { + Log.d(TAG, "Applying ShutUp settings for ${config.packageName}") + withContext(Dispatchers.IO) { + val repo = repository ?: SettingsRepository(context) + val currentBackup = repo.getShutUpOriginalSettings() + val originalSettings = currentBackup.toMutableMap() + + // Snapshot every original value before changing any setting. Re-enforcement never + // creates or changes the snapshot. + if (!reinforcement) { + config.settings.forEach { setting -> + if (setting.enabled) { + val resolvedType = if (setting.key == "accessibility_enabled") "SECURE" else setting.settingType + val prefixedKey = "${resolvedType.lowercase()}:${setting.key}" + if (!originalSettings.containsKey(prefixedKey)) { + originalSettings[prefixedKey] = safeReadSetting(context, resolvedType, setting.key) ?: "" + } + } + } + + val disableAccessibility = config.settings.any { it.key == "accessibility_enabled" && it.enabled } + if (disableAccessibility) { + val prefixedAccKey = "secure:${Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES}" + if (!originalSettings.containsKey(prefixedAccKey)) { + originalSettings[prefixedAccKey] = safeReadSetting( + context, + "SECURE", + Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES + ) ?: "" + } + } + + if (originalSettings != currentBackup) { + repo.saveShutUpOriginalSettings(originalSettings) + } + } + + config.settings.forEach { setting -> + if (setting.enabled) { + val resolvedType = if (setting.key == "accessibility_enabled") "SECURE" else setting.settingType + safeWriteSettingSync(context, resolvedType, setting.key, setting.valueOnLaunch) + } + } + + // Special handling for accessibility services + val disableAccessibility = config.settings.any { it.key == "accessibility_enabled" && it.enabled } + if (disableAccessibility) { + safeWriteSettingSync(context, "SECURE", Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES, "") + } + } + } + + suspend fun revertShutUpSettings(context: Context, config: ShutUpAppConfig) = settingsMutex.withLock { + Log.d(TAG, "Reverting ShutUp settings for ${config.packageName}") + withContext(Dispatchers.IO) { + config.settings.forEach { setting -> + if (setting.enabled) { + val resolvedType = if (setting.key == "accessibility_enabled") "SECURE" else setting.settingType + safeWriteSettingSync(context, resolvedType, setting.key, setting.valueOnRevert) + } + } + } + } + + suspend fun restoreOriginalSettings(context: Context, repository: SettingsRepository) { + settingsMutex.withLock { + val originalSettings = repository.getShutUpOriginalSettings() + if (originalSettings.isEmpty()) { + Log.d(TAG, "No original settings to restore (backup empty)") + return@withLock + } + + Log.d(TAG, "Restoring original settings from backup (${originalSettings.size} entries)") + withContext(Dispatchers.IO) { + var restoreSucceeded = true + originalSettings.forEach { (prefixedKey, value) -> + try { + val parts = prefixedKey.split(":", limit = 2) + if (parts.size < 2) return@forEach + val table = parts[0] + val key = parts[1] + restoreSucceeded = safeWriteSettingSync(context, table, key, value) && restoreSucceeded + Log.d(TAG, "Restored $prefixedKey = $value") + } catch (e: Exception) { + restoreSucceeded = false + Log.e(TAG, "Failed to restore setting $prefixedKey", e) + } + } + + if (restoreSucceeded) repository.saveShutUpOriginalSettings(emptyMap()) + } + + if (repository.getShutUpOriginalSettings().isEmpty()) withContext(Dispatchers.Main) { + Toast.makeText( + context, + context.getString(R.string.shut_up_toast_restored), + Toast.LENGTH_SHORT + ).show() + } + } + } + + suspend fun restartShizuku(context: Context) { + Log.d(TAG, "Waiting 1500ms for developer/ADB services to stabilize before restarting Shizuku") + delay(1500) + Log.d(TAG, "Attempting Shizuku restart now") + + val repository = SettingsRepository(context) + val savedToken = repository.getShizukuAuthToken() + val authTokens = if (savedToken.isNotBlank()) { + listOf(savedToken, "y95fuaRb9USHiIg724tvTHIs") + } else { + listOf("y95fuaRb9USHiIg724tvTHIs") + } + + authTokens.forEach { token -> + // Try explicit ManualStartReceiver broadcast + try { + val intent = Intent("moe.shizuku.privileged.api.START").apply { + setClassName("moe.shizuku.privileged.api", "moe.shizuku.manager.receiver.ManualStartReceiver") + putExtra("auth", token) + addFlags(Intent.FLAG_INCLUDE_STOPPED_PACKAGES) + } + context.sendBroadcast(intent) + Log.d(TAG, "Sent explicit ManualStartReceiver broadcast") + } catch (e: Exception) { + Log.e(TAG, "Failed explicit ManualStartReceiver broadcast", e) + } + + // Try explicit BootReceiver broadcast + try { + val intent = Intent("moe.shizuku.privileged.api.START").apply { + setClassName("moe.shizuku.privileged.api", "moe.shizuku.manager.receiver.BootReceiver") + putExtra("auth", token) + addFlags(Intent.FLAG_INCLUDE_STOPPED_PACKAGES) + } + context.sendBroadcast(intent) + Log.d(TAG, "Sent explicit BootReceiver broadcast") + } catch (e: Exception) { + Log.e(TAG, "Failed explicit BootReceiver broadcast", e) + } + + // Try legacy/implicit broadcast + try { + val intent = Intent("moe.shizuku.privileged.api.START").apply { + setPackage("moe.shizuku.privileged.api") + putExtra("auth", token) + addFlags(Intent.FLAG_INCLUDE_STOPPED_PACKAGES) + } + context.sendBroadcast(intent) + Log.d(TAG, "Sent legacy implicit broadcast") + } catch (e: Exception) { + Log.e(TAG, "Failed legacy implicit broadcast", e) + } + } + + // If shell/root is available, run Shizuku start script + if (ShellUtils.isAvailable(context) && ShellUtils.hasPermission(context)) { + Log.d(TAG, "Shell/Root is available, running Shizuku start script via Shell") + withContext(Dispatchers.IO) { + val scripts = listOf( + "sh /data/data/moe.shizuku.privileged.api/start.sh", + "sh /sdcard/Android/data/moe.shizuku.privileged.api/files/start.sh", + "sh /storage/emulated/0/Android/data/moe.shizuku.privileged.api/files/start.sh" + ) + scripts.forEach { script -> + try { + val success = ShellUtils.runCommand(context, script) + Log.d(TAG, "Executed shell command: '$script', success: $success") + } catch (e: Exception) { + Log.e(TAG, "Failed shell command: '$script'", e) + } + } + } + } + } +} 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 bdd53d01c..c320cf190 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -48,6 +48,7 @@ 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.AppStandbyInfo +import com.sameerasw.essentials.domain.model.ShutUpAppConfig import com.sameerasw.essentials.domain.model.DnsPreset import com.sameerasw.essentials.domain.model.NotificationApp import com.sameerasw.essentials.domain.model.NotificationLightingColorMode @@ -141,6 +142,7 @@ class MainViewModel : ViewModel() { val isBluetoothPermissionGranted = mutableStateOf(false) val isUsageStatsPermissionGranted = mutableStateOf(false) val appLanguage = mutableStateOf("en") + val isShutUpServiceEnabled = mutableStateOf(false) val isBluetoothDevicesEnabled = mutableStateOf(false) val isCallVibrationsEnabled = mutableStateOf(false) @@ -211,9 +213,9 @@ class MainViewModel : ViewModel() { val shutUpConfigs = mutableStateOf>(emptyList()) val isShutUpLoading = mutableStateOf(false) - val isShutUpAttemptShizukuRestart = mutableStateOf(true) val shutUpRestoreDelay = mutableIntStateOf(10) val shutUpRestoreMode = mutableStateOf("Auto") + val isShutUpAttemptShizukuRestart = mutableStateOf(true) val shizukuAuthToken = mutableStateOf("") val edgeLightingSweepSelectedShapes = mutableStateOf>(emptySet()) @@ -769,10 +771,7 @@ class MainViewModel : ViewModel() { liveWallpaperCustomVideos.addAll(settingsRepository.getLiveWallpaperCustomVideos()) } - SettingsRepository.KEY_SHUT_UP_ATTEMPT_SHIZUKU_RESTART -> { - isShutUpAttemptShizukuRestart.value = - settingsRepository.isShutUpAttemptShizukuRestartEnabled() - } + SettingsRepository.KEY_SHUT_UP_RESTORE_DELAY -> { shutUpRestoreDelay.intValue = @@ -917,13 +916,15 @@ class MainViewModel : ViewModel() { /** * Updates ducking or mute configuration for a specific target package. * - * @param config [com.sameerasw.essentials.domain.model.ShutUpAppConfig] The updated ShutUpAppConfig object to store. + * @param config [ShutUpAppConfig] The updated ShutUpAppConfig object to store. */ - fun updateShutUpConfig(config: com.sameerasw.essentials.domain.model.ShutUpAppConfig) { + fun updateShutUpConfig(config: ShutUpAppConfig) { settingsRepository.updateShutUpConfig(config) loadShutUpConfigs() } + + /** * Executes the remove shut up config operation. * @@ -985,7 +986,7 @@ class MainViewModel : ViewModel() { fun saveShutUpSelectedApps(context: Context, apps: List) { val currentConfigs = settingsRepository.loadShutUpConfigs().associateBy { it.packageName } val newConfigs = apps.filter { it.isEnabled }.map { - currentConfigs[it.packageName] ?: com.sameerasw.essentials.domain.model.ShutUpAppConfig( + currentConfigs[it.packageName] ?: ShutUpAppConfig( it.packageName ) } @@ -993,45 +994,58 @@ class MainViewModel : ViewModel() { loadShutUpConfigs() } - fun createShutUpShortcut( - context: Context, - config: com.sameerasw.essentials.domain.model.ShutUpAppConfig - ) { - val appName = try { - val appInfo = context.packageManager.getApplicationInfo(config.packageName, 0) - context.packageManager.getApplicationLabel(appInfo).toString() + fun setShutUpServiceEnabled(enabled: Boolean, context: Context) { + isShutUpServiceEnabled.value = enabled + settingsRepository.setShutUpServiceEnabled(enabled) + val intent = Intent(context, com.sameerasw.essentials.services.ShutUpForegroundService::class.java) + if (enabled) { + androidx.core.content.ContextCompat.startForegroundService(context, intent) + } else { + context.stopService(intent) + } + } + + fun createShutUpShortcut(context: Context, config: ShutUpAppConfig) { + if (!androidx.core.content.pm.ShortcutManagerCompat.isRequestPinShortcutSupported(context)) { + Toast.makeText(context, "Shortcut pinning not supported by launcher", Toast.LENGTH_SHORT).show() + return + } + + val pm = context.packageManager + val appLabel = try { + val appInfo = pm.getApplicationInfo(config.packageName, 0) + pm.getApplicationLabel(appInfo).toString() } catch (e: Exception) { config.packageName } + val shortLabel = "Shut-Up $appLabel" + val longLabel = "Launch $appLabel with Shut-Up" - val intent = - Intent(context, com.sameerasw.essentials.ShutUpShortcutActivity::class.java).apply { - action = Intent.ACTION_MAIN - putExtra("package_name", config.packageName) - data = Uri.parse("shutup://${config.packageName}") - } + val iconCompat = try { + val bitmap = com.sameerasw.essentials.utils.AppUtil.getShortcutIcon(context, config.packageName) + androidx.core.graphics.drawable.IconCompat.createWithBitmap(bitmap) + } catch (e: Exception) { + null + } - if (androidx.core.content.pm.ShortcutManagerCompat.isRequestPinShortcutSupported(context)) { - val appIcon = AppUtil.getShortcutIcon(context, config.packageName) + val shortcutIntent = Intent(context, com.sameerasw.essentials.ShutUpShortcutActivity::class.java).apply { + action = Intent.ACTION_VIEW + putExtra("package_name", config.packageName) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } - val pinShortcutInfo = - androidx.core.content.pm.ShortcutInfoCompat.Builder(context, config.packageName) - .setShortLabel(appName) - .setIcon(androidx.core.graphics.drawable.IconCompat.createWithBitmap(appIcon)) - .setIntent(intent) - .build() + val shortcutInfo = androidx.core.content.pm.ShortcutInfoCompat.Builder(context, "shutup_${config.packageName}") + .setShortLabel(shortLabel) + .setLongLabel(longLabel) + .setIntent(shortcutIntent) + .apply { + if (iconCompat != null) { + setIcon(iconCompat) + } + } + .build() - androidx.core.content.pm.ShortcutManagerCompat.requestPinShortcut( - context, - pinShortcutInfo, - null - ) - Toast.makeText( - context, - context.getString(R.string.shut_up_shortcut_created, appName), - Toast.LENGTH_SHORT - ).show() - } + androidx.core.content.pm.ShortcutManagerCompat.requestPinShortcut(context, shortcutInfo, null) } /** @@ -1091,8 +1105,7 @@ class MainViewModel : ViewModel() { notificationLightingSystemMode.intValue = settingsRepository.getNotificationLightingSystemMode() - isShutUpAttemptShizukuRestart.value = - settingsRepository.isShutUpAttemptShizukuRestartEnabled() + shutUpRestoreDelay.intValue = settingsRepository.getShutUpRestoreDelay() shutUpRestoreMode.value = @@ -1155,6 +1168,8 @@ class MainViewModel : ViewModel() { lockScreenClockSelectedColorId.value = settingsRepository.getLockScreenClockSelectedColorId() lockScreenClockSeedColor.intValue = settingsRepository.getLockScreenClockSeedColor() + isShutUpServiceEnabled.value = settingsRepository.isShutUpServiceEnabled() + isShutUpAttemptShizukuRestart.value = settingsRepository.isShutUpAttemptShizukuRestartEnabled() loadShutUpConfigs() recentSearches.value = settingsRepository.getRecentSearches() loadCachedWallpaper() @@ -6143,6 +6158,7 @@ class MainViewModel : ViewModel() { * Executes the set pocket mode enabled operation. * * @param enabled [Boolean] Target enabled. + * @param context [Context] Target context. */ fun setPocketModeEnabled(enabled: Boolean) { settingsRepository.putBoolean(SettingsRepository.KEY_POCKET_MODE_ENABLED, enabled) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6c699806d..332a104c4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -962,6 +962,10 @@ Toggle Flashlight Turn On Low Power Mode Turn Off Low Power Mode + Turn On Cellular Data + Turn Off Cellular Data + Turn On Auto Brightness + Turn Off Auto Brightness Dim Wallpaper Screen Off Media Play/Pause @@ -980,6 +984,11 @@ Turn Off Hotspot Toggle Hotspot This action requires Shizuku or Root to adjust system wallpaper dimming. + Freeze Apps + Unfreeze Apps + This action requires Shizuku or Root to freeze specific applications. + This action requires Shizuku or Root to unfreeze specific applications. + Select Trigger App Automate based on open app @@ -1928,6 +1937,11 @@ %1$s will be archived in %2$d seconds Freeze now Abort + Shut-Up! Service + Monitors launched apps to disable developer settings + Shut-Up! is active + Monitoring app launch and exit + Lock screen clock Customize lock screen clock on Pixels diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..13372aef5 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradlew b/gradlew index b9bb139f7..1992bd5db 100755 --- a/gradlew +++ b/gradlew @@ -208,9 +208,12 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ "$@" # Stop when "xargs" is not available.