Skip to content
Merged
5 changes: 3 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, FeatureHelpMedia> =
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<Map<String, FeatureHelpMedia>>() {}.type
val cachedMap: Map<String, FeatureHelpMedia>? = 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<Map<String, FeatureHelpMedia>>() {}.type
val freshMap: Map<String, FeatureHelpMedia> = 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<Map<String, FeatureHelpMedia>>() {}.type
val cachedMap: Map<String, FeatureHelpMedia>? = 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 }
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
@@ -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<String> = 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) } }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, MutableList<Int>>()

fun register(
permission: AppPermission,
featureTitleRes: Int,
) {
register(permission.name, featureTitleRes)
}

fun register(
permissionKey: String,
featureTitleRes: Int,
Expand All @@ -22,7 +30,20 @@ object PermissionRegistry {
if (!list.contains(featureTitleRes)) list.add(featureTitleRes)
}

fun getFeatures(permissionKey: String): List<Int> = registry[permissionKey]?.toList() ?: emptyList()
fun getFeatures(permission: AppPermission): List<Int> {
val direct = registry[permission.name] ?: emptyList()
val aliasMatches = permission.aliases.flatMap { registry[it] ?: emptyList() }
return (direct + aliasMatches).distinct()
}

fun getFeatures(permissionKey: String): List<Int> {
val perm = AppPermission.fromKey(permissionKey)
return if (perm != null) {
getFeatures(perm)
} else {
registry[permissionKey]?.toList() ?: emptyList()
}
}
}

// Register existing dependencies
Expand Down
Loading
Loading