diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 60d4dfa35..9de30b992 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -90,7 +90,7 @@ android { minSdk = 26 targetSdk = 37 versionCode = 62 - versionName = "18.0-beta.1" + versionName = "18.0-beta.2" val whatsNewCounter = 2 buildConfigField("int", "WHATS_NEW_COUNTER", whatsNewCounter.toString()) @@ -220,9 +220,10 @@ dependencies { // AutoUpdater implementation(libs.autoupdater) - // Media3 for Live Wallpaper + // Media3 for Live Wallpaper & Online Help Media implementation(libs.androidx.media3.exoplayer) implementation(libs.androidx.media3.common) + implementation(libs.androidx.media3.ui) // RemoteIntent support implementation(libs.androidx.wear.remote.interactions.v110alpha02) diff --git a/app/src/main/java/com/sameerasw/essentials/data/repository/HelpMediaRepository.kt b/app/src/main/java/com/sameerasw/essentials/data/repository/HelpMediaRepository.kt new file mode 100644 index 000000000..27eb36478 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/data/repository/HelpMediaRepository.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Data & Repository Layer + * File: HelpMediaRepository.kt + * Description: Data repository for fetching and caching online help media mappings. + */ + +package com.sameerasw.essentials.data.repository + +import android.content.Context +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.net.HttpURLConnection +import java.net.URL + +data class FeatureHelpMedia( + val type: String, // "video", "gif", "image" + val url: String, +) + +class HelpMediaRepository(private val context: Context) { + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + private val gson = Gson() + + suspend fun getHelpMediaForFeature(featureId: String): FeatureHelpMedia? { + val mapping = getHelpMediaMapping() + return mapping[featureId] + } + + private suspend fun getHelpMediaMapping(): Map = + withContext(Dispatchers.IO) { + val cachedJson = prefs.getString(KEY_MEDIA_MAPPING_CACHE, null) + val lastFetchTime = prefs.getLong(KEY_MEDIA_MAPPING_LAST_FETCH, 0L) + val now = System.currentTimeMillis() + + val isCacheStale = (now - lastFetchTime) > CACHE_EXPIRATION_MS + + if (!cachedJson.isNullOrEmpty() && !isCacheStale) { + try { + val type = object : TypeToken>() {}.type + val cachedMap: Map? = gson.fromJson(cachedJson, type) + if (cachedMap != null) { + return@withContext cachedMap + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + // Fetch remote mapping + try { + val url = URL(MAPPING_URL) + val connection = (url.openConnection() as HttpURLConnection).apply { + connectTimeout = 5000 + readTimeout = 5000 + requestMethod = "GET" + } + + if (connection.responseCode == HttpURLConnection.HTTP_OK) { + val response = connection.inputStream.bufferedReader().use { it.readText() } + val type = object : TypeToken>() {}.type + val freshMap: Map = gson.fromJson(response, type) + + prefs.edit() + .putString(KEY_MEDIA_MAPPING_CACHE, response) + .putLong(KEY_MEDIA_MAPPING_LAST_FETCH, now) + .apply() + + return@withContext freshMap + } + } catch (e: Exception) { + e.printStackTrace() + } + + // Fallback to cache if available + if (!cachedJson.isNullOrEmpty()) { + try { + val type = object : TypeToken>() {}.type + val cachedMap: Map? = gson.fromJson(cachedJson, type) + if (cachedMap != null) { + return@withContext cachedMap + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + emptyMap() + } + + companion object { + private const val PREFS_NAME = "help_media_prefs" + private const val KEY_MEDIA_MAPPING_CACHE = "media_mapping_cache" + private const val KEY_MEDIA_MAPPING_LAST_FETCH = "media_mapping_last_fetch" + private const val CACHE_EXPIRATION_MS = 1000 * 60 * 60 // 1 hour + private const val MAPPING_URL = "https://sameerasw.com/essentials/help/media-mapping.json" + + @Volatile + private var instance: HelpMediaRepository? = null + + fun getInstance(context: Context): HelpMediaRepository { + return instance ?: synchronized(this) { + instance ?: HelpMediaRepository(context.applicationContext).also { instance = it } + } + } + } +} 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 c576c9fba..b9d8d75b7 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 @@ -273,6 +273,7 @@ class SettingsRepository( const val KEY_PINNED_FEATURES = "pinned_features" const val KEY_PINNED_QS_TILES = "pinned_qs_tiles" + const val KEY_SECURE_SENSITIVE_TILES = "secure_sensitive_tiles" const val KEY_LIKE_SONG_TOAST_ENABLED = "like_song_toast_enabled" const val KEY_LIKE_SONG_AOD_OVERLAY_ENABLED = "like_song_aod_overlay_enabled" const val KEY_AMBIENT_MUSIC_GLANCE_ENABLED = "ambient_music_glance_enabled" @@ -336,6 +337,7 @@ class SettingsRepository( const val KEY_USE_BLUR = "use_blur" const val KEY_USE_RIPPLE = "use_ripple" const val KEY_MOTION_BLUR = "motion_blur" + const val KEY_ONLINE_HELP_MEDIA = "online_help_media" const val KEY_SWIPE_TABS = "swipe_tabs" const val KEY_SENTRY_REPORT_MODE = "sentry_report_mode" const val KEY_ONBOARDING_COMPLETED = "onboarding_completed" @@ -1675,6 +1677,10 @@ class SettingsRepository( */ fun isShutUpAttemptShizukuRestartEnabled(): Boolean = getBoolean(KEY_SHUT_UP_ATTEMPT_SHIZUKU_RESTART, true) + fun isOnlineHelpMediaEnabled(): Boolean = getBoolean(KEY_ONLINE_HELP_MEDIA, true) + + fun setOnlineHelpMediaEnabled(enabled: Boolean) = putBoolean(KEY_ONLINE_HELP_MEDIA, enabled) + /** * Executes the set shut up attempt shizuku restart enabled operation. * @@ -3037,4 +3043,7 @@ class SettingsRepository( fun isBubbleWebFullscreen(): Boolean = getBoolean(KEY_BUBBLE_WEB_FULLSCREEN, false) fun setBubbleWebFullscreen(fullscreen: Boolean) = putBoolean(KEY_BUBBLE_WEB_FULLSCREEN, fullscreen) + + fun isSecureSensitiveTilesEnabled(): Boolean = getBoolean(KEY_SECURE_SENSITIVE_TILES, true) + fun setSecureSensitiveTilesEnabled(enabled: Boolean) = putBoolean(KEY_SECURE_SENSITIVE_TILES, enabled) } diff --git a/app/src/main/java/com/sameerasw/essentials/domain/model/AppPermission.kt b/app/src/main/java/com/sameerasw/essentials/domain/model/AppPermission.kt new file mode 100644 index 000000000..fac2b152a --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/domain/model/AppPermission.kt @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Domain Layer Models + * File: AppPermission.kt + * Description: Enum defining all app permissions with keys, localized titles, and icons. + */ + +package com.sameerasw.essentials.domain.model + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import com.sameerasw.essentials.R + +enum class AppPermission( + val key: String, + @StringRes val titleRes: Int, + @DrawableRes val iconRes: Int, + val aliases: List = emptyList(), +) { + ACCESSIBILITY( + key = "ACCESSIBILITY", + titleRes = R.string.perm_accessibility_title, + iconRes = R.drawable.rounded_settings_accessibility_24, + ), + WRITE_SECURE_SETTINGS( + key = "WRITE_SECURE_SETTINGS", + titleRes = R.string.perm_write_secure_title, + iconRes = R.drawable.rounded_security_24, + ), + NOTIFICATION_LISTENER( + key = "NOTIFICATION_LISTENER", + titleRes = R.string.perm_notif_listener_title, + iconRes = R.drawable.rounded_notifications_unread_24, + ), + DRAW_OVERLAYS( + key = "DRAW_OVERLAYS", + titleRes = R.string.perm_overlay_title, + iconRes = R.drawable.rounded_magnify_fullscreen_24, + aliases = listOf("DRAW_OVER_OTHER_APPS"), + ), + WRITE_SETTINGS( + key = "WRITE_SETTINGS", + titleRes = R.string.perm_write_settings_title, + iconRes = R.drawable.rounded_security_24, + ), + NOTIFICATION_POLICY( + key = "NOTIFICATION_POLICY", + titleRes = R.string.perm_notif_policy_title, + iconRes = R.drawable.rounded_volume_up_24, + ), + POST_NOTIFICATIONS( + key = "POST_NOTIFICATIONS", + titleRes = R.string.permission_post_notifications_title, + iconRes = R.drawable.rounded_notifications_unread_24, + ), + READ_PHONE_STATE( + key = "READ_PHONE_STATE", + titleRes = R.string.permission_read_phone_state_title, + iconRes = R.drawable.rounded_android_cell_dual_4_bar_24, + ), + LOCATION( + key = "LOCATION", + titleRes = R.string.perm_location_title, + iconRes = R.drawable.rounded_location_on_24, + ), + BACKGROUND_LOCATION( + key = "BACKGROUND_LOCATION", + titleRes = R.string.perm_bg_location_title, + iconRes = R.drawable.rounded_location_on_24, + ), + DEVICE_ADMIN( + key = "DEVICE_ADMIN", + titleRes = R.string.perm_device_admin_title, + iconRes = R.drawable.rounded_admin_panel_settings_24, + ), + ROOT( + key = "ROOT", + titleRes = R.string.perm_root_title, + iconRes = R.drawable.rounded_numbers_24, + ), + SHIZUKU( + key = "SHIZUKU", + titleRes = R.string.perm_shizuku_title, + iconRes = R.drawable.rounded_adb_24, + ), + READ_CALENDAR( + key = "READ_CALENDAR", + titleRes = R.string.perm_calendar_title, + iconRes = R.drawable.rounded_calendar_today_24, + ), + USAGE_STATS( + key = "USAGE_STATS", + titleRes = R.string.perm_usage_stats_title, + iconRes = R.drawable.rounded_data_usage_24, + ), + DEFAULT_BROWSER( + key = "DEFAULT_BROWSER", + titleRes = R.string.perm_default_browser_title, + iconRes = R.drawable.rounded_open_in_browser_24, + ), + BLUETOOTH( + key = "BLUETOOTH_CONNECT", + titleRes = R.string.perm_nearby_devices_title, + iconRes = R.drawable.rounded_bluetooth_24, + aliases = listOf("BLUETOOTH_SCAN", "BLUETOOTH"), + ), + REQUEST_INSTALL_PACKAGES( + key = "REQUEST_INSTALL_PACKAGES", + titleRes = R.string.perm_install_packages_title, + iconRes = R.drawable.rounded_mobile_arrow_down_24, + ), + READ_CONTACTS( + key = "READ_CONTACTS", + titleRes = R.string.perm_contacts_title, + iconRes = R.drawable.rounded_call_24, + ), + WATCH_CALL_SYNC( + key = "WATCH_CALL_SYNC", + titleRes = R.string.watch_call_sync_title, + iconRes = R.drawable.rounded_mobile_sound_24, + aliases = listOf("ANSWER_PHONE_CALLS", "READ_CALL_LOG"), + ), + STORAGE( + key = "STORAGE", + titleRes = R.string.perm_storage_title, + iconRes = R.drawable.rounded_image_24, + aliases = listOf("READ_MEDIA_IMAGES", "READ_EXTERNAL_STORAGE", "MANAGE_EXTERNAL_STORAGE"), + ); + + companion object { + fun fromKey(key: String): AppPermission? { + val upper = key.uppercase() + return entries.firstOrNull { it.name == upper || it.key.equals(upper, ignoreCase = true) || it.aliases.any { alias -> alias.equals(upper, ignoreCase = true) } } + } + } +} 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 062573e0e..4a5f606d8 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 @@ -436,6 +436,7 @@ object FeatureRegistry { description = R.string.feat_network_download_rate_limit_desc, permissionKeys = listOf("WRITE_SECURE_SETTINGS"), parentFeatureId = "Networks", + hasMoreSettings = false, showToggle = false, ) { override fun isEnabled(viewModel: MainViewModel) = viewModel.networkDownloadRateLimit.intValue != -1 @@ -454,6 +455,7 @@ object FeatureRegistry { description = R.string.feat_mobile_data_always_on_desc, permissionKeys = listOf("WRITE_SECURE_SETTINGS"), parentFeatureId = "Networks", + hasMoreSettings = false, ) { override fun isEnabled(viewModel: MainViewModel) = viewModel.isMobileDataAlwaysOnEnabled.value @@ -471,6 +473,7 @@ object FeatureRegistry { description = R.string.feat_wireless_display_certification_desc, permissionKeys = listOf("WRITE_SECURE_SETTINGS"), parentFeatureId = "Networks", + hasMoreSettings = false, ) { override fun isEnabled(viewModel: MainViewModel) = viewModel.isWirelessDisplayCertificationEnabled.value @@ -488,6 +491,7 @@ object FeatureRegistry { description = R.string.feat_sim_names_desc, permissionKeys = listOf("SHIZUKU", "READ_PHONE_STATE"), parentFeatureId = "Networks", + hasMoreSettings = false, showToggle = false, ) { override fun isEnabled(viewModel: MainViewModel) = true @@ -1013,6 +1017,13 @@ object FeatureRegistry { showToggle = false, searchableSettings = listOf( + SearchSetting( + R.string.qs_secure_sensitive_tiles_title, + R.string.qs_secure_sensitive_tiles_desc, + "Secure sensitive tiles", + R.array.keywords_privacy, + R.string.feat_qs_tiles_title, + ), SearchSetting( R.string.search_qs_blur_title, R.string.search_qs_blur_desc, 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 e9ccd8383..a9950ae83 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 @@ -10,10 +10,18 @@ package com.sameerasw.essentials.domain.registry import com.sameerasw.essentials.R +import com.sameerasw.essentials.domain.model.AppPermission object PermissionRegistry { private val registry = mutableMapOf>() + fun register( + permission: AppPermission, + featureTitleRes: Int, + ) { + register(permission.name, featureTitleRes) + } + fun register( permissionKey: String, featureTitleRes: Int, @@ -22,7 +30,20 @@ object PermissionRegistry { if (!list.contains(featureTitleRes)) list.add(featureTitleRes) } - fun getFeatures(permissionKey: String): List = registry[permissionKey]?.toList() ?: emptyList() + fun getFeatures(permission: AppPermission): List { + val direct = registry[permission.name] ?: emptyList() + val aliasMatches = permission.aliases.flatMap { registry[it] ?: emptyList() } + return (direct + aliasMatches).distinct() + } + + fun getFeatures(permissionKey: String): List { + val perm = AppPermission.fromKey(permissionKey) + return if (perm != null) { + getFeatures(perm) + } else { + registry[permissionKey]?.toList() ?: emptyList() + } + } } // Register existing dependencies diff --git a/app/src/main/java/com/sameerasw/essentials/domain/registry/QSTileRegistry.kt b/app/src/main/java/com/sameerasw/essentials/domain/registry/QSTileRegistry.kt new file mode 100644 index 000000000..7aa440351 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/domain/registry/QSTileRegistry.kt @@ -0,0 +1,369 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Domain Layer Registries + * File: QSTileRegistry.kt + * Description: Canonical registry and metadata for all Quick Settings tiles. + */ + +package com.sameerasw.essentials.domain.registry + +import android.content.Context +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import com.sameerasw.essentials.R +import com.sameerasw.essentials.services.tiles.AdaptiveBrightnessTileService +import com.sameerasw.essentials.services.tiles.AlwaysOnDisplayTileService +import com.sameerasw.essentials.services.tiles.AppFreezingTileService +import com.sameerasw.essentials.services.tiles.AppLockTileService +import com.sameerasw.essentials.services.tiles.BubblesTileService +import com.sameerasw.essentials.services.tiles.CaffeinateTileService +import com.sameerasw.essentials.services.tiles.ChargeQuickTileService +import com.sameerasw.essentials.services.tiles.ColorPickerTileService +import com.sameerasw.essentials.services.tiles.DeveloperOptionsTileService +import com.sameerasw.essentials.services.tiles.DynamicNightLightTileService +import com.sameerasw.essentials.services.tiles.EssentialsOnDisplayTileService +import com.sameerasw.essentials.services.tiles.FlashlightPulseTileService +import com.sameerasw.essentials.services.tiles.FlashlightTileService +import com.sameerasw.essentials.services.tiles.LockdownTileService +import com.sameerasw.essentials.services.tiles.MapsPowerSavingTileService +import com.sameerasw.essentials.services.tiles.MonoAudioTileService +import com.sameerasw.essentials.services.tiles.NfcTileService +import com.sameerasw.essentials.services.tiles.NotificationLightingTileService +import com.sameerasw.essentials.services.tiles.PrivateDnsTileService +import com.sameerasw.essentials.services.tiles.PrivateNotificationsTileService +import com.sameerasw.essentials.services.tiles.RefreshRateTileService +import com.sameerasw.essentials.services.tiles.RestartSystemUiTileService +import com.sameerasw.essentials.services.tiles.ScaleAnimationsTileService +import com.sameerasw.essentials.services.tiles.ScreenLockedSecurityTileService +import com.sameerasw.essentials.services.tiles.SmartPixelsTileService +import com.sameerasw.essentials.services.tiles.SoundModeTileService +import com.sameerasw.essentials.services.tiles.StayAwakeTileService +import com.sameerasw.essentials.services.tiles.TapToWakeTileService +import com.sameerasw.essentials.services.tiles.UiBlurTileService +import com.sameerasw.essentials.services.tiles.UrlShortenerTileService +import com.sameerasw.essentials.services.tiles.UsbDebuggingTileService +import com.sameerasw.essentials.utils.DeviceUtils +import com.sameerasw.essentials.utils.PermissionUtils +import com.sameerasw.essentials.utils.ShellUtils + +data class QSTileInfo( + @StringRes val titleRes: Int, + @DrawableRes val iconRes: Int, + val serviceClass: Class<*>, + val permissionKeys: List = emptyList(), + @StringRes val aboutDescription: Int? = null, + @StringRes val categoryRes: Int, + val isSupported: (Context) -> Boolean = { true }, +) + +object QSTileRegistry { + fun getAllTiles(context: Context, isUseUsageStats: Boolean = false): List = + listOf( + QSTileInfo( + R.string.tile_ui_blur, + R.drawable.rounded_blur_on_24, + UiBlurTileService::class.java, + listOf("WRITE_SECURE_SETTINGS"), + R.string.about_desc_ui_blur, + R.string.cat_visuals, + ), + QSTileInfo( + R.string.tile_bubbles, + R.drawable.rounded_bubble_24, + BubblesTileService::class.java, + listOf("WRITE_SECURE_SETTINGS"), + R.string.about_desc_bubbles, + R.string.cat_utils, + ), + QSTileInfo( + R.string.tile_sensitive_content, + R.drawable.rounded_notifications_off_24, + PrivateNotificationsTileService::class.java, + listOf("WRITE_SECURE_SETTINGS"), + R.string.about_desc_sensitive_content, + R.string.cat_privacy, + ), + QSTileInfo( + R.string.tile_tap_to_wake, + R.drawable.rounded_touch_app_24, + TapToWakeTileService::class.java, + listOf("WRITE_SECURE_SETTINGS"), + R.string.about_desc_tap_to_wake, + R.string.cat_visuals, + ), + QSTileInfo( + R.string.tile_aod, + R.drawable.rounded_mobile_text_2_24, + AlwaysOnDisplayTileService::class.java, + if (ShellUtils.isRootEnabled(context)) { + listOf("ROOT") + } else if (PermissionUtils.canWriteSecureSettings(context)) { + listOf("WRITE_SECURE_SETTINGS") + } else { + listOf("SHIZUKU") + }, + R.string.about_desc_aod, + R.string.cat_visuals, + ), + QSTileInfo( + R.string.tile_caffeinate, + R.drawable.rounded_coffee_24, + CaffeinateTileService::class.java, + listOf("POST_NOTIFICATIONS"), + R.string.about_desc_caffeinate, + R.string.cat_utils, + ), + QSTileInfo( + R.string.tile_sound_mode, + R.drawable.rounded_volume_up_24, + SoundModeTileService::class.java, + listOf("NOTIFICATION_POLICY"), + R.string.about_desc_sound_mode_tile, + R.string.cat_utils, + ), + QSTileInfo( + R.string.tile_notification_lighting, + R.drawable.rounded_blur_linear_24, + NotificationLightingTileService::class.java, + listOf("DRAW_OVERLAYS", "ACCESSIBILITY", "NOTIFICATION_LISTENER"), + R.string.about_desc_notification_lighting, + R.string.cat_utils, + ), + QSTileInfo( + R.string.tile_dynamic_night_light, + R.drawable.rounded_nightlight_24, + DynamicNightLightTileService::class.java, + listOf("ACCESSIBILITY", "WRITE_SECURE_SETTINGS"), + R.string.about_desc_dynamic_night_light, + R.string.cat_visuals, + ), + QSTileInfo( + R.string.tile_locked_security, + R.drawable.rounded_security_24, + ScreenLockedSecurityTileService::class.java, + if (ShellUtils.isRootEnabled(context)) listOf("ROOT") else listOf("SHIZUKU"), + R.string.about_desc_screen_locked_security, + R.string.cat_privacy, + ), + QSTileInfo( + R.string.tile_app_lock, + R.drawable.rounded_shield_lock_24, + AppLockTileService::class.java, + if (isUseUsageStats) listOf("USAGE_STATS") else listOf("ACCESSIBILITY"), + R.string.about_desc_app_lock, + R.string.cat_privacy, + ), + QSTileInfo( + R.string.tile_mono_audio, + R.drawable.rounded_headphones_24, + MonoAudioTileService::class.java, + if (ShellUtils.isRootEnabled(context)) { + listOf("ROOT") + } else if (PermissionUtils.canWriteSecureSettings(context)) { + listOf("WRITE_SECURE_SETTINGS") + } else { + listOf("SHIZUKU") + }, + R.string.about_desc_mono_audio, + R.string.cat_accessibility, + ), + QSTileInfo( + R.string.tile_flashlight, + R.drawable.rounded_flashlight_on_24, + FlashlightTileService::class.java, + emptyList(), + R.string.about_desc_flashlight_tile, + R.string.cat_utils, + ), + QSTileInfo( + R.string.tile_app_freezing, + R.drawable.rounded_app_badging_24, + AppFreezingTileService::class.java, + if (ShellUtils.isRootEnabled(context)) { + listOf( + "ROOT", + "USAGE_STATS", + "NOTIFICATION_LISTENER", + ) + } else { + listOf("SHIZUKU", "USAGE_STATS", "NOTIFICATION_LISTENER") + }, + R.string.about_desc_freeze, + R.string.cat_utils, + ), + QSTileInfo( + R.string.tile_flashlight_pulse, + R.drawable.outline_backlight_high_24, + FlashlightPulseTileService::class.java, + listOf("NOTIFICATION_LISTENER"), + R.string.about_desc_flashlight_pulse, + R.string.cat_utils, + ), + QSTileInfo( + R.string.tile_stay_awake, + R.drawable.rounded_av_timer_24, + StayAwakeTileService::class.java, + listOf("WRITE_SECURE_SETTINGS"), + R.string.about_desc_stay_awake, + R.string.cat_visuals, + ), + QSTileInfo( + R.string.nfc_tile_label, + R.drawable.rounded_nfc_24, + NfcTileService::class.java, + if (ShellUtils.isRootEnabled(context)) { + listOf("ROOT") + } else if (PermissionUtils.canWriteSecureSettings(context)) { + listOf("WRITE_SECURE_SETTINGS") + } else { + listOf("SHIZUKU") + }, + R.string.about_desc_nfc, + R.string.cat_connectivity, + ), + QSTileInfo( + R.string.tile_adaptive_brightness, + R.drawable.rounded_brightness_auto_24, + AdaptiveBrightnessTileService::class.java, + listOf("WRITE_SETTINGS"), + R.string.about_desc_adaptive_brightness, + R.string.cat_visuals, + ), + QSTileInfo( + R.string.tile_scale_animations, + R.drawable.rounded_front_hand_24, + ScaleAnimationsTileService::class.java, + listOf("WRITE_SECURE_SETTINGS"), + R.string.about_desc_scale_animations_tile, + R.string.cat_visuals, + ), + QSTileInfo( + R.string.tile_restart_systemui, + R.drawable.reopen_window_24px, + RestartSystemUiTileService::class.java, + if (ShellUtils.isRootEnabled(context)) listOf("ROOT") else listOf("SHIZUKU"), + R.string.about_desc_restart_systemui_tile, + R.string.cat_utils, + ), + QSTileInfo( + R.string.tile_refresh_rate, + R.drawable.rounded_shutter_speed_24, + RefreshRateTileService::class.java, + listOf("SHIZUKU"), + R.string.about_desc_refresh_rate_tile, + R.string.cat_visuals, + isSupported = { _ -> DeviceUtils.isGoogleDevice() }, + ), + QSTileInfo( + R.string.feat_maps_power_saving_title, + R.drawable.rounded_navigation_24, + MapsPowerSavingTileService::class.java, + if (ShellUtils.isRootEnabled(context)) { + listOf( + "ROOT", + "NOTIFICATION_LISTENER", + ) + } else { + listOf("SHIZUKU", "NOTIFICATION_LISTENER") + }, + R.string.about_desc_maps_power_saving, + R.string.cat_utils, + ), + QSTileInfo( + R.string.tile_private_dns, + R.drawable.rounded_dns_24, + PrivateDnsTileService::class.java, + listOf("WRITE_SECURE_SETTINGS"), + R.string.about_desc_private_dns, + R.string.cat_connectivity, + ), + QSTileInfo( + R.string.tile_usb_debugging, + R.drawable.rounded_adb_24, + UsbDebuggingTileService::class.java, + listOf("WRITE_SECURE_SETTINGS"), + R.string.about_desc_usb_debugging, + R.string.cat_utils, + ), + QSTileInfo( + R.string.tile_color_picker, + R.drawable.rounded_colorize_24, + ColorPickerTileService::class.java, + emptyList(), + R.string.about_desc_color_picker, + R.string.cat_visuals, + ), + QSTileInfo( + R.string.tile_developer_options, + R.drawable.rounded_mobile_code_24, + DeveloperOptionsTileService::class.java, + listOf("WRITE_SECURE_SETTINGS"), + R.string.about_desc_developer_options, + R.string.cat_utils, + ), + QSTileInfo( + R.string.tile_charge_optimization, + R.drawable.rounded_battery_android_frame_shield_24, + ChargeQuickTileService::class.java, + if (ShellUtils.isRootEnabled(context)) listOf("ROOT") else listOf("SHIZUKU"), + R.string.about_desc_charge_optimization, + R.string.cat_utils, + isSupported = { _ -> DeviceUtils.isGoogleDevice() }, + ), + QSTileInfo( + R.string.tile_lock, + R.drawable.rounded_lock_24, + LockdownTileService::class.java, + listOf("ACCESSIBILITY", "DEVICE_ADMIN"), + R.string.tile_lockdown_mode_about_desc, + R.string.cat_privacy, + ), + QSTileInfo( + R.string.feat_smart_pixels_title, + R.drawable.rounded_grain_24, + SmartPixelsTileService::class.java, + listOf("ACCESSIBILITY"), + R.string.about_desc_smart_pixels, + R.string.cat_visuals, + ), + QSTileInfo( + R.string.feat_essentials_on_display_title, + R.drawable.rounded_live_tv_24, + EssentialsOnDisplayTileService::class.java, + listOf("ACCESSIBILITY", "NOTIFICATION_LISTENER"), + R.string.feat_essentials_on_display_desc, + R.string.cat_visuals, + ), + QSTileInfo( + R.string.tile_url_shortener, + R.drawable.rounded_link_24, + UrlShortenerTileService::class.java, + emptyList(), + R.string.tile_url_shortener_subtitle, + R.string.cat_utils, + ), + ) + + fun searchTiles( + context: Context, + query: String, + includeUnsupported: Boolean = false, + isUseUsageStats: Boolean = false, + ): List { + val q = query.trim().lowercase() + if (q.isEmpty()) return emptyList() + + val tiles = getAllTiles(context, isUseUsageStats).filter { tile -> + includeUnsupported || tile.isSupported(context) + } + + return tiles.filter { tile -> + val title = context.getString(tile.titleRes).lowercase() + val category = context.getString(tile.categoryRes).lowercase() + val desc = tile.aboutDescription?.let { context.getString(it).lowercase() } ?: "" + title.contains(q) || category.contains(q) || desc.contains(q) + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/services/tiles/AppFreezingTileService.kt b/app/src/main/java/com/sameerasw/essentials/services/tiles/AppFreezingTileService.kt index 4a6fa7841..9600094c0 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/tiles/AppFreezingTileService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/tiles/AppFreezingTileService.kt @@ -16,6 +16,8 @@ import android.service.quicksettings.Tile import com.sameerasw.essentials.ui.activities.AppFreezingActivity class AppFreezingTileService : BaseTileService() { + override val isSensitiveTile: Boolean = true + override fun onTileClick() { val intent = Intent(this, AppFreezingActivity::class.java).apply { diff --git a/app/src/main/java/com/sameerasw/essentials/services/tiles/BaseTileService.kt b/app/src/main/java/com/sameerasw/essentials/services/tiles/BaseTileService.kt index 1ab055553..ed7fccc22 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/tiles/BaseTileService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/tiles/BaseTileService.kt @@ -14,6 +14,7 @@ import android.os.Build import android.provider.Settings import android.service.quicksettings.Tile import android.service.quicksettings.TileService +import android.widget.Toast import androidx.core.content.edit import com.sameerasw.essentials.R import com.sameerasw.essentials.data.repository.SettingsRepository @@ -32,6 +33,8 @@ abstract class BaseTileService : TileService() { protected var isProcessing = false + open val isSensitiveTile: Boolean = false + abstract fun onTileClick() abstract fun getTileLabel(): String @@ -88,6 +91,23 @@ abstract class BaseTileService : TileService() { return } + val settingsRepository = SettingsRepository(this) + if (isSensitiveTile && settingsRepository.isSecureSensitiveTilesEnabled() && isLocked) { + Toast.makeText( + this, + getString(R.string.qs_tile_unlock_to_use, getTileLabel()), + Toast.LENGTH_SHORT, + ).show() + unlockAndRun { + executeTileClick() + } + return + } + + executeTileClick() + } + + private fun executeTileClick() { // Immediate feedback 2: Processing state isProcessing = true updateTile() diff --git a/app/src/main/java/com/sameerasw/essentials/services/tiles/DeveloperOptionsTileService.kt b/app/src/main/java/com/sameerasw/essentials/services/tiles/DeveloperOptionsTileService.kt index d0380748f..1236853e1 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/tiles/DeveloperOptionsTileService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/tiles/DeveloperOptionsTileService.kt @@ -21,6 +21,8 @@ import com.sameerasw.essentials.utils.PermissionUtils @RequiresApi(Build.VERSION_CODES.N) class DeveloperOptionsTileService : BaseTileService() { + override val isSensitiveTile: Boolean = true + override fun onClick() { if (!hasFeaturePermission()) { val intent = diff --git a/app/src/main/java/com/sameerasw/essentials/services/tiles/NfcTileService.kt b/app/src/main/java/com/sameerasw/essentials/services/tiles/NfcTileService.kt index 0118ab06b..c102fe256 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/tiles/NfcTileService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/tiles/NfcTileService.kt @@ -21,6 +21,8 @@ import java.lang.reflect.Method @RequiresApi(Build.VERSION_CODES.N) class NfcTileService : BaseTileService() { + override val isSensitiveTile: Boolean = true + private val nfcReceiver = object : android.content.BroadcastReceiver() { override fun onReceive( diff --git a/app/src/main/java/com/sameerasw/essentials/services/tiles/PrivateDnsTileService.kt b/app/src/main/java/com/sameerasw/essentials/services/tiles/PrivateDnsTileService.kt index 901a2548b..52c3d5572 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/tiles/PrivateDnsTileService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/tiles/PrivateDnsTileService.kt @@ -31,6 +31,8 @@ class PrivateDnsTileService : BaseTileService() { private const val MODE_HOSTNAME = "hostname" } + override val isSensitiveTile: Boolean = true + override fun onClick() { if (!hasFeaturePermission()) { val intent = diff --git a/app/src/main/java/com/sameerasw/essentials/services/tiles/PrivateNotificationsTileService.kt b/app/src/main/java/com/sameerasw/essentials/services/tiles/PrivateNotificationsTileService.kt index 990505e9d..ee51554ed 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/tiles/PrivateNotificationsTileService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/tiles/PrivateNotificationsTileService.kt @@ -17,6 +17,8 @@ import android.service.quicksettings.Tile import com.sameerasw.essentials.R class PrivateNotificationsTileService : BaseTileService() { + override val isSensitiveTile: Boolean = true + override fun getTileLabel(): String = "Sensitive Content" override fun getTileSubtitle(): String = if (arePrivateNotificationsAllowed()) "Shown on lock screen" else "Hidden on lock screen" diff --git a/app/src/main/java/com/sameerasw/essentials/services/tiles/RestartSystemUiTileService.kt b/app/src/main/java/com/sameerasw/essentials/services/tiles/RestartSystemUiTileService.kt index 0e9b730d8..46aa3d323 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/tiles/RestartSystemUiTileService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/tiles/RestartSystemUiTileService.kt @@ -21,6 +21,8 @@ import com.sameerasw.essentials.utils.ShizukuUtils @RequiresApi(Build.VERSION_CODES.N) class RestartSystemUiTileService : BaseTileService() { + override val isSensitiveTile: Boolean = true + override fun onClick() { if (!hasFeaturePermission()) { val intent = diff --git a/app/src/main/java/com/sameerasw/essentials/services/tiles/UrlShortenerTileService.kt b/app/src/main/java/com/sameerasw/essentials/services/tiles/UrlShortenerTileService.kt index 75cbf7352..7ad83db6d 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/tiles/UrlShortenerTileService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/tiles/UrlShortenerTileService.kt @@ -29,6 +29,8 @@ import kotlinx.coroutines.withContext @RequiresApi(Build.VERSION_CODES.N) class UrlShortenerTileService : BaseTileService() { + override val isSensitiveTile: Boolean = true + override fun getTileLabel(): String = getString(R.string.tile_url_shortener) override fun getTileSubtitle(): String = getString(R.string.tile_url_shortener_subtitle) diff --git a/app/src/main/java/com/sameerasw/essentials/services/tiles/UsbDebuggingTileService.kt b/app/src/main/java/com/sameerasw/essentials/services/tiles/UsbDebuggingTileService.kt index 5b171b049..fcebc4647 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/tiles/UsbDebuggingTileService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/tiles/UsbDebuggingTileService.kt @@ -21,6 +21,8 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch class UsbDebuggingTileService : BaseTileService() { + override val isSensitiveTile: Boolean = true + override fun onClick() { if (!hasFeaturePermission()) { val intent = diff --git a/app/src/main/java/com/sameerasw/essentials/ui/activities/PixelSearchResultsActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/PixelSearchResultsActivity.kt index 1c465d60b..e1210fef9 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/activities/PixelSearchResultsActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/PixelSearchResultsActivity.kt @@ -107,14 +107,22 @@ import com.sameerasw.essentials.FeatureSettingsActivity import com.sameerasw.essentials.R import com.sameerasw.essentials.data.repository.SettingsRepository import com.sameerasw.essentials.domain.model.PixelSearchResultItem +import com.sameerasw.essentials.domain.registry.FeatureRegistry +import com.sameerasw.essentials.domain.registry.QSTileInfo +import com.sameerasw.essentials.domain.registry.QSTileRegistry import com.sameerasw.essentials.domain.registry.SearchRegistry +import com.sameerasw.essentials.ui.activities.PixelSearchbarSettingsActivity import com.sameerasw.essentials.ui.activities.WallpaperActivity +import com.sameerasw.essentials.ui.activities.YourAndroidActivity import com.sameerasw.essentials.ui.core.cards.FeatureCard import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer +import com.sameerasw.essentials.ui.features.tiles.QSTilesSearchResultCard import com.sameerasw.essentials.ui.modifiers.BlurDirection import com.sameerasw.essentials.ui.modifiers.progressiveBlur import com.sameerasw.essentials.ui.theme.EssentialsTheme import com.sameerasw.essentials.utils.AppUtil +import androidx.lifecycle.viewmodel.compose.viewModel +import com.sameerasw.essentials.viewmodels.MainViewModel import com.sameerasw.essentials.utils.ColorUtil import com.sameerasw.essentials.utils.HapticUtil import com.sameerasw.essentials.utils.WindowingUtils @@ -354,10 +362,12 @@ fun PixelSearchResultsScreen( } } + val viewModel: MainViewModel = viewModel() var appResults by remember { mutableStateOf>(emptyList()) } var contactResults by remember { mutableStateOf>(emptyList()) } var systemSettingResults by remember { mutableStateOf>(emptyList()) } var settingResults by remember { mutableStateOf>(emptyList()) } + var matchingQsTiles by remember { mutableStateOf>(emptyList()) } var shortcutResults by remember { mutableStateOf>(emptyList()) } var isSearching by remember { mutableStateOf(false) } @@ -376,6 +386,7 @@ fun PixelSearchResultsScreen( contactResults = emptyList() systemSettingResults = emptyList() settingResults = emptyList() + matchingQsTiles = emptyList() shortcutResults = emptyList() isSearching = false return @@ -418,12 +429,22 @@ fun PixelSearchResultsScreen( if (isSettingsEnabled) { val systemSettings = loadSystemSettings(trimmed) val results = SearchRegistry.search(context, trimmed, repository.isEnableUnsupportedFeatures()) - val mappedSettings = results.take(6).map { - PixelSearchResultItem.SettingItem(it) - } + val qsTiles = QSTileRegistry.searchTiles( + context = context, + query = trimmed, + includeUnsupported = repository.isEnableUnsupportedFeatures(), + isUseUsageStats = repository.getBoolean(SettingsRepository.KEY_USE_USAGE_ACCESS), + ) + val mappedSettings = results + .filter { it.featureKey != "Quick settings tiles" } + .take(6) + .map { + PixelSearchResultItem.SettingItem(it) + } withContext(Dispatchers.Main) { systemSettingResults = systemSettings settingResults = mappedSettings + matchingQsTiles = qsTiles } } @@ -464,7 +485,7 @@ fun PixelSearchResultsScreen( val hasApps = isAppsEnabled && appResults.isNotEmpty() val hasContacts = !hasApps && isContactsEnabled && contactResults.isNotEmpty() val hasSystemSettings = !hasApps && !hasContacts && isSettingsEnabled && systemSettingResults.isNotEmpty() - val hasEssentials = !hasApps && !hasContacts && !hasSystemSettings && isSettingsEnabled && settingResults.isNotEmpty() + val hasEssentials = !hasApps && !hasContacts && !hasSystemSettings && isSettingsEnabled && (matchingQsTiles.isNotEmpty() || settingResults.isNotEmpty()) val hasShortcuts = !hasApps && !hasContacts && !hasSystemSettings && !hasEssentials && isShortcutsEnabled && shortcutResults.isNotEmpty() val highlightColor = MaterialTheme.colorScheme.secondaryContainer @@ -727,43 +748,88 @@ fun PixelSearchResultsScreen( } // ESSENTIALS - if (isSettingsEnabled && settingResults.isNotEmpty()) { + if (isSettingsEnabled && (matchingQsTiles.isNotEmpty() || settingResults.isNotEmpty())) { item(key = "essentials_header") { SearchSectionHeader( stringResource(R.string.pixel_search_section_essentials), modifier = Modifier.animateItem(), ) } - item(key = "essentials_cards") { - RoundedCardContainer(modifier = Modifier.animateItem()) { - settingResults.forEachIndexed { index, item -> - val isTopmost = index == 0 && hasEssentials - val setting = item.searchableItem - FeatureCard( - title = setting.title, - isEnabled = true, - onToggle = {}, - onClick = { - HapticUtil.performVirtualKeyHaptic(view) - val intent = Intent(context, FeatureSettingsActivity::class.java).apply { - putExtra("feature", setting.featureKey) - setting.targetSettingHighlightKey?.let { - putExtra("highlight_setting", it) - } - } - context.startActivity(intent) - onFinish() - }, - containerColor = if (isTopmost) highlightColor else normalCardColor, - iconRes = setting.icon ?: R.drawable.rounded_settings_24, - showToggle = false, - hasMoreSettings = true, - description = setting.description, - isBeta = setting.isBeta, - ) + if (settingResults.isNotEmpty()) { + item(key = "essentials_cards") { + RoundedCardContainer(modifier = Modifier.animateItem()) { + settingResults.forEachIndexed { index, item -> + val isTopmost = index == 0 && hasEssentials + val setting = item.searchableItem + FeatureCard( + title = setting.title, + isEnabled = true, + onToggle = {}, + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + val feature = FeatureRegistry.ALL_FEATURES.find { it.id == setting.featureKey } + val targetFeatureKey = + if (feature != null && !feature.hasMoreSettings && feature.parentFeatureId != null) { + feature.parentFeatureId + } else { + setting.featureKey + } + val highlightKey = + if (feature != null && !feature.hasMoreSettings && feature.parentFeatureId != null) { + feature.id + } else { + setting.targetSettingHighlightKey + } + + val intent = + if (targetFeatureKey == "Pixel Searchbar") { + Intent(context, PixelSearchbarSettingsActivity::class.java) + } else if (targetFeatureKey == "LiveWallpaper" || targetFeatureKey == "Daily Wallpaper") { + Intent(context, WallpaperActivity::class.java).apply { + putExtra( + "tab", + if (targetFeatureKey == "LiveWallpaper") "live" else "daily", + ) + } + } else if (targetFeatureKey == "App updates") { + Intent(context, YourAndroidActivity::class.java) + } else { + Intent(context, FeatureSettingsActivity::class.java).apply { + putExtra("feature", targetFeatureKey) + highlightKey?.let { + putExtra("highlight_setting", it) + } + } + } + context.startActivity(intent) + onFinish() + }, + containerColor = if (isTopmost) highlightColor else normalCardColor, + iconRes = setting.icon ?: R.drawable.rounded_settings_24, + showToggle = false, + hasMoreSettings = true, + description = setting.description, + isBeta = setting.isBeta, + descriptionOverride = + if (setting.parentFeature != null) { + "${setting.parentFeature} > ${setting.description}" + } else { + setting.description + }, + ) + } } } } + if (matchingQsTiles.isNotEmpty()) { + item(key = "essentials_qs_tiles") { + QSTilesSearchResultCard( + tiles = matchingQsTiles, + viewModel = viewModel, + modifier = Modifier.animateItem(), + ) + } + } } // SHORTCUTS diff --git a/app/src/main/java/com/sameerasw/essentials/ui/activities/SettingsActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/SettingsActivity.kt index 0122d27ed..b6db2802b 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/activities/SettingsActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/SettingsActivity.kt @@ -74,6 +74,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView @@ -119,6 +121,7 @@ import com.sameerasw.essentials.ui.theme.Shapes import com.sameerasw.essentials.utils.DeviceUtils import com.sameerasw.essentials.utils.HapticUtil import com.sameerasw.essentials.utils.PermissionUtils +import com.sameerasw.essentials.utils.PermissionUIHelper import com.sameerasw.essentials.viewmodels.GitHubAuthViewModel import com.sameerasw.essentials.viewmodels.MainViewModel import rikka.shizuku.Shizuku @@ -214,9 +217,12 @@ class SettingsActivity : AppCompatActivity() { end = 16.dp, ) + val expandPermissions = intent.getBooleanExtra("expand_permissions", false) + SettingsContent( viewModel = viewModel, contentPadding = contentPadding, + expandPermissionsInitial = expandPermissions, onAppIconSelectedWithPosition = { _, pos -> iconRippleOrigin = pos iconRippleTrigger++ @@ -283,6 +289,7 @@ fun SettingsContent( viewModel: MainViewModel, contentPadding: PaddingValues, modifier: Modifier = Modifier, + expandPermissionsInitial: Boolean = false, onAppIconSelectedWithPosition: ((com.sameerasw.essentials.domain.model.AppIcon, Offset) -> Unit)? = null, onAvatarLongClickWithPosition: ((Offset) -> Unit)? = null, onRippleToggleEnabledWithPosition: ((Offset) -> Unit)? = null, @@ -305,7 +312,7 @@ fun SettingsContent( val isUsageStatsPermissionGranted by viewModel.isUsageStatsPermissionGranted val context = LocalContext.current val isAppHapticsEnabled = remember { mutableStateOf(HapticUtil.loadAppHapticsEnabled(context)) } - var isPermissionsExpanded by remember { mutableStateOf(false) } + var isPermissionsExpanded by remember { mutableStateOf(expandPermissionsInitial) } var showUpdateSheet by remember { mutableStateOf(false) } val updateInfo by viewModel.updateInfo val isUpdateAvailable by viewModel.isUpdateAvailable @@ -527,6 +534,13 @@ fun SettingsContent( val sentryMode by viewModel.sentryReportMode val isMotionBlurEnabled by viewModel.isMotionBlurEnabled val scrollState = rememberScrollState() + var permissionsSectionY by remember { mutableStateOf(null) } + + LaunchedEffect(expandPermissionsInitial, permissionsSectionY) { + if (expandPermissionsInitial && permissionsSectionY != null) { + scrollState.animateScrollTo(permissionsSectionY!!.toInt()) + } + } Column( modifier = @@ -709,6 +723,13 @@ fun SettingsContent( onCheckedChange = { viewModel.setMotionBlurEnabled(it, context) }, ) + IconToggleItem( + iconRes = R.drawable.rounded_music_video_24, + title = stringResource(R.string.label_online_help_media), + isChecked = viewModel.isOnlineHelpMediaEnabled.value, + onCheckedChange = { viewModel.setOnlineHelpMediaEnabled(it, context) }, + ) + CrashReportingPicker( selectedMode = sentryMode, onModeSelected = { viewModel.setSentryReportMode(it, context) }, @@ -1022,6 +1043,9 @@ fun SettingsContent( modifier = Modifier .fillMaxWidth() + .onGloballyPositioned { coordinates -> + permissionsSectionY = coordinates.positionInParent().y + } .clickable { isPermissionsExpanded = !isPermissionsExpanded } .padding(start = 16.dp, top = 16.dp, bottom = 8.dp, end = 16.dp), verticalAlignment = Alignment.CenterVertically, @@ -1032,7 +1056,7 @@ fun SettingsContent( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Text( - text = "Permissions", + text = stringResource(R.string.settings_section_permissions), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -1052,249 +1076,48 @@ fun SettingsContent( enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut(), ) { - RoundedCardContainer { - PermissionCard( - iconRes = R.drawable.rounded_settings_accessibility_24, - title = "Accessibility", - dependentFeatures = PermissionRegistry.getFeatures("ACCESSIBILITY"), - actionLabel = if (isAccessibilityEnabled) "Granted" else "Grant Permission", - isGranted = isAccessibilityEnabled, - onActionClick = { - val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) - context.startActivity(intent) - }, - ) - - PermissionCard( - iconRes = R.drawable.rounded_security_24, - title = "Write Secure Settings", - dependentFeatures = PermissionRegistry.getFeatures("WRITE_SECURE_SETTINGS"), - actionLabel = if (isWriteSecureSettingsEnabled) "Granted" else "Copy ADB Command", - isGranted = isWriteSecureSettingsEnabled, - onActionClick = { - 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 = "Check", - onSecondaryActionClick = { - viewModel.check(context) - }, - ) - - if (isRootEnabled) { - PermissionCard( - iconRes = R.drawable.rounded_numbers_24, - title = stringResource(R.string.perm_root_title), - dependentFeatures = PermissionRegistry.getFeatures("ROOT"), - actionLabel = if (isRootPermissionGranted) "Granted" else "Grant Access", - isGranted = isRootPermissionGranted, - onActionClick = { - viewModel.check(context) - }, - ) - } else if (isShizukuAvailable) { - PermissionCard( - iconRes = R.drawable.rounded_adb_24, - title = "Shizuku", - dependentFeatures = PermissionRegistry.getFeatures("SHIZUKU"), - actionLabel = if (isShizukuPermissionGranted) "Granted" else "Request Permission", - isGranted = isShizukuPermissionGranted, - onActionClick = { - viewModel.requestShizukuPermission() - }, - secondaryActionLabel = if (isShizukuPermissionGranted && !isWriteSecureSettingsEnabled) "Auto-Grant" else null, - onSecondaryActionClick = - if (isShizukuPermissionGranted && !isWriteSecureSettingsEnabled) { - { - viewModel.grantWriteSecureSettingsWithShizuku(context) - } - } else { - null - }, - ) + val permissionItems = + remember( + isAccessibilityEnabled, + isWriteSecureSettingsEnabled, + isRootEnabled, + isRootPermissionGranted, + isShizukuPermissionGranted, + isShizukuAvailable, + isReadPhoneStateEnabled, + isPostNotificationsEnabled, + isOverlayPermissionGranted, + isNotificationListenerEnabled, + isWriteSettingsEnabled, + isNotificationPolicyAccessGranted, + isDefaultBrowserSet, + isUsageStatsPermissionGranted, + isLocationPermissionGranted, + isBackgroundLocationPermissionGranted, + isDeviceAdminEnabled, + isCalendarPermissionGranted, + ) { + PermissionUIHelper.getAllPermissionItems(context, viewModel, context as? ComponentActivity) } - PermissionCard( - iconRes = R.drawable.rounded_android_cell_dual_4_bar_24, - title = "Read Phone State", - dependentFeatures = PermissionRegistry.getFeatures("READ_PHONE_STATE"), - actionLabel = if (isReadPhoneStateEnabled) "Granted" else "Grant Permission", - isGranted = isReadPhoneStateEnabled, - onActionClick = { - viewModel.requestReadPhoneStatePermission(context as ComponentActivity) - }, - ) - - PermissionCard( - iconRes = R.drawable.rounded_notifications_unread_24, - title = "Post Notifications", - dependentFeatures = PermissionRegistry.getFeatures("POST_NOTIFICATIONS"), - actionLabel = if (isPostNotificationsEnabled) "Granted" else "Grant Permission", - isGranted = isPostNotificationsEnabled, - onActionClick = { - // Request permission - ActivityCompat.requestPermissions( - context as ComponentActivity, - arrayOf(Manifest.permission.POST_NOTIFICATIONS), - 1002, - ) - }, - ) - - PermissionCard( - iconRes = R.drawable.rounded_magnify_fullscreen_24, - title = "Draw Overlays", - dependentFeatures = PermissionRegistry.getFeatures("DRAW_OVER_OTHER_APPS"), - actionLabel = if (isOverlayPermissionGranted) "Granted" else "Grant Permission", - isGranted = isOverlayPermissionGranted, - onActionClick = { - val intent = - Intent( - Settings.ACTION_MANAGE_OVERLAY_PERMISSION, - android.net.Uri.parse("package:${context.packageName}"), - ) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK - context.startActivity(intent) - }, - ) - - PermissionCard( - iconRes = R.drawable.rounded_notification_settings_24, - title = "Notification Listener", - dependentFeatures = PermissionRegistry.getFeatures("NOTIFICATION_LISTENER"), - actionLabel = if (isNotificationListenerEnabled) "Granted" else "Enable listener", - isGranted = isNotificationListenerEnabled, - onActionClick = { - val intent = - Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK - } - context.startActivity(intent) - }, - ) - - PermissionCard( - iconRes = R.drawable.rounded_security_24, - title = stringResource(R.string.perm_write_settings_title), - dependentFeatures = PermissionRegistry.getFeatures("WRITE_SETTINGS"), - actionLabel = if (isWriteSettingsEnabled) "Granted" else "Grant Permission", - isGranted = isWriteSettingsEnabled, - onActionClick = { - PermissionUtils.openWriteSettings(context) - }, - ) - - PermissionCard( - iconRes = R.drawable.rounded_volume_up_24, - title = stringResource(R.string.perm_notif_policy_title), - dependentFeatures = PermissionRegistry.getFeatures("NOTIFICATION_POLICY"), - actionLabel = if (isNotificationPolicyAccessGranted) "Granted" else "Grant Permission", - isGranted = isNotificationPolicyAccessGranted, - onActionClick = { - PermissionUtils.openNotificationPolicySettings(context) - }, - ) - - PermissionCard( - iconRes = R.drawable.rounded_open_in_browser_24, - title = stringResource(R.string.perm_default_browser_title), - dependentFeatures = PermissionRegistry.getFeatures("DEFAULT_BROWSER"), - actionLabel = if (isDefaultBrowserSet) "Granted" else "Set as Default", - isGranted = isDefaultBrowserSet, - onActionClick = { - val intent = Intent(Settings.ACTION_MANAGE_DEFAULT_APPS_SETTINGS) - try { - context.startActivity(intent) - } catch (e: Exception) { - // Fallback for older Android versions - val settingsIntent = Intent(Settings.ACTION_SETTINGS) - context.startActivity(settingsIntent) - } - }, - ) - - PermissionCard( - iconRes = R.drawable.rounded_settings_motion_mode_24, - title = stringResource(R.string.perm_write_settings_title), - dependentFeatures = PermissionRegistry.getFeatures("WRITE_SETTINGS"), - actionLabel = if (isWriteSettingsEnabled) "Granted" else "Grant Permission", - isGranted = isWriteSettingsEnabled, - onActionClick = { - PermissionUtils.openWriteSettings(context) - }, - ) - - PermissionCard( - iconRes = R.drawable.rounded_notifications_off_24, - title = stringResource(R.string.perm_notif_policy_title), - dependentFeatures = PermissionRegistry.getFeatures("NOTIFICATION_POLICY"), - actionLabel = if (isNotificationPolicyAccessGranted) "Granted" else "Grant Permission", - isGranted = isNotificationPolicyAccessGranted, - onActionClick = { - PermissionUtils.openNotificationPolicySettings(context) - }, - ) - - PermissionCard( - iconRes = R.drawable.rounded_data_usage_24, - title = stringResource(R.string.perm_usage_stats_title), - dependentFeatures = PermissionRegistry.getFeatures("USAGE_STATS"), - actionLabel = if (isUsageStatsPermissionGranted) "Granted" else "Grant Permission", - isGranted = isUsageStatsPermissionGranted, - onActionClick = { - PermissionUtils.openUsageStatsSettings(context) - }, - ) - - PermissionCard( - iconRes = R.drawable.rounded_location_on_24, - title = "Location Access", - dependentFeatures = PermissionRegistry.getFeatures("LOCATION"), - actionLabel = if (isLocationPermissionGranted) "Granted" else "Grant Permission", - isGranted = isLocationPermissionGranted, - onActionClick = { - viewModel.requestLocationPermission(context as ComponentActivity) - }, - ) - - if (isLocationPermissionGranted && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + RoundedCardContainer { + permissionItems.forEach { item -> PermissionCard( - iconRes = R.drawable.rounded_location_on_24, - title = "Background Location", - dependentFeatures = PermissionRegistry.getFeatures("BACKGROUND_LOCATION"), - actionLabel = if (isBackgroundLocationPermissionGranted) "Granted" else "Grant Permission", - isGranted = isBackgroundLocationPermissionGranted, - onActionClick = { - viewModel.requestBackgroundLocationPermission(context as ComponentActivity) - }, + iconRes = item.iconRes, + title = item.title, + dependentFeatures = item.dependentFeatures, + actionLabel = item.actionLabel ?: R.string.perm_action_grant, + isGranted = item.isGranted, + onActionClick = { item.action?.invoke() }, + secondaryActionLabel = item.secondaryActionLabel, + onSecondaryActionClick = item.secondaryAction, + shizukuActionLabel = item.shizukuActionLabel, + shizukuActionEnabled = item.shizukuActionEnabled, + onShizukuActionClick = item.shizukuAction, + instructions = item.instructions, + description = item.description, ) } - - PermissionCard( - iconRes = R.drawable.rounded_admin_panel_settings_24, - title = "Device Admin", - dependentFeatures = PermissionRegistry.getFeatures("DEVICE_ADMIN"), - actionLabel = if (isDeviceAdminEnabled) "Granted" else "Enable Admin", - isGranted = isDeviceAdminEnabled, - onActionClick = { - viewModel.requestDeviceAdmin(context) - }, - ) - - PermissionCard( - iconRes = R.drawable.rounded_calendar_today_24, - title = "Calendar", - dependentFeatures = PermissionRegistry.getFeatures("READ_CALENDAR"), - actionLabel = if (isCalendarPermissionGranted) "Granted" else "Grant Permission", - isGranted = isCalendarPermissionGranted, - onActionClick = { - viewModel.requestCalendarPermission(context as ComponentActivity) - }, - ) } } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/FavoriteCarousel.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/FavoriteCarousel.kt index 57a493ee0..c5362206b 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/components/FavoriteCarousel.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/components/FavoriteCarousel.kt @@ -15,6 +15,7 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -145,7 +146,13 @@ fun FavoriteCarousel( label = "alpha", ) - val pastelColor = ColorUtil.getPastelColorFor(resolvedTitle) + val isDarkTheme = isSystemInDarkTheme() + val iconColor = + if (isDarkTheme) { + ColorUtil.getPastelColorFor(resolvedTitle) + } else { + ColorUtil.getVibrantColorFor(resolvedTitle) + } Box( modifier = @@ -190,7 +197,7 @@ fun FavoriteCarousel( painter = painterResource(id = feature.iconRes), contentDescription = resolvedTitle, modifier = Modifier.size(24.dp), - tint = pastelColor, + tint = iconColor, ) } Spacer(modifier = Modifier.height(8.dp)) 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 0e7b73861..bb99bda6f 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 @@ -17,6 +17,8 @@ import android.content.Intent import android.provider.Settings import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.tween import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -88,6 +90,9 @@ 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.domain.registry.QSTileRegistry +import com.sameerasw.essentials.ui.activities.PixelSearchbarSettingsActivity +import com.sameerasw.essentials.ui.activities.WallpaperActivity import com.sameerasw.essentials.ui.activities.YourAndroidActivity import com.sameerasw.essentials.ui.components.FavoriteCarousel import com.sameerasw.essentials.ui.components.buttons.ListExpandToggleButton @@ -96,6 +101,9 @@ import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer import com.sameerasw.essentials.ui.core.sheets.PermissionItem import com.sameerasw.essentials.ui.core.sheets.PermissionsBottomSheet import com.sameerasw.essentials.ui.core.sheets.ReorderFavoritesBottomSheet +import com.sameerasw.essentials.ui.features.permissions.PermissionsSearchResultCard +import com.sameerasw.essentials.ui.features.tiles.QSTilesSearchResultCard +import com.sameerasw.essentials.utils.PermissionUIHelper import androidx.compose.ui.geometry.Offset import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot @@ -1033,6 +1041,24 @@ fun SetupFeatures( val searchResults = viewModel.searchResults.value val isSearchingViewModel = viewModel.isSearching.value val recentSearches by viewModel.recentSearches + val matchingTiles = + remember(searchQuery, viewModel.isEnableUnsupportedFeatures.value, viewModel.isUseUsageAccess.value) { + QSTileRegistry.searchTiles( + context = context, + query = searchQuery, + includeUnsupported = viewModel.isEnableUnsupportedFeatures.value, + isUseUsageStats = viewModel.isUseUsageAccess.value, + ) + } + val matchingPermissions = + remember(searchQuery, viewModel) { + PermissionUIHelper.searchPermissions( + context = context, + query = searchQuery, + viewModel = viewModel, + activity = context as? Activity, + ) + } val isMotionBlurEnabled by viewModel.isMotionBlurEnabled @@ -1113,7 +1139,7 @@ fun SetupFeatures( val size = coords.size textCenterOffset = Offset( x = pos.x + (size.width / 2f), - y = pos.y + (size.height / 2f) + y = pos.y + (size.height / 2f), ) } .graphicsLayer { @@ -1129,26 +1155,45 @@ fun SetupFeatures( } item { - // Search Field + // Search Bar OutlinedTextField( value = searchQuery, - onValueChange = { new -> - viewModel.onSearchQueryChanged(new, context) + onValueChange = { + viewModel.onSearchQueryChanged(it, context) }, - maxLines = 1, - textStyle = MaterialTheme.typography.bodyLarge, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 16.dp) - .defaultMinSize(minHeight = 64.dp) + .padding(horizontal = 16.dp, vertical = 8.dp) .focusRequester(focusRequester) - .onFocusChanged { isFocused = it.isFocused }, + .onFocusChanged { + isFocused = it.isFocused + }, leadingIcon = { - Box(modifier = Modifier.padding(start = 16.dp, end = 8.dp)) { - if (isSearchingViewModel) { - LoadingIndicator() - } else { + Box( + modifier = + Modifier + .padding(start = 12.dp, end = 4.dp) + .size(40.dp), + contentAlignment = Alignment.Center, + ) { + androidx.compose.animation.AnimatedVisibility( + visible = isSearchingViewModel, + enter = androidx.compose.animation.fadeIn(), + exit = androidx.compose.animation.fadeOut(), + ) { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + } + + androidx.compose.animation.AnimatedVisibility( + visible = !isSearchingViewModel, + enter = androidx.compose.animation.fadeIn(), + exit = androidx.compose.animation.fadeOut(), + ) { Icon( painter = painterResource(id = R.drawable.rounded_search_24), contentDescription = stringResource(R.string.label_search_content_description), @@ -1235,7 +1280,7 @@ fun SetupFeatures( ) } } else if (isFocused && searchQuery.isNotEmpty()) { - if (!isSearchingViewModel && searchResults.isEmpty()) { + if (!isSearchingViewModel && searchResults.isEmpty() && matchingTiles.isEmpty() && matchingPermissions.isEmpty()) { item { Column( modifier = @@ -1266,14 +1311,16 @@ fun SetupFeatures( } } - item { - SearchResultsSection( - searchResults = searchResults, - allFeatures = allFeatures, - pinnedFeatureKeys = pinnedFeatureKeys, - context = context, - viewModel = viewModel, - ) + if (!isSearchingViewModel) { + item { + SearchResultsSection( + searchResults = searchResults, + allFeatures = allFeatures, + pinnedFeatureKeys = pinnedFeatureKeys, + context = context, + viewModel = viewModel, + ) + } } } else if (!isFocused) { val topLevelFeatures = @@ -1553,119 +1600,206 @@ private fun SearchResultsSection( context: Context, viewModel: MainViewModel, ) { - if (searchResults.isNotEmpty()) { - Text( - text = stringResource(R.string.search_results_title), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(start = 32.dp, top = 16.dp, bottom = 8.dp), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + val query = viewModel.searchQuery.value + val matchingTiles = + remember(query, viewModel.isEnableUnsupportedFeatures.value, viewModel.isUseUsageAccess.value) { + QSTileRegistry.searchTiles( + context = context, + query = query, + includeUnsupported = viewModel.isEnableUnsupportedFeatures.value, + isUseUsageStats = viewModel.isUseUsageAccess.value, + ) + } + val matchingPermissions = + remember(query, viewModel) { + PermissionUIHelper.searchPermissions( + context = context, + query = query, + viewModel = viewModel, + activity = context as? Activity, + ) + } - RoundedCardContainer( - modifier = Modifier.padding(horizontal = 16.dp), - ) { - for (result in searchResults) { - FeatureCard( - title = result.title, - isEnabled = true, - onToggle = {}, - onClick = { - viewModel.addRecentSearch(result) - val feature = allFeatures.find { it.id == result.featureKey } - if (feature != null) { - val targetFeatureKey = - if (!feature.hasMoreSettings && feature.parentFeatureId != null) { - feature.parentFeatureId - } else { - feature.id - } - val highlightKey = - if (!feature.hasMoreSettings && feature.parentFeatureId != null) { - feature.id + var isExpanded by rememberSaveable(query) { mutableStateOf(false) } + + val nonQSSearchResults = + remember(searchResults) { + searchResults.filter { it.featureKey != "Quick settings tiles" } + } + + val visibleResults = + if (!isExpanded && nonQSSearchResults.size > 7) { + nonQSSearchResults.take(7) + } else { + nonQSSearchResults + } + + if (matchingTiles.isNotEmpty() || nonQSSearchResults.isNotEmpty() || matchingPermissions.isNotEmpty()) { + if (nonQSSearchResults.isNotEmpty()) { + Text( + text = stringResource(R.string.search_results_title), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 32.dp, top = 16.dp, bottom = 8.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + RoundedCardContainer( + modifier = + Modifier + .padding(horizontal = 16.dp, vertical = 4.dp), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .animateContentSize(animationSpec = tween(durationMillis = 300)), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + for (result in visibleResults) { + FeatureCard( + title = result.title, + isEnabled = true, + onToggle = {}, + onClick = { + viewModel.addRecentSearch(result) + val feature = allFeatures.find { it.id == result.featureKey } + if (feature != null) { + val targetFeatureKey = + if (!feature.hasMoreSettings && feature.parentFeatureId != null) { + feature.parentFeatureId + } else { + feature.id + } + val highlightKey = + if (!feature.hasMoreSettings && feature.parentFeatureId != null) { + feature.id + } else { + result.targetSettingHighlightKey + } + BiometricSecurityHelper.runWithAuth( + activity = context as FragmentActivity, + feature = feature, + action = { + val intent = + if (targetFeatureKey == "Pixel Searchbar") { + Intent( + context, + PixelSearchbarSettingsActivity::class.java, + ) + } else if (targetFeatureKey == "LiveWallpaper" || targetFeatureKey == "Daily Wallpaper") { + Intent( + context, + WallpaperActivity::class.java, + ).apply { + putExtra( + "tab", + if (targetFeatureKey == "LiveWallpaper") "live" else "daily", + ) + } + } else if (targetFeatureKey == "App updates") { + Intent( + context, + YourAndroidActivity::class.java, + ) + } else { + Intent( + context, + FeatureSettingsActivity::class.java, + ).apply { + putExtra("feature", targetFeatureKey) + highlightKey?.let { + putExtra("highlight_setting", it) + } + } + } + context.startActivity(intent) + }, + ) } else { - result.targetSettingHighlightKey - } - BiometricSecurityHelper.runWithAuth( - activity = context as FragmentActivity, - feature = feature, - action = { val intent = - if (targetFeatureKey == "LiveWallpaper" || targetFeatureKey == "Daily Wallpaper") { + if (result.featureKey == "Pixel Searchbar") { Intent( context, - com.sameerasw.essentials.ui.activities.WallpaperActivity::class.java, + PixelSearchbarSettingsActivity::class.java, + ) + } else if (result.featureKey == "LiveWallpaper" || result.featureKey == "Daily Wallpaper") { + Intent( + context, + WallpaperActivity::class.java, ).apply { putExtra( "tab", - if (targetFeatureKey == "LiveWallpaper") "live" else "daily", + if (result.featureKey == "LiveWallpaper") "live" else "daily", ) } - } else if (targetFeatureKey == "App updates") { + } else if (result.featureKey == "App updates") { Intent( context, - com.sameerasw.essentials.ui.activities.YourAndroidActivity::class.java, + YourAndroidActivity::class.java, ) } else { - Intent( - context, - FeatureSettingsActivity::class.java, - ).apply { - putExtra("feature", targetFeatureKey) - highlightKey?.let { + Intent(context, FeatureSettingsActivity::class.java).apply { + putExtra("feature", result.featureKey) + result.targetSettingHighlightKey?.let { putExtra("highlight_setting", it) } } } context.startActivity(intent) - }, - ) - } else { - val intent = - if (result.featureKey == "LiveWallpaper" || result.featureKey == "Daily Wallpaper") { - Intent( - context, - com.sameerasw.essentials.ui.activities.WallpaperActivity::class.java, - ).apply { - putExtra( - "tab", - if (result.featureKey == "LiveWallpaper") "live" else "daily", - ) - } - } else if (result.featureKey == "App updates") { - Intent( - context, - com.sameerasw.essentials.ui.activities.YourAndroidActivity::class.java, - ) - } else { - Intent(context, FeatureSettingsActivity::class.java).apply { - putExtra("feature", result.featureKey) - result.targetSettingHighlightKey?.let { - putExtra("highlight_setting", it) - } - } } - context.startActivity(intent) - } - }, - iconRes = result.icon ?: R.drawable.rounded_settings_24, - modifier = Modifier.padding(horizontal = 0.dp, vertical = 0.dp), - showToggle = false, - hasMoreSettings = true, - isBeta = result.isBeta, - descriptionOverride = - if (result.parentFeature != - null - ) { - "${result.parentFeature} > ${result.description}" - } else { - result.description - }, - isPinned = pinnedFeatureKeys.contains(result.featureKey), - onPinToggle = { - viewModel.togglePinFeature(result.featureKey) - }, + }, + iconRes = result.icon ?: R.drawable.rounded_settings_24, + modifier = Modifier.padding(horizontal = 0.dp, vertical = 0.dp), + showToggle = false, + hasMoreSettings = true, + isBeta = result.isBeta, + descriptionOverride = + if (result.parentFeature != + null + ) { + "${result.parentFeature} > ${result.description}" + } else { + result.description + }, + isPinned = pinnedFeatureKeys.contains(result.featureKey), + onPinToggle = { + viewModel.togglePinFeature(result.featureKey) + }, + ) + } + } + } + + if (nonQSSearchResults.size > 7) { + ListExpandToggleButton( + isExpanded = isExpanded, + onToggle = { isExpanded = !isExpanded }, + title = R.string.action_show_more, + description = R.string.action_show_less, + expandedText = stringResource(R.string.action_show_less), + collapsedText = stringResource(R.string.action_show_more), + modifier = + Modifier + .padding(horizontal = 16.dp) + .padding(top = 8.dp, bottom = 4.dp), ) } } + + if (matchingTiles.isNotEmpty()) { + QSTilesSearchResultCard( + tiles = matchingTiles, + viewModel = viewModel, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + } + + if (matchingPermissions.isNotEmpty()) { + PermissionsSearchResultCard( + permissions = matchingPermissions, + viewModel = viewModel, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + } } } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/core/media/FeatureHelpMediaViewer.kt b/app/src/main/java/com/sameerasw/essentials/ui/core/media/FeatureHelpMediaViewer.kt new file mode 100644 index 000000000..a982f3d5c --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/core/media/FeatureHelpMediaViewer.kt @@ -0,0 +1,531 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: UI Core Components + * File: FeatureHelpMediaViewer.kt + * Description: Seamlessly shared help media player supporting in-sheet preview and edge-to-edge immersive full-screen pinch-to-zoom. + */ + +package com.sameerasw.essentials.ui.core.media + +import android.content.Context +import android.net.Uri +import android.view.ViewGroup +import android.view.WindowManager +import androidx.annotation.OptIn +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.calculateCentroid +import androidx.compose.foundation.gestures.calculatePan +import androidx.compose.foundation.gestures.calculateZoom +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.DialogWindowProvider +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.PlayerView +import coil.compose.SubcomposeAsyncImage +import coil.request.ImageRequest +import com.sameerasw.essentials.data.repository.FeatureHelpMedia +import com.sameerasw.essentials.data.repository.HelpMediaRepository +import com.sameerasw.essentials.domain.model.Feature +import com.sameerasw.essentials.viewmodels.MainViewModel +import kotlinx.coroutines.launch + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.ui.res.painterResource +import com.sameerasw.essentials.R +import com.sameerasw.essentials.utils.HapticUtil +import kotlin.math.abs + +@OptIn(UnstableApi::class) +@ExperimentalMaterial3ExpressiveApi +@Composable +fun FeatureHelpMediaViewer( + feature: Feature, + viewModel: MainViewModel, + modifier: Modifier = Modifier, +) { + if (!viewModel.isOnlineHelpMediaEnabled.value) { + return + } + + val context = LocalContext.current + val helpMediaRepo = remember { HelpMediaRepository.getInstance(context) } + var mediaItem by remember(feature.id) { mutableStateOf(null) } + var hasCheckedMedia by remember(feature.id) { mutableStateOf(false) } + var isFullScreenOpen by remember { mutableStateOf(false) } + + LaunchedEffect(feature.id) { + hasCheckedMedia = false + mediaItem = helpMediaRepo.getHelpMediaForFeature(feature.id) + hasCheckedMedia = true + } + + val exoPlayer = + remember(mediaItem?.url) { + val url = mediaItem?.url + if (url != null && mediaItem?.type.equals("video", ignoreCase = true)) { + ExoPlayer.Builder(context).build().apply { + repeatMode = Player.REPEAT_MODE_ALL + volume = 0f + setMediaItem(MediaItem.fromUri(Uri.parse(url))) + prepare() + playWhenReady = true + } + } else { + null + } + } + + DisposableEffect(exoPlayer) { + onDispose { + exoPlayer?.stop() + exoPlayer?.release() + } + } + + var isMediaReady by remember(feature.id) { mutableStateOf(false) } + var isPlaying by remember(exoPlayer) { mutableStateOf(exoPlayer?.isPlaying == true) } + + LaunchedEffect(exoPlayer) { + exoPlayer?.addListener( + object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + if (playbackState == Player.STATE_READY) { + isMediaReady = true + } + } + + override fun onIsPlayingChanged(playing: Boolean) { + isPlaying = playing + } + }, + ) + } + + val playerView = + remember(exoPlayer) { + if (exoPlayer != null) { + PlayerView(context).apply { + useController = false + player = exoPlayer + setShowBuffering(PlayerView.SHOW_BUFFERING_NEVER) + } + } else { + null + } + } + + Box( + modifier = + modifier + .fillMaxWidth() + .animateContentSize( + animationSpec = spring(dampingRatio = 0.8f, stiffness = 400f), + ), + ) { + if (!hasCheckedMedia) { + Box( + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(9f / 12f) + .clip(RoundedCornerShape(24.dp)) + .background(MaterialTheme.colorScheme.surfaceBright), + contentAlignment = Alignment.Center, + ) { + LoadingIndicator() + } + } else if (mediaItem != null) { + val currentMedia = mediaItem!! + val localView = LocalView.current + Box( + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(9f / 12f) + .clip(RoundedCornerShape(24.dp)) + .background(MaterialTheme.colorScheme.surfaceBright) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + HapticUtil.performUIHaptic(localView) + isFullScreenOpen = true + }, + contentAlignment = Alignment.Center, + ) { + if (currentMedia.type.equals("video", ignoreCase = true)) { + if (playerView != null && !isFullScreenOpen) { + AndroidView( + factory = { + (playerView.parent as? ViewGroup)?.removeView(playerView) + playerView + }, + update = { view -> + (view.parent as? ViewGroup)?.let { parent -> + // already attached + } + }, + modifier = Modifier.fillMaxSize(), + ) + } + + AnimatedVisibility( + visible = !isMediaReady, + enter = fadeIn(), + exit = fadeOut(), + ) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceBright), + contentAlignment = Alignment.Center, + ) { + LoadingIndicator() + } + } + } else { + SubcomposeAsyncImage( + model = + ImageRequest.Builder(context) + .data(currentMedia.url) + .crossfade(true) + .build(), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + loading = { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceBright), + contentAlignment = Alignment.Center, + ) { + LoadingIndicator() + } + }, + contentScale = ContentScale.Crop, + ) + } + } + + if (isFullScreenOpen) { + FullScreenMediaDialog( + media = currentMedia, + playerView = playerView, + exoPlayer = exoPlayer, + isPlaying = isPlaying, + onDismissRequest = { isFullScreenOpen = false }, + ) + } + } + } +} + +@OptIn(UnstableApi::class) +@Composable +private fun FullScreenMediaDialog( + media: FeatureHelpMedia, + playerView: PlayerView?, + exoPlayer: ExoPlayer?, + isPlaying: Boolean, + onDismissRequest: () -> Unit, +) { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + val scaleAnim = remember { Animatable(1f) } + val offsetAnim = remember { Animatable(Offset.Zero, Offset.VectorConverter) } + val bgAlphaAnim = remember { Animatable(1f) } + + Dialog( + onDismissRequest = onDismissRequest, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + dismissOnBackPress = true, + dismissOnClickOutside = true, + ), + ) { + val dialogView = LocalView.current + val dialogWindow = (dialogView.parent as? DialogWindowProvider)?.window + SideEffect { + dialogWindow?.let { window -> + WindowCompat.setDecorFitsSystemWindows(window, false) + window.setLayout( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + window.setBackgroundDrawableResource(android.R.color.transparent) + + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) { + window.attributes = + window.attributes.apply { + layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES + } + } + + window.setFlags( + WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, + WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, + ) + + val insetsController = WindowCompat.getInsetsController(window, window.decorView) + insetsController.isAppearanceLightStatusBars = false + insetsController.isAppearanceLightNavigationBars = false + insetsController.hide(WindowInsetsCompat.Type.systemBars()) + insetsController.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + } + + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = bgAlphaAnim.value.coerceIn(0f, 1f))), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = + Modifier + .fillMaxSize() + .graphicsLayer { + scaleX = scaleAnim.value + scaleY = scaleAnim.value + translationX = offsetAnim.value.x + translationY = offsetAnim.value.y + clip = false + } + .pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + var isMultiTouch = false + var totalDragY = 0f + var totalDragX = 0f + var hasMovedSignificantly = false + + do { + val event = awaitPointerEvent() + val pressedCount = event.changes.count { it.pressed } + + if (pressedCount >= 2) { + isMultiTouch = true + val zoomChange = event.calculateZoom() + val panChange = event.calculatePan() + val centroid = event.calculateCentroid(useCurrent = true) + + val oldScale = scaleAnim.value + val newScale = (oldScale * zoomChange).coerceIn(1f, 6f) + + val centerOffset = + centroid - Offset(size.width / 2f, size.height / 2f) + val newOffset = + (offsetAnim.value + panChange) + + centerOffset * (1f - newScale / oldScale) + + coroutineScope.launch { + scaleAnim.snapTo(newScale) + if (newScale > 1.01f) { + offsetAnim.snapTo(newOffset) + bgAlphaAnim.snapTo(1f) + } else { + offsetAnim.snapTo(Offset.Zero) + } + } + event.changes.forEach { it.consume() } + } else if (isMultiTouch || scaleAnim.value > 1.05f) { + // Panning when zoomed in + val panChange = event.calculatePan() + if (panChange != Offset.Zero) { + coroutineScope.launch { + offsetAnim.snapTo(offsetAnim.value + panChange) + } + } + event.changes.forEach { it.consume() } + } else { + // Single touch swipe-to-dismiss gesture when scale is 1f + val panChange = event.calculatePan() + totalDragY += panChange.y + totalDragX += panChange.x + + if (abs(totalDragY) > 8f || abs(totalDragX) > 8f) { + hasMovedSignificantly = true + } + + if (hasMovedSignificantly) { + val currentOffsetY = offsetAnim.value.y + panChange.y + val currentOffsetX = offsetAnim.value.x + (panChange.x * 0.4f) + val progress = (abs(currentOffsetY) / (size.height * 0.45f)).coerceIn(0f, 1f) + val targetAlpha = 1f - (progress * 0.85f) + + coroutineScope.launch { + offsetAnim.snapTo(Offset(currentOffsetX, currentOffsetY)) + bgAlphaAnim.snapTo(targetAlpha) + } + event.changes.forEach { it.consume() } + } + } + } while (event.changes.any { it.pressed }) + + if (scaleAnim.value > 1.01f) { + // Animate zoom back + coroutineScope.launch { + launch { + scaleAnim.animateTo( + targetValue = 1f, + animationSpec = spring(dampingRatio = 0.8f, stiffness = 400f), + ) + } + launch { + offsetAnim.animateTo( + targetValue = Offset.Zero, + animationSpec = spring(dampingRatio = 0.8f, stiffness = 400f), + ) + } + } + } else if (hasMovedSignificantly) { + val dismissThreshold = size.height * 0.15f + if (abs(offsetAnim.value.y) > dismissThreshold) { + // Instant dismiss directly so view seamlessly returns to sheet + onDismissRequest() + } else { + // Spring bounce back to center + coroutineScope.launch { + launch { + bgAlphaAnim.animateTo( + targetValue = 1f, + animationSpec = spring(dampingRatio = 0.8f, stiffness = 400f), + ) + } + launch { + offsetAnim.animateTo( + targetValue = Offset.Zero, + animationSpec = spring(dampingRatio = 0.8f, stiffness = 400f), + ) + } + } + } + } else { + // Single tap -> Toggle Play/Pause for video + if (exoPlayer != null && media.type.equals("video", ignoreCase = true)) { + HapticUtil.performUIHaptic(dialogView) + if (exoPlayer.isPlaying) { + exoPlayer.pause() + } else { + exoPlayer.play() + } + } + } + } + }, + contentAlignment = Alignment.Center, + ) { + if (media.type.equals("video", ignoreCase = true)) { + if (playerView != null) { + AndroidView( + factory = { + (playerView.parent as? ViewGroup)?.removeView(playerView) + playerView + }, + update = { view -> + (view.parent as? ViewGroup)?.let { parent -> + // attached in dialog + } + }, + modifier = Modifier.fillMaxSize(), + ) + } + } else { + SubcomposeAsyncImage( + model = + ImageRequest.Builder(context) + .data(media.url) + .crossfade(false) + .build(), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + ) + } + } + + AnimatedVisibility( + visible = !isPlaying && media.type.equals("video", ignoreCase = true), + enter = fadeIn(), + exit = fadeOut(), + modifier = + Modifier + .align(Alignment.BottomEnd) + .padding(24.dp), + ) { + Box( + modifier = + Modifier + .size(44.dp) + .clip(RoundedCornerShape(14.dp)) + .background(Color.Black.copy(alpha = 0.6f)), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(id = R.drawable.rounded_pause_24), + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(24.dp), + ) + } + } + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/core/sheets/FeatureHelpBottomSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/core/sheets/FeatureHelpBottomSheet.kt index 2570c6983..bd020d2b3 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/core/sheets/FeatureHelpBottomSheet.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/core/sheets/FeatureHelpBottomSheet.kt @@ -21,10 +21,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -37,12 +40,14 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.sameerasw.essentials.domain.model.Feature +import com.sameerasw.essentials.translation.TranslatableText import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer +import com.sameerasw.essentials.ui.core.media.FeatureHelpMediaViewer import com.sameerasw.essentials.utils.ColorUtil import com.sameerasw.essentials.utils.PermissionUIHelper import com.sameerasw.essentials.viewmodels.MainViewModel -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun FeatureHelpBottomSheet( onDismissRequest: () -> Unit, @@ -50,12 +55,16 @@ fun FeatureHelpBottomSheet( viewModel: MainViewModel = viewModel(), ) { val context = LocalContext.current + val scrollState = rememberScrollState() + EssentialsBottomSheet( onDismissRequest = onDismissRequest, ) { Column( modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState) .padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { @@ -87,13 +96,19 @@ fun FeatureHelpBottomSheet( } Column { - com.sameerasw.essentials.translation.TranslatableText( + TranslatableText( stringResId = feature.title, style = MaterialTheme.typography.titleMedium, ) } } + // Online Help Media + FeatureHelpMediaViewer( + feature = feature, + viewModel = viewModel, + ) + // Description Body RoundedCardContainer( containerColor = MaterialTheme.colorScheme.surfaceBright, @@ -101,7 +116,7 @@ fun FeatureHelpBottomSheet( Column(modifier = Modifier.padding(16.dp)) { val descRes = feature.aboutDescription ?: feature.description - com.sameerasw.essentials.translation.TranslatableText( + TranslatableText( stringResId = descRes, style = MaterialTheme.typography.bodyLarge, ) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/audio/SnoozeNotificationsSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/audio/SnoozeNotificationsSettingsUI.kt index e8bea4d4a..d42a43e65 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/audio/SnoozeNotificationsSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/audio/SnoozeNotificationsSettingsUI.kt @@ -19,6 +19,9 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.getValue import com.sameerasw.essentials.R import com.sameerasw.essentials.ui.core.cards.IconToggleItem import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer @@ -34,14 +37,29 @@ fun SnoozeNotificationsSettingsUI( ) { val context = LocalContext.current val view = LocalView.current + val isMasterEnabled by viewModel.isSnoozeHeadsUpEnabled Column( modifier = modifier .fillMaxWidth() .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { + RoundedCardContainer { + IconToggleItem( + iconRes = R.drawable.rounded_snooze_24, + title = stringResource(R.string.feat_snooze_notifications_title), + isChecked = isMasterEnabled, + onCheckedChange = { checked -> + HapticUtil.performVirtualKeyHaptic(view) + viewModel.setSnoozeHeadsUpEnabled(checked, context) + }, + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + RoundedCardContainer( modifier = Modifier, spacing = 2.dp, @@ -62,6 +80,7 @@ fun SnoozeNotificationsSettingsUI( HapticUtil.performVirtualKeyHaptic(view) viewModel.setSnoozeChannelBlocked(channel.id, checked, context) }, + enabled = isMasterEnabled, modifier = Modifier.highlight(highlightSetting == channel.id), ) } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/lighting/NotificationLightingSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/lighting/NotificationLightingSettingsUI.kt index 7df24e278..4a5b6efab 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/lighting/NotificationLightingSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/lighting/NotificationLightingSettingsUI.kt @@ -123,7 +123,22 @@ fun NotificationLightingSettingsUI( } } + val isNotificationLightingEnabled by viewModel.isNotificationLightingEnabled + Column(modifier = modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + RoundedCardContainer { + IconToggleItem( + iconRes = R.drawable.rounded_magnify_fullscreen_24, + title = stringResource(R.string.feat_notification_lighting_title), + isChecked = isNotificationLightingEnabled, + onCheckedChange = { checked -> + viewModel.setNotificationLightingEnabled(checked, context) + }, + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + RoundedCardContainer { IconToggleItem( iconRes = R.drawable.rounded_power_settings_new_24, @@ -132,6 +147,7 @@ fun NotificationLightingSettingsUI( onCheckedChange = { checked -> viewModel.setOnlyShowWhenScreenOff(checked, context) }, + enabled = isNotificationLightingEnabled, modifier = Modifier.highlight(highlightSetting == "only_screen_off"), ) IconToggleItem( diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/network/NetworksSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/network/NetworksSettingsUI.kt index ba1ce7b88..de4fd2c1c 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/network/NetworksSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/network/NetworksSettingsUI.kt @@ -275,7 +275,11 @@ fun NetworksSettingsUI( }, iconRes = R.drawable.rounded_cell_wifi_24, enabled = true, - modifier = Modifier.highlight(highlightSetting == "network_download_rate_limit_slider"), + modifier = + Modifier.highlight( + highlightSetting == "network_download_rate_limit_slider" || + highlightSetting == "Network download rate limit", + ), ) IconToggleItem( @@ -296,7 +300,11 @@ fun NetworksSettingsUI( } }, iconRes = R.drawable.rounded_mobile_24, - modifier = Modifier.highlight(highlightSetting == "mobile_data_always_on_toggle"), + modifier = + Modifier.highlight( + highlightSetting == "mobile_data_always_on_toggle" || + highlightSetting == "Mobile data always on", + ), ) IconToggleItem( @@ -319,7 +327,11 @@ fun NetworksSettingsUI( } }, iconRes = R.drawable.rounded_cast_24, - modifier = Modifier.highlight(highlightSetting == "wireless_display_certification_toggle"), + modifier = + Modifier.highlight( + highlightSetting == "wireless_display_certification_toggle" || + highlightSetting == "Wireless display certification", + ), ) IconToggleItem( @@ -334,7 +346,11 @@ fun NetworksSettingsUI( requestingPermissionFor = NetworkPermissionModule.SIM_NAMES } }, - modifier = Modifier.highlight(highlightSetting == "sim_names_item"), + modifier = + Modifier.highlight( + highlightSetting == "sim_names_item" || + highlightSetting == "SIM names", + ), ) } } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/permissions/PermissionsSearchResultCard.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/permissions/PermissionsSearchResultCard.kt new file mode 100644 index 000000000..67b03c3cc --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/permissions/PermissionsSearchResultCard.kt @@ -0,0 +1,201 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: UI Feature - Permissions + * File: PermissionsSearchResultCard.kt + * Description: Grouped permissions card for in-app search results with grant/granted actions and view all navigation. + */ + +package com.sameerasw.essentials.ui.features.permissions + +import android.app.Activity +import android.content.Context +import android.content.Intent +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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 +import com.sameerasw.essentials.R +import com.sameerasw.essentials.SettingsActivity +import com.sameerasw.essentials.ui.components.buttons.ListExpandToggleButton +import com.sameerasw.essentials.ui.core.cards.FeatureCard +import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer +import com.sameerasw.essentials.ui.core.sheets.PermissionItem +import com.sameerasw.essentials.utils.HapticUtil +import com.sameerasw.essentials.viewmodels.MainViewModel + +@Composable +fun PermissionsSearchResultCard( + permissions: List, + viewModel: MainViewModel, + modifier: Modifier = Modifier, +) { + if (permissions.isEmpty()) return + + val context = LocalContext.current + val view = LocalView.current + var isExpanded by remember { mutableStateOf(false) } + + val visiblePermissions = + if (!isExpanded && permissions.size > 5) { + permissions.take(5) + } else { + permissions + } + + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.settings_section_permissions), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + AssistChip( + onClick = { + HapticUtil.performUIHaptic(view) + val intent = + Intent(context, SettingsActivity::class.java).apply { + putExtra("expand_permissions", true) + } + context.startActivity(intent) + }, + label = { + Text( + text = stringResource(R.string.action_view_all), + style = MaterialTheme.typography.labelMedium, + ) + }, + trailingIcon = { + Icon( + painter = painterResource(R.drawable.rounded_chevron_right_24), + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }, + colors = + AssistChipDefaults.assistChipColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + labelColor = MaterialTheme.colorScheme.onSurface, + trailingIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + border = AssistChipDefaults.assistChipBorder(true), + ) + } + + RoundedCardContainer( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .animateContentSize(animationSpec = tween(durationMillis = 300)), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + for (permission in visiblePermissions) { + val resolvedTitle = + when (val t = permission.title) { + is Int -> stringResource(id = t) + is String -> t + else -> "" + } + + val isGranted = permission.isGranted + + FeatureCard( + title = resolvedTitle, + isEnabled = true, + onToggle = {}, + onClick = { + permission.action?.invoke() + }, + iconRes = permission.iconRes, + modifier = Modifier.padding(horizontal = 0.dp, vertical = 0.dp), + showToggle = false, + hasMoreSettings = false, + customTrailingContent = { + if (isGranted) { + OutlinedButton( + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + permission.action?.invoke() + }, + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + ) { + Text( + text = stringResource(R.string.perm_action_granted), + style = MaterialTheme.typography.labelMedium, + ) + } + } else { + Button( + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + permission.action?.invoke() + }, + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + ) { + Text( + text = stringResource(R.string.perm_action_grant), + style = MaterialTheme.typography.labelMedium, + ) + } + } + }, + ) + } + } + } + + if (permissions.size > 5) { + ListExpandToggleButton( + isExpanded = isExpanded, + onToggle = { isExpanded = !isExpanded }, + title = R.string.action_show_more, + description = R.string.action_show_less, + expandedText = stringResource(R.string.action_show_less), + collapsedText = stringResource(R.string.action_show_more), + modifier = + Modifier + .padding(horizontal = 0.dp) + .padding(top = 4.dp, bottom = 4.dp), + ) + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/power/CaffeinateSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/power/CaffeinateSettingsUI.kt index d5cda41f3..959753eb8 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/power/CaffeinateSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/power/CaffeinateSettingsUI.kt @@ -38,6 +38,9 @@ import com.sameerasw.essentials.ui.core.sheets.PermissionItem import com.sameerasw.essentials.ui.core.sheets.PermissionsBottomSheet import com.sameerasw.essentials.viewmodels.CaffeinateViewModel +import androidx.compose.ui.platform.LocalView +import com.sameerasw.essentials.utils.HapticUtil + @OptIn(ExperimentalLayoutApi::class) @Composable fun CaffeinateSettingsUI( @@ -46,6 +49,8 @@ fun CaffeinateSettingsUI( highlightSetting: String? = null, ) { val context = LocalContext.current + val view = LocalView.current + val isCaffeinateActive by viewModel.isActive var showPermissionSheet by remember { mutableStateOf(false) } @@ -104,6 +109,17 @@ fun CaffeinateSettingsUI( .padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { + RoundedCardContainer { + IconToggleItem( + iconRes = R.drawable.rounded_coffee_24, + title = stringResource(R.string.feat_caffeinate_title), + isChecked = isCaffeinateActive, + onCheckedChange = { _ -> + HapticUtil.performVirtualKeyHaptic(view) + viewModel.toggle(context) + }, + ) + } RoundedCardContainer { IconToggleItem( title = stringResource(R.string.caffeinate_battery_optimization_title), diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/system/CalendarSyncSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/system/CalendarSyncSettingsUI.kt index bd64e2b32..a616afce9 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/system/CalendarSyncSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/system/CalendarSyncSettingsUI.kt @@ -65,6 +65,20 @@ fun CalendarSyncSettingsUI( .padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { + RoundedCardContainer { + IconToggleItem( + iconRes = R.drawable.rounded_calendar_today_24, + title = stringResource(R.string.feat_calendar_sync_title), + isChecked = isEnabled, + onCheckedChange = { checked -> + HapticUtil.performVirtualKeyHaptic(view) + viewModel.setCalendarSyncEnabled(checked, context) + }, + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + Text( text = stringResource(R.string.calendar_sync_settings_title), style = MaterialTheme.typography.titleMedium, diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/system/StatusBarIconSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/system/StatusBarIconSettingsUI.kt index 8130821ee..7dcbd4808 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/system/StatusBarIconSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/system/StatusBarIconSettingsUI.kt @@ -152,6 +152,18 @@ fun StatusBarIconSettingsUI( .padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp), ) { + RoundedCardContainer { + IconToggleItem( + iconRes = R.drawable.rounded_signal_cellular_alt_24, + title = stringResource(R.string.feat_statusbar_icons_title), + isChecked = isMasterEnabled, + onCheckedChange = { checked -> + HapticUtil.performVirtualKeyHaptic(view) + mainViewModel.setStatusBarIconControlEnabled(checked, context) + }, + ) + } + if (isMasterEnabled) { // Iterate through categories categories.forEach { categoryRes -> diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/tiles/QSTilesSearchResultCard.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/tiles/QSTilesSearchResultCard.kt new file mode 100644 index 000000000..4f8450ec3 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/tiles/QSTilesSearchResultCard.kt @@ -0,0 +1,380 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: UI Feature - Tiles + * File: QSTilesSearchResultCard.kt + * Description: Grouped Quick Settings tiles card for in-app search results with smooth 2x2 grid expansion. + */ + +package com.sameerasw.essentials.ui.features.tiles + +import android.app.Activity +import android.app.StatusBarManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.graphics.drawable.Icon +import android.os.Build +import android.widget.Toast +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +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 +import com.sameerasw.essentials.FeatureSettingsActivity +import com.sameerasw.essentials.R +import com.sameerasw.essentials.domain.model.Feature +import com.sameerasw.essentials.domain.registry.QSTileInfo +import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer +import com.sameerasw.essentials.ui.core.sheets.FeatureHelpBottomSheet +import com.sameerasw.essentials.ui.core.sheets.PermissionsBottomSheet +import com.sameerasw.essentials.ui.features.system.QSTileCard +import com.sameerasw.essentials.utils.HapticUtil +import com.sameerasw.essentials.utils.PermissionUIHelper +import com.sameerasw.essentials.viewmodels.MainViewModel + +private sealed interface GridItem { + data class Tile(val info: QSTileInfo) : GridItem + data class ToggleButton(val isExpanded: Boolean) : GridItem +} + +@Composable +fun QSTilesSearchResultCard( + tiles: List, + viewModel: MainViewModel, + modifier: Modifier = Modifier, +) { + if (tiles.isEmpty()) return + + val context = LocalContext.current + val view = LocalView.current + var isExpanded by remember { mutableStateOf(false) } + + var showPermissionSheet by remember { mutableStateOf(false) } + var selectedTileForPermissions by remember { mutableStateOf(null) } + + var showHelpSheet by remember { mutableStateOf(false) } + var selectedHelpTile by remember { mutableStateOf(null) } + + if (showPermissionSheet && selectedTileForPermissions != null) { + val permissionItems = + PermissionUIHelper.getPermissionItems( + selectedTileForPermissions!!.permissionKeys, + context, + viewModel, + context as? Activity ?: (view.context as? Activity), + ) + + if (permissionItems.isNotEmpty()) { + PermissionsBottomSheet( + onDismissRequest = { + showPermissionSheet = false + selectedTileForPermissions = null + }, + featureTitle = selectedTileForPermissions!!.titleRes, + permissions = permissionItems, + ) + } + } + + if (showHelpSheet && selectedHelpTile != null) { + val tileId = stringResource(selectedHelpTile!!.titleRes) + val tempFeature = + object : Feature( + id = tileId, + title = selectedHelpTile!!.titleRes, + iconRes = selectedHelpTile!!.iconRes, + category = R.string.cat_system, + description = 0, + aboutDescription = selectedHelpTile!!.aboutDescription, + permissionKeys = selectedHelpTile!!.permissionKeys, + showToggle = false, + ) { + override fun isEnabled(viewModel: MainViewModel) = true + + override fun onToggle( + viewModel: MainViewModel, + context: Context, + enabled: Boolean, + ) {} + } + + FeatureHelpBottomSheet( + onDismissRequest = { + showHelpSheet = false + selectedHelpTile = null + }, + feature = tempFeature, + ) + } + + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.feat_qs_tiles_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + AssistChip( + onClick = { + HapticUtil.performUIHaptic(view) + val intent = + Intent(context, FeatureSettingsActivity::class.java).apply { + putExtra("feature", "Quick settings tiles") + } + context.startActivity(intent) + }, + label = { + Text( + text = stringResource(R.string.action_view_all), + style = MaterialTheme.typography.labelMedium, + ) + }, + trailingIcon = { + Icon( + painter = painterResource(R.drawable.rounded_chevron_right_24), + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }, + colors = + AssistChipDefaults.assistChipColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + labelColor = MaterialTheme.colorScheme.onSurface, + trailingIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + border = AssistChipDefaults.assistChipBorder(true), + ) + } + + RoundedCardContainer( + modifier = Modifier.fillMaxWidth(), + containerColor = MaterialTheme.colorScheme.surfaceBright, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp) + .animateContentSize(animationSpec = tween(durationMillis = 300)), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + + val showExpandButton = tiles.size > 3 + + val visibleItems: List = + if (!showExpandButton) { + tiles.map { GridItem.Tile(it) } + } else if (!isExpanded) { + tiles.take(3).map { GridItem.Tile(it) } + GridItem.ToggleButton(isExpanded = false) + } else { + tiles.map { GridItem.Tile(it) } + GridItem.ToggleButton(isExpanded = true) + } + + visibleItems.chunked(2).forEach { rowItems -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + rowItems.forEach { item -> + when (item) { + is GridItem.Tile -> { + val tile = item.info + val allPermissionsGranted = + tile.permissionKeys.all { key -> + PermissionUIHelper + .getPermissionItem( + key, + context, + viewModel, + )?.isGranted == true + } + + val addedTiles by viewModel.addedQSTiles + val componentName = ComponentName(context, tile.serviceClass) + val isAdded = + addedTiles.any { + it.contains(componentName.flattenToString()) || + it.contains(componentName.flattenToShortString()) || + it.contains(tile.serviceClass.name) + } + + val pinnedQsTiles by viewModel.pinnedQsTileKeys + val isPinned = pinnedQsTiles.contains(tile.serviceClass.name) + + QSTileCard( + tile = tile, + modifier = Modifier.weight(1f), + isMissingPermissions = !allPermissionsGranted, + isAdded = isAdded, + isPinned = isPinned, + onClick = { + if (!allPermissionsGranted) { + selectedTileForPermissions = tile + showPermissionSheet = true + } else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val statusBarManager = + context.getSystemService(StatusBarManager::class.java) + val targetComponent = + ComponentName(context, tile.serviceClass) + + statusBarManager.requestAddTileService( + targetComponent, + context.getString(tile.titleRes), + Icon.createWithResource(context, tile.iconRes), + context.mainExecutor, + ) { result -> + if (result == StatusBarManager.TILE_ADD_REQUEST_RESULT_TILE_ALREADY_ADDED) { + context.getSharedPreferences("essentials_prefs", Context.MODE_PRIVATE) + .edit() + .putBoolean("${tile.serviceClass.name}_is_added", true) + .apply() + viewModel.addedQSTiles.value = viewModel.addedQSTiles.value + tile.serviceClass.name + Toast.makeText( + context, + context.getString(R.string.qs_tile_already_added), + Toast.LENGTH_SHORT, + ).show() + } else if (result == StatusBarManager.TILE_ADD_REQUEST_RESULT_TILE_ADDED) { + context.getSharedPreferences("essentials_prefs", Context.MODE_PRIVATE) + .edit() + .putBoolean("${tile.serviceClass.name}_is_added", true) + .apply() + viewModel.addedQSTiles.value = viewModel.addedQSTiles.value + tile.serviceClass.name + } + } + } else { + Toast.makeText( + context, + context.getString(R.string.qs_tile_requires_android_13), + Toast.LENGTH_SHORT, + ).show() + } + } + }, + onPinToggle = { + viewModel.togglePinQsTile(tile.serviceClass.name) + }, + onHelpClick = + if (tile.aboutDescription != null) { + { + selectedHelpTile = tile + showHelpSheet = true + } + } else { + null + }, + ) + } + is GridItem.ToggleButton -> { + Box( + modifier = + Modifier + .weight(1f) + .clip(RoundedCornerShape(24.dp)) + .border( + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + shape = RoundedCornerShape(24.dp), + ) + .clickable { + HapticUtil.performVirtualKeyHaptic(view) + isExpanded = !isExpanded + } + .padding(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + painter = + painterResource( + if (item.isExpanded) { + R.drawable.rounded_keyboard_arrow_up_24 + } else { + R.drawable.rounded_keyboard_arrow_down_24 + }, + ), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(8.dp), + ) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = + stringResource( + if (item.isExpanded) { + R.string.action_show_less + } else { + R.string.action_show_more + }, + ), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + ) + Text( + text = stringResource(R.string.feat_qs_tiles_title), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } + } + } + } + } + + if (rowItems.size < 2) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } + } +} +} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/tiles/QuickSettingsTilesSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/tiles/QuickSettingsTilesSettingsUI.kt index 5dc736c93..632682374 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/tiles/QuickSettingsTilesSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/tiles/QuickSettingsTilesSettingsUI.kt @@ -87,6 +87,10 @@ import com.sameerasw.essentials.services.tiles.TapToWakeTileService import com.sameerasw.essentials.services.tiles.UiBlurTileService import com.sameerasw.essentials.services.tiles.UrlShortenerTileService import com.sameerasw.essentials.services.tiles.UsbDebuggingTileService +import com.sameerasw.essentials.domain.registry.QSTileInfo +import com.sameerasw.essentials.domain.registry.QSTileRegistry +import com.sameerasw.essentials.ui.core.cards.IconToggleItem +import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer import com.sameerasw.essentials.ui.core.sheets.PermissionsBottomSheet import com.sameerasw.essentials.ui.modifiers.highlight import com.sameerasw.essentials.ui.modifiers.scrollMotionBlur @@ -96,16 +100,6 @@ import com.sameerasw.essentials.utils.PermissionUtils import com.sameerasw.essentials.utils.ShellUtils import com.sameerasw.essentials.viewmodels.MainViewModel -data class QSTileInfo( - val titleRes: Int, - val iconRes: Int, - val serviceClass: Class<*>, - val permissionKeys: List = emptyList(), - val aboutDescription: Int? = null, - val categoryRes: Int, - val isSupported: (Context) -> Boolean = { true }, -) - @Composable fun QuickSettingsTilesSettingsUI( modifier: Modifier = Modifier, @@ -129,292 +123,9 @@ fun QuickSettingsTilesSettingsUI( } val isUseUsageStats by viewModel.isUseUsageAccess - val allTiles = - listOf( - QSTileInfo( - R.string.tile_ui_blur, - R.drawable.rounded_blur_on_24, - UiBlurTileService::class.java, - listOf("WRITE_SECURE_SETTINGS"), - R.string.about_desc_ui_blur, - R.string.cat_visuals, - ), - QSTileInfo( - R.string.tile_bubbles, - R.drawable.rounded_bubble_24, - BubblesTileService::class.java, - listOf("WRITE_SECURE_SETTINGS"), - R.string.about_desc_bubbles, - R.string.cat_utils, - ), - QSTileInfo( - R.string.tile_sensitive_content, - R.drawable.rounded_notifications_off_24, - PrivateNotificationsTileService::class.java, - listOf("WRITE_SECURE_SETTINGS"), - R.string.about_desc_sensitive_content, - R.string.cat_privacy, - ), - QSTileInfo( - R.string.tile_tap_to_wake, - R.drawable.rounded_touch_app_24, - TapToWakeTileService::class.java, - listOf("WRITE_SECURE_SETTINGS"), - R.string.about_desc_tap_to_wake, - R.string.cat_visuals, - ), - QSTileInfo( - R.string.tile_aod, - R.drawable.rounded_mobile_text_2_24, - AlwaysOnDisplayTileService::class.java, - if (ShellUtils.isRootEnabled(context)) { - listOf("ROOT") - } else if (PermissionUtils.canWriteSecureSettings(context)) { - listOf("WRITE_SECURE_SETTINGS") - } else { - listOf("SHIZUKU") - }, - R.string.about_desc_aod, - R.string.cat_visuals, - ), - QSTileInfo( - R.string.tile_caffeinate, - R.drawable.rounded_coffee_24, - CaffeinateTileService::class.java, - listOf("POST_NOTIFICATIONS"), - R.string.about_desc_caffeinate, - R.string.cat_utils, - ), - QSTileInfo( - R.string.tile_sound_mode, - R.drawable.rounded_volume_up_24, - SoundModeTileService::class.java, - listOf("NOTIFICATION_POLICY"), - R.string.about_desc_sound_mode_tile, - R.string.cat_utils, - ), - QSTileInfo( - R.string.tile_notification_lighting, - R.drawable.rounded_blur_linear_24, - NotificationLightingTileService::class.java, - listOf("DRAW_OVERLAYS", "ACCESSIBILITY", "NOTIFICATION_LISTENER"), - R.string.about_desc_notification_lighting, - R.string.cat_utils, - ), - QSTileInfo( - R.string.tile_dynamic_night_light, - R.drawable.rounded_nightlight_24, - DynamicNightLightTileService::class.java, - listOf("ACCESSIBILITY", "WRITE_SECURE_SETTINGS"), - R.string.about_desc_dynamic_night_light, - R.string.cat_visuals, - ), - QSTileInfo( - R.string.tile_locked_security, - R.drawable.rounded_security_24, - ScreenLockedSecurityTileService::class.java, - if (ShellUtils.isRootEnabled(context)) listOf("ROOT") else listOf("SHIZUKU"), - R.string.about_desc_screen_locked_security, - R.string.cat_privacy, - ), - QSTileInfo( - R.string.tile_app_lock, - R.drawable.rounded_shield_lock_24, - AppLockTileService::class.java, - if (isUseUsageStats) listOf("USAGE_STATS") else listOf("ACCESSIBILITY"), - R.string.about_desc_app_lock, - R.string.cat_privacy, - ), - QSTileInfo( - R.string.tile_mono_audio, - R.drawable.rounded_headphones_24, - MonoAudioTileService::class.java, - if (ShellUtils.isRootEnabled(context)) { - listOf("ROOT") - } else if (PermissionUtils.canWriteSecureSettings(context)) { - listOf("WRITE_SECURE_SETTINGS") - } else { - listOf("SHIZUKU") - }, - R.string.about_desc_mono_audio, - R.string.cat_accessibility, - ), - QSTileInfo( - R.string.tile_flashlight, - R.drawable.rounded_flashlight_on_24, - FlashlightTileService::class.java, - emptyList(), - R.string.about_desc_flashlight_tile, - R.string.cat_utils, - ), - QSTileInfo( - R.string.tile_app_freezing, - R.drawable.rounded_app_badging_24, - AppFreezingTileService::class.java, - if (ShellUtils.isRootEnabled(context)) { - listOf( - "ROOT", - "USAGE_STATS", - "NOTIFICATION_LISTENER", - ) - } else { - listOf("SHIZUKU", "USAGE_STATS", "NOTIFICATION_LISTENER") - }, - R.string.about_desc_freeze, - R.string.cat_utils, - ), - QSTileInfo( - R.string.tile_flashlight_pulse, - R.drawable.outline_backlight_high_24, - FlashlightPulseTileService::class.java, - listOf("NOTIFICATION_LISTENER"), - R.string.about_desc_flashlight_pulse, - R.string.cat_utils, - ), - QSTileInfo( - R.string.tile_stay_awake, - R.drawable.rounded_av_timer_24, - StayAwakeTileService::class.java, - listOf("WRITE_SECURE_SETTINGS"), - R.string.about_desc_stay_awake, - R.string.cat_visuals, - ), - QSTileInfo( - R.string.nfc_tile_label, - R.drawable.rounded_nfc_24, - NfcTileService::class.java, - if (ShellUtils.isRootEnabled(context)) { - listOf("ROOT") - } else if (PermissionUtils.canWriteSecureSettings(context)) { - listOf("WRITE_SECURE_SETTINGS") - } else { - listOf("SHIZUKU") - }, - R.string.about_desc_nfc, - R.string.cat_connectivity, - ), - QSTileInfo( - R.string.tile_adaptive_brightness, - R.drawable.rounded_brightness_auto_24, - AdaptiveBrightnessTileService::class.java, - listOf("WRITE_SETTINGS"), - R.string.about_desc_adaptive_brightness, - R.string.cat_visuals, - ), - QSTileInfo( - R.string.tile_scale_animations, - R.drawable.rounded_front_hand_24, - ScaleAnimationsTileService::class.java, - listOf("WRITE_SECURE_SETTINGS"), - R.string.about_desc_scale_animations_tile, - R.string.cat_visuals, - ), - QSTileInfo( - R.string.tile_restart_systemui, - R.drawable.reopen_window_24px, - RestartSystemUiTileService::class.java, - if (ShellUtils.isRootEnabled(context)) listOf("ROOT") else listOf("SHIZUKU"), - R.string.about_desc_restart_systemui_tile, - R.string.cat_utils, - ), - QSTileInfo( - R.string.tile_refresh_rate, - R.drawable.rounded_shutter_speed_24, - RefreshRateTileService::class.java, - listOf("SHIZUKU"), - R.string.about_desc_refresh_rate_tile, - R.string.cat_visuals, - isSupported = { _ -> DeviceUtils.isGoogleDevice() }, - ), - QSTileInfo( - R.string.feat_maps_power_saving_title, - R.drawable.rounded_navigation_24, - MapsPowerSavingTileService::class.java, - if (ShellUtils.isRootEnabled(context)) { - listOf( - "ROOT", - "NOTIFICATION_LISTENER", - ) - } else { - listOf("SHIZUKU", "NOTIFICATION_LISTENER") - }, - R.string.about_desc_maps_power_saving, - R.string.cat_utils, - ), - QSTileInfo( - R.string.tile_private_dns, - R.drawable.rounded_dns_24, - PrivateDnsTileService::class.java, - listOf("WRITE_SECURE_SETTINGS"), - R.string.about_desc_private_dns, - R.string.cat_connectivity, - ), - QSTileInfo( - R.string.tile_usb_debugging, - R.drawable.rounded_adb_24, - UsbDebuggingTileService::class.java, - listOf("WRITE_SECURE_SETTINGS"), - R.string.about_desc_usb_debugging, - R.string.cat_utils, - ), - QSTileInfo( - R.string.tile_color_picker, - R.drawable.rounded_colorize_24, - com.sameerasw.essentials.services.tiles.ColorPickerTileService::class.java, - emptyList(), - R.string.about_desc_color_picker, - R.string.cat_visuals, - ), - QSTileInfo( - R.string.tile_developer_options, - R.drawable.rounded_mobile_code_24, - DeveloperOptionsTileService::class.java, - listOf("WRITE_SECURE_SETTINGS"), - R.string.about_desc_developer_options, - R.string.cat_utils, - ), - QSTileInfo( - R.string.tile_charge_optimization, - R.drawable.rounded_battery_android_frame_shield_24, - ChargeQuickTileService::class.java, - if (ShellUtils.isRootEnabled(context)) listOf("ROOT") else listOf("SHIZUKU"), - R.string.about_desc_charge_optimization, - R.string.cat_utils, - isSupported = { _ -> DeviceUtils.isGoogleDevice() }, - ), - QSTileInfo( - R.string.tile_lock, - R.drawable.rounded_lock_24, - LockdownTileService::class.java, - listOf("ACCESSIBILITY", "DEVICE_ADMIN"), - R.string.tile_lockdown_mode_about_desc, - R.string.cat_privacy, - ), - QSTileInfo( - R.string.feat_smart_pixels_title, - R.drawable.rounded_grain_24, - SmartPixelsTileService::class.java, - listOf("ACCESSIBILITY"), - R.string.about_desc_smart_pixels, - R.string.cat_visuals, - ), - QSTileInfo( - R.string.feat_essentials_on_display_title, - R.drawable.rounded_live_tv_24, - EssentialsOnDisplayTileService::class.java, - listOf("ACCESSIBILITY", "NOTIFICATION_LISTENER"), - R.string.feat_essentials_on_display_desc, - R.string.cat_visuals, - ), - QSTileInfo( - R.string.tile_url_shortener, - R.drawable.rounded_link_24, - UrlShortenerTileService::class.java, - emptyList(), - R.string.tile_url_shortener_subtitle, - R.string.cat_utils, - ), - ) + val allTiles = remember(isUseUsageStats) { + QSTileRegistry.getAllTiles(context, isUseUsageStats) + } val tiles = allTiles.filter { tile -> tile.isSupported(context) || includeUnsupportedFeatures } @@ -503,6 +214,27 @@ fun QuickSettingsTilesSettingsUI( Spacer(modifier = Modifier.height(contentPadding.calculateTopPadding())) Spacer(modifier = Modifier.height(16.dp)) + val isSecureSensitiveTilesEnabled by viewModel.isSecureSensitiveTilesEnabled + RoundedCardContainer( + modifier = + Modifier + .fillMaxWidth() + .highlight( + highlightSetting.equals( + resources.getString(R.string.qs_secure_sensitive_tiles_title), + ignoreCase = true, + ), + ), + ) { + IconToggleItem( + title = stringResource(R.string.qs_secure_sensitive_tiles_title), + description = stringResource(R.string.qs_secure_sensitive_tiles_desc), + iconRes = R.drawable.rounded_shield_lock_24, + isChecked = isSecureSensitiveTilesEnabled, + onCheckedChange = { viewModel.setSecureSensitiveTilesEnabled(it) }, + ) + } + categorizedTiles.forEachIndexed { index, (categoryRes, tilesInSection) -> if (index > 0) { Spacer(modifier = Modifier.height(8.dp)) diff --git a/app/src/main/java/com/sameerasw/essentials/utils/ui/PermissionUIHelper.kt b/app/src/main/java/com/sameerasw/essentials/utils/ui/PermissionUIHelper.kt index fb393839a..7702116de 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/ui/PermissionUIHelper.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/ui/PermissionUIHelper.kt @@ -19,24 +19,25 @@ import android.os.Build import android.provider.Settings import androidx.core.app.ActivityCompat import com.sameerasw.essentials.R +import com.sameerasw.essentials.domain.model.AppPermission import com.sameerasw.essentials.domain.registry.PermissionRegistry import com.sameerasw.essentials.ui.core.sheets.PermissionItem import com.sameerasw.essentials.viewmodels.MainViewModel object PermissionUIHelper { fun getPermissionItem( - key: String, + permission: AppPermission, context: Context, viewModel: MainViewModel, activity: Activity? = null, ): PermissionItem? = - when (key) { - "ACCESSIBILITY" -> + when (permission) { + AppPermission.ACCESSIBILITY -> PermissionItem( - iconRes = R.drawable.rounded_settings_accessibility_24, - title = R.string.perm_accessibility_title, + iconRes = permission.iconRes, + title = permission.titleRes, description = R.string.perm_accessibility_desc_common, - dependentFeatures = PermissionRegistry.getFeatures("ACCESSIBILITY"), + dependentFeatures = PermissionRegistry.getFeatures(permission), actionLabel = if (viewModel.isAccessibilityEnabled.value) R.string.label_enabled else R.string.perm_action_enable, action = { val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) @@ -46,12 +47,12 @@ object PermissionUIHelper { isGranted = viewModel.isAccessibilityEnabled.value, ) - "WRITE_SECURE_SETTINGS" -> + AppPermission.WRITE_SECURE_SETTINGS -> PermissionItem( - iconRes = R.drawable.rounded_security_24, - title = R.string.perm_write_secure_title, + iconRes = permission.iconRes, + title = permission.titleRes, description = R.string.perm_write_secure_desc_common, - dependentFeatures = PermissionRegistry.getFeatures("WRITE_SECURE_SETTINGS"), + dependentFeatures = PermissionRegistry.getFeatures(permission), actionLabel = if (viewModel.isWriteSecureSettingsEnabled.value) R.string.perm_action_granted else R.string.perm_action_copy_adb, action = { val adbCommand = @@ -87,64 +88,64 @@ object PermissionUIHelper { instructions = R.string.perm_write_secure_instructions, ) - "NOTIFICATION_LISTENER" -> + AppPermission.NOTIFICATION_LISTENER -> PermissionItem( - iconRes = R.drawable.rounded_notifications_unread_24, - title = R.string.perm_notif_listener_title, + iconRes = permission.iconRes, + title = permission.titleRes, description = if (PermissionRegistry - .getFeatures("NOTIFICATION_LISTENER") + .getFeatures(permission) .contains(R.string.feat_freeze_title) ) { R.string.perm_notif_listener_desc_freeze } else { R.string.perm_notif_listener_desc_lighting }, - dependentFeatures = PermissionRegistry.getFeatures("NOTIFICATION_LISTENER"), + dependentFeatures = PermissionRegistry.getFeatures(permission), actionLabel = if (viewModel.isNotificationListenerEnabled.value) R.string.perm_action_granted else R.string.perm_action_grant, action = { viewModel.requestNotificationListenerPermission(context) }, isGranted = viewModel.isNotificationListenerEnabled.value, ) - "DRAW_OVERLAYS" -> + AppPermission.DRAW_OVERLAYS -> PermissionItem( - iconRes = R.drawable.rounded_magnify_fullscreen_24, - title = R.string.perm_overlay_title, + iconRes = permission.iconRes, + title = permission.titleRes, description = R.string.perm_overlay_desc, - dependentFeatures = PermissionRegistry.getFeatures("DRAW_OVERLAYS"), + dependentFeatures = PermissionRegistry.getFeatures(permission), actionLabel = if (viewModel.isOverlayPermissionGranted.value) R.string.perm_action_granted else R.string.perm_action_grant, action = { PermissionUtils.openOverlaySettings(context) }, isGranted = viewModel.isOverlayPermissionGranted.value, ) - "WRITE_SETTINGS" -> + AppPermission.WRITE_SETTINGS -> PermissionItem( - iconRes = R.drawable.rounded_security_24, - title = R.string.perm_write_settings_title, + iconRes = permission.iconRes, + title = permission.titleRes, description = R.string.perm_write_settings_desc, - dependentFeatures = PermissionRegistry.getFeatures("WRITE_SETTINGS"), + dependentFeatures = PermissionRegistry.getFeatures(permission), actionLabel = if (viewModel.isWriteSettingsEnabled.value) R.string.perm_action_granted else R.string.perm_action_grant, action = { PermissionUtils.openWriteSettings(context) }, isGranted = viewModel.isWriteSettingsEnabled.value, ) - "NOTIFICATION_POLICY" -> + AppPermission.NOTIFICATION_POLICY -> PermissionItem( - iconRes = R.drawable.rounded_volume_up_24, - title = R.string.perm_notif_policy_title, + iconRes = permission.iconRes, + title = permission.titleRes, description = R.string.perm_notif_policy_desc, - dependentFeatures = PermissionRegistry.getFeatures("NOTIFICATION_POLICY"), + dependentFeatures = PermissionRegistry.getFeatures(permission), actionLabel = if (viewModel.isNotificationPolicyAccessGranted.value) R.string.perm_action_granted else R.string.perm_action_grant, action = { PermissionUtils.openNotificationPolicySettings(context) }, isGranted = viewModel.isNotificationPolicyAccessGranted.value, ) - "POST_NOTIFICATIONS" -> + AppPermission.POST_NOTIFICATIONS -> PermissionItem( - iconRes = R.drawable.rounded_notifications_unread_24, - title = R.string.permission_post_notifications_title, + iconRes = permission.iconRes, + title = permission.titleRes, description = R.string.permission_post_notifications_desc, - dependentFeatures = PermissionRegistry.getFeatures("POST_NOTIFICATIONS"), + dependentFeatures = PermissionRegistry.getFeatures(permission), actionLabel = if (viewModel.isPostNotificationsEnabled.value) R.string.perm_action_granted else R.string.perm_action_grant, action = { if (activity != null) { @@ -165,12 +166,12 @@ object PermissionUIHelper { isGranted = viewModel.isPostNotificationsEnabled.value, ) - "READ_PHONE_STATE" -> + AppPermission.READ_PHONE_STATE -> PermissionItem( - iconRes = R.drawable.rounded_mobile_vibrate_24, - title = R.string.permission_read_phone_state_title, + iconRes = permission.iconRes, + title = permission.titleRes, description = R.string.permission_read_phone_state_desc_call_vibrations, - dependentFeatures = PermissionRegistry.getFeatures("READ_PHONE_STATE"), + dependentFeatures = PermissionRegistry.getFeatures(permission), actionLabel = if (viewModel.isReadPhoneStateEnabled.value) R.string.perm_action_granted else R.string.perm_action_grant, action = { if (activity != null) { @@ -184,12 +185,12 @@ object PermissionUIHelper { isGranted = viewModel.isReadPhoneStateEnabled.value, ) - "LOCATION" -> + AppPermission.LOCATION -> PermissionItem( - iconRes = R.drawable.rounded_navigation_24, - title = R.string.perm_location_title, + iconRes = permission.iconRes, + title = permission.titleRes, description = R.string.perm_location_desc, - dependentFeatures = PermissionRegistry.getFeatures("LOCATION"), + dependentFeatures = PermissionRegistry.getFeatures(permission), actionLabel = if (viewModel.isLocationPermissionGranted.value) R.string.perm_action_granted else R.string.perm_action_grant, action = { (activity as? androidx.activity.ComponentActivity)?.let { @@ -201,12 +202,12 @@ object PermissionUIHelper { isGranted = viewModel.isLocationPermissionGranted.value, ) - "BACKGROUND_LOCATION" -> + AppPermission.BACKGROUND_LOCATION -> PermissionItem( - iconRes = R.drawable.rounded_navigation_24, - title = R.string.perm_bg_location_title, + iconRes = permission.iconRes, + title = permission.titleRes, description = R.string.perm_bg_location_desc, - dependentFeatures = PermissionRegistry.getFeatures("BACKGROUND_LOCATION"), + dependentFeatures = PermissionRegistry.getFeatures(permission), actionLabel = if (viewModel.isBackgroundLocationPermissionGranted.value) R.string.perm_action_granted else R.string.perm_action_grant, action = { (activity as? androidx.activity.ComponentActivity)?.let { @@ -218,34 +219,34 @@ object PermissionUIHelper { isGranted = viewModel.isBackgroundLocationPermissionGranted.value, ) - "DEVICE_ADMIN" -> + AppPermission.DEVICE_ADMIN -> PermissionItem( - iconRes = R.drawable.rounded_security_24, - title = R.string.perm_device_admin_title, + iconRes = permission.iconRes, + title = permission.titleRes, description = R.string.perm_device_admin_desc, - dependentFeatures = PermissionRegistry.getFeatures("DEVICE_ADMIN"), + dependentFeatures = PermissionRegistry.getFeatures(permission), actionLabel = if (viewModel.isDeviceAdminEnabled.value) R.string.label_enabled else R.string.action_enable_in_settings, action = { viewModel.requestDeviceAdmin(context) }, isGranted = viewModel.isDeviceAdminEnabled.value, ) - "ROOT" -> + AppPermission.ROOT -> PermissionItem( - iconRes = R.drawable.rounded_numbers_24, - title = R.string.perm_root_title, + iconRes = permission.iconRes, + title = permission.titleRes, description = R.string.perm_root_desc, - dependentFeatures = PermissionRegistry.getFeatures("ROOT"), + dependentFeatures = PermissionRegistry.getFeatures(permission), actionLabel = if (viewModel.isRootPermissionGranted.value) R.string.perm_action_granted else R.string.perm_action_grant, action = { viewModel.check(context) }, isGranted = viewModel.isRootPermissionGranted.value, ) - "SHIZUKU" -> + AppPermission.SHIZUKU -> PermissionItem( - iconRes = if (viewModel.isRootEnabled.value) R.drawable.rounded_numbers_24 else R.drawable.rounded_mode_cool_24, - title = if (viewModel.isRootEnabled.value) R.string.perm_root_title else R.string.perm_shizuku_title, + iconRes = if (viewModel.isRootEnabled.value) R.drawable.rounded_numbers_24 else permission.iconRes, + title = if (viewModel.isRootEnabled.value) R.string.perm_root_title else permission.titleRes, description = if (viewModel.isRootEnabled.value) R.string.perm_root_desc else R.string.perm_shizuku_desc, - dependentFeatures = PermissionRegistry.getFeatures("SHIZUKU"), + dependentFeatures = PermissionRegistry.getFeatures(permission), actionLabel = if (viewModel.isRootEnabled.value) { if (viewModel.isRootPermissionGranted.value) R.string.perm_action_granted else R.string.perm_action_grant @@ -262,12 +263,12 @@ object PermissionUIHelper { isGranted = if (viewModel.isRootEnabled.value) viewModel.isRootPermissionGranted.value else viewModel.isShizukuPermissionGranted.value, ) - "READ_CALENDAR" -> + AppPermission.READ_CALENDAR -> PermissionItem( - iconRes = R.drawable.rounded_sync_24, - title = R.string.feat_calendar_sync_title, + iconRes = permission.iconRes, + title = permission.titleRes, description = R.string.feat_calendar_sync_desc, - dependentFeatures = PermissionRegistry.getFeatures("READ_CALENDAR"), + dependentFeatures = PermissionRegistry.getFeatures(permission), actionLabel = if (viewModel.isCalendarPermissionGranted.value) R.string.perm_action_granted else R.string.perm_action_grant, action = { if (activity != null) { @@ -284,28 +285,28 @@ object PermissionUIHelper { isGranted = viewModel.isCalendarPermissionGranted.value, ) - "USAGE_STATS" -> + AppPermission.USAGE_STATS -> PermissionItem( - iconRes = R.drawable.rounded_data_usage_24, - title = R.string.perm_usage_stats_title, + iconRes = permission.iconRes, + title = permission.titleRes, description = if (viewModel.isUseUsageAccess.value) { R.string.perm_usage_stats_desc_app_lock } else { R.string.perm_usage_stats_desc }, - dependentFeatures = PermissionRegistry.getFeatures("USAGE_STATS"), + dependentFeatures = PermissionRegistry.getFeatures(permission), actionLabel = if (viewModel.isUsageStatsPermissionGranted.value) R.string.perm_action_granted else R.string.perm_action_grant, action = { PermissionUtils.openUsageStatsSettings(context) }, isGranted = viewModel.isUsageStatsPermissionGranted.value, ) - "DEFAULT_BROWSER" -> + AppPermission.DEFAULT_BROWSER -> PermissionItem( - iconRes = R.drawable.rounded_open_in_browser_24, - title = R.string.perm_default_browser_title, + iconRes = permission.iconRes, + title = permission.titleRes, description = R.string.perm_default_browser_desc, - dependentFeatures = PermissionRegistry.getFeatures("DEFAULT_BROWSER"), + dependentFeatures = PermissionRegistry.getFeatures(permission), actionLabel = if (viewModel.isDefaultBrowserSet.value) R.string.perm_action_granted else R.string.perm_action_grant, action = { val intent = Intent(Settings.ACTION_MANAGE_DEFAULT_APPS_SETTINGS) @@ -321,12 +322,12 @@ object PermissionUIHelper { isGranted = viewModel.isDefaultBrowserSet.value, ) - "BLUETOOTH_CONNECT", "BLUETOOTH_SCAN" -> + AppPermission.BLUETOOTH -> PermissionItem( - iconRes = R.drawable.rounded_bluetooth_24, - title = R.string.perm_nearby_devices_title, + iconRes = permission.iconRes, + title = permission.titleRes, description = R.string.perm_nearby_devices_desc, - dependentFeatures = PermissionRegistry.getFeatures(key), + dependentFeatures = PermissionRegistry.getFeatures(permission), actionLabel = if (viewModel.isBluetoothPermissionGranted.value) R.string.perm_action_granted else R.string.perm_action_grant, action = { if (activity != null) { @@ -348,7 +349,7 @@ object PermissionUIHelper { isGranted = viewModel.isBluetoothPermissionGranted.value, ) - "REQUEST_INSTALL_PACKAGES" -> { + AppPermission.REQUEST_INSTALL_PACKAGES -> { val isGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { context.packageManager.canRequestPackageInstalls() @@ -356,10 +357,10 @@ object PermissionUIHelper { true } PermissionItem( - iconRes = R.drawable.rounded_mobile_arrow_down_24, - title = R.string.perm_install_packages_title, + iconRes = permission.iconRes, + title = permission.titleRes, description = R.string.perm_install_packages_desc, - dependentFeatures = PermissionRegistry.getFeatures("REQUEST_INSTALL_PACKAGES"), + dependentFeatures = PermissionRegistry.getFeatures(permission), actionLabel = if (isGranted) R.string.perm_action_granted else R.string.perm_action_grant, action = { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -377,7 +378,7 @@ object PermissionUIHelper { ) } - "READ_CONTACTS" -> { + AppPermission.READ_CONTACTS -> { val isGranted = androidx.core.content.ContextCompat.checkSelfPermission( context, @@ -397,8 +398,8 @@ object PermissionUIHelper { } PermissionItem( - iconRes = R.drawable.rounded_call_24, - title = R.string.perm_contacts_title, + iconRes = permission.iconRes, + title = permission.titleRes, description = R.string.perm_contacts_desc, dependentFeatures = listOf(R.string.pixel_search_results_contacts_title), actionLabel = if (isGranted) R.string.perm_action_granted else R.string.perm_action_grant, @@ -421,7 +422,7 @@ object PermissionUIHelper { ) } - "READ_PHONE_STATE", "ANSWER_PHONE_CALLS", "READ_CALL_LOG" -> { + AppPermission.WATCH_CALL_SYNC -> { val hasReadPhoneState = androidx.core.content.ContextCompat .checkSelfPermission(context, android.Manifest.permission.READ_PHONE_STATE) == @@ -450,10 +451,10 @@ object PermissionUIHelper { } PermissionItem( - iconRes = R.drawable.rounded_mobile_sound_24, - title = R.string.watch_call_sync_title, + iconRes = permission.iconRes, + title = permission.titleRes, description = R.string.watch_call_sync_desc, - dependentFeatures = PermissionRegistry.getFeatures("READ_PHONE_STATE"), + dependentFeatures = PermissionRegistry.getFeatures(AppPermission.READ_PHONE_STATE), actionLabel = if (isGranted) R.string.perm_action_granted else R.string.perm_action_grant, action = { if (targetActivity != null) { @@ -473,26 +474,94 @@ object PermissionUIHelper { ) } - "STORAGE", "READ_MEDIA_IMAGES", "READ_EXTERNAL_STORAGE", "MANAGE_EXTERNAL_STORAGE" -> + AppPermission.STORAGE -> PermissionItem( - iconRes = R.drawable.rounded_image_24, - title = R.string.perm_storage_title, + iconRes = permission.iconRes, + title = permission.titleRes, description = R.string.perm_storage_desc, - dependentFeatures = PermissionRegistry.getFeatures("STORAGE"), + dependentFeatures = PermissionRegistry.getFeatures(permission), actionLabel = if (PermissionUtils.hasStoragePermission(context)) R.string.perm_action_granted else R.string.perm_action_grant, action = { PermissionUtils.openManageExternalStorageSettings(context) }, isGranted = PermissionUtils.hasStoragePermission(context), ) - - else -> null } + fun getPermissionItem( + key: String, + context: Context, + viewModel: MainViewModel, + activity: Activity? = null, + ): PermissionItem? { + val perm = AppPermission.fromKey(key) ?: return null + return getPermissionItem(perm, context, viewModel, activity) + } + + @JvmName("getPermissionItemsFromKeys") fun getPermissionItems( keys: List, context: Context, viewModel: MainViewModel, activity: Activity? = null, ): List = keys.mapNotNull { getPermissionItem(it, context, viewModel, activity) }.distinctBy { it.title } + + @JvmName("getPermissionItemsFromEnums") + fun getPermissionItems( + permissions: List, + context: Context, + viewModel: MainViewModel, + activity: Activity? = null, + ): List = permissions.mapNotNull { getPermissionItem(it, context, viewModel, activity) }.distinctBy { it.title } + + fun getAllPermissionItems( + context: Context, + viewModel: MainViewModel, + activity: Activity? = null, + ): List = getPermissionItems(AppPermission.entries, context, viewModel, activity) + + fun searchPermissions( + context: Context, + query: String, + viewModel: MainViewModel, + activity: Activity? = null, + ): List { + val trimmedQuery = query.trim() + if (trimmedQuery.isEmpty()) return emptyList() + + return getAllPermissionItems(context, viewModel, activity).filter { item -> + val titleStr = + when (val t = item.title) { + is Int -> context.getString(t) + is String -> t + else -> "" + } + val descStr = + when (val d = item.description) { + is Int -> context.getString(d) + is String -> d + else -> "" + } + val dependentFeaturesMatch = + item.dependentFeatures.any { featureRes -> + val featureName = + when (featureRes) { + is Int -> { + try { + context.getString(featureRes) + } catch (e: Exception) { + "" + } + } + is String -> featureRes + else -> "" + } + featureName.contains(trimmedQuery, ignoreCase = true) + } + + titleStr.contains(trimmedQuery, ignoreCase = true) || + descStr.contains(trimmedQuery, ignoreCase = true) || + dependentFeaturesMatch + } + } } 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 59185ab47..3984261e9 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -302,12 +302,14 @@ class MainViewModel : ViewModel() { val isLocationReachedFullScreenAlarmEnabled = mutableStateOf(true) val isEnableUnsupportedFeatures = mutableStateOf(false) + val isSecureSensitiveTilesEnabled = mutableStateOf(true) val isBlurEnabled = mutableStateOf(true) val isBlurSettingEnabled = mutableStateOf(true) val isRippleEnabled = mutableStateOf(true) val isRippleSettingEnabled = mutableStateOf(true) val isMotionBlurEnabled = mutableStateOf(false) val isMotionBlurSettingEnabled = mutableStateOf(false) + val isOnlineHelpMediaEnabled = mutableStateOf(true) val isSwipeTabsEnabled = mutableStateOf(true) val sentryReportMode = mutableStateOf("auto") val isPowerSaveModeEnabled = mutableStateOf(false) @@ -593,6 +595,11 @@ class MainViewModel : ViewModel() { } } + SettingsRepository.KEY_SECURE_SENSITIVE_TILES -> { + isSecureSensitiveTilesEnabled.value = + settingsRepository.isSecureSensitiveTilesEnabled() + } + SettingsRepository.KEY_KEYBOARD_HEIGHT -> keyboardHeight.floatValue = settingsRepository.getFloat(key, 54f) @@ -829,6 +836,10 @@ class MainViewModel : ViewModel() { appContext?.let { updateMotionBlurState(it) } } + SettingsRepository.KEY_ONLINE_HELP_MEDIA -> { + isOnlineHelpMediaEnabled.value = settingsRepository.isOnlineHelpMediaEnabled() + } + SettingsRepository.KEY_PRIVATE_DNS_PRESETS -> { dnsPresets.clear() dnsPresets.addAll(settingsRepository.getPrivateDnsPresets()) @@ -1764,6 +1775,7 @@ class MainViewModel : ViewModel() { isLocationReachedFullScreenAlarmEnabled.value = settingsRepository.getLocationReachedFullScreenAlarmEnabled() isEnableUnsupportedFeatures.value = settingsRepository.isEnableUnsupportedFeatures() + isSecureSensitiveTilesEnabled.value = settingsRepository.isSecureSensitiveTilesEnabled() keyboardHeight.floatValue = settingsRepository.getFloat(SettingsRepository.KEY_KEYBOARD_HEIGHT, 54f) @@ -2046,6 +2058,11 @@ class MainViewModel : ViewModel() { } } + fun setSecureSensitiveTilesEnabled(enabled: Boolean) { + isSecureSensitiveTilesEnabled.value = enabled + settingsRepository.setSecureSensitiveTilesEnabled(enabled) + } + /** * Executes the on search query changed operation. * @@ -2558,6 +2575,14 @@ class MainViewModel : ViewModel() { updateMotionBlurState(context) } + fun setOnlineHelpMediaEnabled( + enabled: Boolean, + context: Context? = null, + ) { + settingsRepository.setOnlineHelpMediaEnabled(enabled) + isOnlineHelpMediaEnabled.value = enabled + } + /** * Executes the set swipe tabs enabled operation. * diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b0bcd1960..1f7f79162 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2004,6 +2004,7 @@ Ripple animation Enable liquid ripple animations across the UI Motion blur + Online help media Blur is disabled on this device to prevent a known display bug on Samsung devices with Android 15 or below. Swipe between tabs In home screen @@ -2410,4 +2411,18 @@ Open \"%1$s\" Link copied to clipboard Apply after reboot + Secure sensitive tiles + Request to unlock before triggering sensitive QS tiles while locked + Unlock device to use %1$s + Show more + Show less + View all + Permissions + Calendar + Required to display events in Glance widgets and complications. + Request Permission + Grant Access + Enable Admin + Set as Default + Auto-Grant diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 73ed18fbb..1e22cb240 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -108,6 +108,7 @@ sentry-android = { group = "io.sentry", name = "sentry-android", version.ref = " androidx-graphics-shapes = { group = "androidx.graphics", name = "graphics-shapes", version.ref = "graphicsShapes" } androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" } androidx-media3-common = { group = "androidx.media3", name = "media3-common", version.ref = "media3" } +androidx-media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" } androidx-wear-remote-interactions = { group = "androidx.wear", name = "wear-remote-interactions", version.ref = "wearRemoteInteractions" } symspellkt = { module = "com.darkrockstudios:symspellkt", version.ref = "symspellkt" } genai-schema = { group = "com.google.mlkit", name = "genai-schema", version.ref = "genaiSchema" }