diff --git a/.github/workflows/manual_release.yml b/.github/workflows/manual_release.yml index b7a977d..865f7ba 100644 --- a/.github/workflows/manual_release.yml +++ b/.github/workflows/manual_release.yml @@ -41,7 +41,7 @@ jobs: # Step 2: Configure Java environment with integrated caching - name: Setup Java - uses: actions/setup-java@v6.0.0 + uses: actions/setup-java@v6.0.1 with: java-version: '21' distribution: 'temurin' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c0bddf3..eef8b64 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,7 +36,7 @@ jobs: # Sets up JDK 21 with Temurin and configures server-id for # potential publication to Maven-style repositories. - name: Set up JDK 21 - uses: actions/setup-java@v6.0.0 + uses: actions/setup-java@v6.0.1 with: java-version: '21' distribution: 'temurin' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dde5617..3677086 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,7 +40,7 @@ jobs: # Uses Temurin distribution (OpenJDK) and enables Gradle caching # to significantly reduce subsequent build times. - name: Setup Java - uses: actions/setup-java@v6.0.0 + uses: actions/setup-java@v6.0.1 with: java-version: '21' distribution: 'temurin' diff --git a/AGENTS.md b/AGENTS.md index 12f6294..3a6ba3d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ This file serves as the central "Source of Truth" for all AI agents (Gemini, Cur - **Name:** PiPup - **Purpose:** An Android application designed to display notifications and media in a Picture-in-Picture (PiP) window, primarily optimized for Android TV. -- **Technology Stack:** Kotlin, Android SDK, Jackson (JSON), SharedPreferences. +- **Technology Stack:** Kotlin, Android SDK, org.json, SharedPreferences, NanoHTTPD (WebServer), Media3 (Video), Glide (Images). ## Coding Standards & Preferences @@ -17,6 +17,14 @@ This file serves as the central "Source of Truth" for all AI agents (Gemini, Cur - **UI/UX:** Ensure all UI changes are optimized for Android TV (high contrast, readable font sizes, D-pad navigation support). Use the project's preset color resources. - **Documentation:** Use KDoc for all public-facing methods and classes. +## Core Knowledge Base + +Before proposing architectural changes, adding new features, or implementing modules, you MUST read the following documentation: + +- **[Architecture Overview](docs/architecture.md)**: Design principles and component interactions. +- **[Module Development Guide](docs/module-development.md)**: Blueprint and recipe for adding new modular features. +- **[HTTP API Reference](docs/api.md)**: Precision specification for remote requests. + ## Interaction Guidelines - **Language:** All instructions, code comments, and technical documentation within the codebase MUST be in English. diff --git a/README.md b/README.md index 0b4a421..1dda791 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ PiPup uses an embedded webserver (NanoHTTPD) which runs on port 7979. ### Sending notifications You can send notifications either as an **application/json** payload (for external media URLs) or as **multipart/form-data** (to upload a local image file). -For a detailed list of all endpoints and response formats, please refer to the [Full API Reference](API.md). +For a detailed list of all endpoints and response formats, please refer to the [Full API Reference](docs/api.md). | _Property_ | _Value_ | | ----------------- | ------------------------------------------- | @@ -287,9 +287,12 @@ Retrieves a JSON object containing all current styling and layout configurations "mediaRetries": 3, "appTheme": 0, "advancedMode": false, - "powerModuleEnabled": true, - "discoveryModuleEnabled": true, - "mediaModuleEnabled": true, + "moduleModes": { + "power": 1, + "discovery": 1, + "media": 2, + "vendor": 0 + }, "updateChannel": 0, "language": "default" } diff --git a/app/src/main/java/nl/rogro82/pipup/AppSettings.kt b/app/src/main/java/nl/rogro82/pipup/AppSettings.kt index 741fdf4..b914d9b 100644 --- a/app/src/main/java/nl/rogro82/pipup/AppSettings.kt +++ b/app/src/main/java/nl/rogro82/pipup/AppSettings.kt @@ -186,21 +186,6 @@ class AppSettings(context: Context) { /** The application theme (0: Dark, 1: Light). */ var appTheme by IntPref("app_theme", 0) - /** Whether the power control module is enabled. */ - var powerModuleEnabled: Boolean - get() = getModuleMode("power") != ModuleMode.OFF - set(value) = setModuleMode("power", if (value) getPreferredMode("power").takeIf { it != ModuleMode.OFF } ?: ModuleMode.ON else ModuleMode.OFF) - - /** Whether the network discovery module is enabled. */ - var discoveryModuleEnabled: Boolean - get() = getModuleMode("discovery") != ModuleMode.OFF - set(value) = setModuleMode("discovery", if (value) ModuleMode.ON else ModuleMode.OFF) - - /** Whether the rich media (WebView/WHEP) module is enabled. */ - var mediaModuleEnabled: Boolean - get() = getModuleMode("media") != ModuleMode.OFF - set(value) = setModuleMode("media", if (value) getPreferredMode("media").takeIf { it != ModuleMode.OFF } ?: ModuleMode.ECO else ModuleMode.OFF) - /** The preferred language for the application UI. */ var language by StringPref("language", "default") /** The timestamp of the last daily permission nag. */ @@ -278,9 +263,7 @@ class AppSettings(context: Context) { val mediaRetries: Int, val appTheme: Int, val advancedMode: Boolean, - val powerModuleEnabled: Boolean, - val discoveryModuleEnabled: Boolean, - val mediaModuleEnabled: Boolean, + val moduleModes: Map, val updateChannel: Int, val updateInterval: Int, val updateNotificationStyle: Int, @@ -317,9 +300,7 @@ class AppSettings(context: Context) { put("mediaRetries", mediaRetries) put("appTheme", appTheme) put("advancedMode", advancedMode) - put("powerModuleEnabled", powerModuleEnabled) - put("discoveryModuleEnabled", discoveryModuleEnabled) - put("mediaModuleEnabled", mediaModuleEnabled) + put("moduleModes", JSONObject(moduleModes)) put("updateChannel", updateChannel) put("updateInterval", updateInterval) put("updateNotificationStyle", updateNotificationStyle) @@ -360,9 +341,11 @@ class AppSettings(context: Context) { mediaRetries = j.optInt("mediaRetries", 3), appTheme = j.optInt("appTheme", 0), advancedMode = j.optBoolean("advancedMode", false), - powerModuleEnabled = j.optBoolean("powerModuleEnabled", false), - discoveryModuleEnabled = j.optBoolean("discoveryModuleEnabled", false), - mediaModuleEnabled = j.optBoolean("mediaModuleEnabled", false), + moduleModes = j.optJSONObject("moduleModes")?.let { modes -> + val map = mutableMapOf() + modes.keys().forEach { key -> map[key] = modes.getInt(key) } + map + } ?: emptyMap(), updateChannel = j.optInt("updateChannel", 0), updateInterval = j.optInt("updateInterval", 4), updateNotificationStyle = j.optInt("updateNotificationStyle", 1), @@ -384,6 +367,9 @@ class AppSettings(context: Context) { * Retrieves all current settings as a [SettingsData] object. */ fun getAll(): SettingsData { + val mm = (appContext as? PiPupApp)?.moduleManager + val modes = mm?.getAllModules()?.associate { it.id to getModuleMode(it.id).value } ?: emptyMap() + return SettingsData( positionIndex = positionIndex, backgroundColor = backgroundColor, @@ -406,9 +392,7 @@ class AppSettings(context: Context) { mediaRetries = mediaRetries, appTheme = appTheme, advancedMode = advancedMode, - powerModuleEnabled = powerModuleEnabled, - discoveryModuleEnabled = discoveryModuleEnabled, - mediaModuleEnabled = mediaModuleEnabled, + moduleModes = modes, updateChannel = updateChannel, updateInterval = updateInterval, updateNotificationStyle = updateNotificationStyle, @@ -419,8 +403,7 @@ class AppSettings(context: Context) { pendingUpdateId = pendingUpdateId, pendingUpdateDigest = pendingUpdateDigest, pendingUpdateTagName = pendingUpdateTagName, - language = language, - moduleModeMedia = getModuleMode("media").value + language = language ) } @@ -449,9 +432,15 @@ class AppSettings(context: Context) { mediaRetries = data.mediaRetries.coerceIn(0, 10) appTheme = data.appTheme.coerceIn(0, 1) advancedMode = data.advancedMode - powerModuleEnabled = data.powerModuleEnabled - discoveryModuleEnabled = data.discoveryModuleEnabled - mediaModuleEnabled = data.mediaModuleEnabled + + val mm = (appContext as? PiPupApp)?.moduleManager + data.moduleModes.forEach { (id, modeValue) -> + ModuleMode.entries.find { it.value == modeValue }?.let { mode -> + setModuleMode(id, mode) + mm?.updateModuleState(id, mode) + } + } + updateChannel = data.updateChannel.coerceIn(-1, 1) updateInterval = data.updateInterval.coerceIn(0, 4) updateNotificationStyle = data.updateNotificationStyle.coerceIn(0, 2) @@ -464,10 +453,6 @@ class AppSettings(context: Context) { pendingUpdateTagName = data.pendingUpdateTagName language = data.language - data.moduleModeMedia?.let { - ModuleMode.entries.find { m -> m.value == it }?.let { m -> setModuleMode("media", m) } - } - cachedFullBgColor = null } diff --git a/app/src/main/java/nl/rogro82/pipup/MainActivity.kt b/app/src/main/java/nl/rogro82/pipup/MainActivity.kt index 8005bbb..1ca40a4 100644 --- a/app/src/main/java/nl/rogro82/pipup/MainActivity.kt +++ b/app/src/main/java/nl/rogro82/pipup/MainActivity.kt @@ -20,7 +20,6 @@ import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.media3.common.util.UnstableApi import java.util.Calendar -import nl.rogro82.pipup.core.PowerController import nl.rogro82.pipup.service.PipUpService import nl.rogro82.pipup.ui.SettingsActivity @@ -147,11 +146,14 @@ class MainActivity : AppCompatActivity() { missing.add(getString(R.string.permission_overlay)) } - if (appSettings.powerModuleEnabled && PowerController.getSleepMethod(this) == null) { - missing.add(getString(R.string.settings_module_power)) + // Generic module permission check + val mm = (application as PiPupApp).moduleManager + val moduleMissing = mm.getEnabledModulesMissingPermissions(this).map { key -> + Permissions.getLabel(this, key) } + missing.addAll(moduleMissing) - return missing + return missing.distinct() } /** diff --git a/app/src/main/java/nl/rogro82/pipup/Permissions.kt b/app/src/main/java/nl/rogro82/pipup/Permissions.kt index 78c02fd..df0aba9 100644 --- a/app/src/main/java/nl/rogro82/pipup/Permissions.kt +++ b/app/src/main/java/nl/rogro82/pipup/Permissions.kt @@ -1,12 +1,17 @@ package nl.rogro82.pipup +import android.app.Activity import android.app.AppOpsManager import android.app.admin.DevicePolicyManager +import android.content.ComponentName import android.content.Context import android.content.Intent import android.os.Build import android.os.Handler import android.os.Looper +import android.os.PowerManager +import android.os.Process +import android.os.SystemClock import android.provider.Settings import android.text.Spannable import android.text.SpannableStringBuilder @@ -22,16 +27,17 @@ import androidx.core.net.toUri import androidx.lifecycle.Lifecycle import androidx.annotation.OptIn import androidx.media3.common.util.UnstableApi +import java.util.concurrent.atomic.AtomicInteger import nl.rogro82.pipup.core.PowerController import nl.rogro82.pipup.service.AdminReceiver import nl.rogro82.pipup.service.PiPupAccessibilityService +import nl.rogro82.pipup.ui.SettingsActivity /** * Centralized and robust permission management for PiPup. * - * Implements DRY by being the single source of truth for all permission checks, - * intents, and adb commands. Handles restricted environments (Fire OS, TCL) - * by detecting do-nothing placeholder screens. + * All permission metadata and implementations are centralized here. + * Modules simply request these permissions by key. */ @OptIn(UnstableApi::class) object Permissions { @@ -47,9 +53,9 @@ object Permissions { const val KEY_POWER = "power" // Virtual key for grouped power permissions /** List of permissions that can potentially be "fixed" via a system settings screen. */ - val FIXABLE_KEYS = listOf(KEY_OVERLAY, KEY_INSTALL, KEY_ADMIN, KEY_ACCESSIBILITY, KEY_ENERGY) + val FIXABLE_KEYS = listOf(KEY_OVERLAY, KEY_INSTALL, KEY_ADMIN, KEY_ACCESSIBILITY, KEY_ENERGY, KEY_AUTO_START) - private const val OP_AUTO_START = "android:auto_start" + private const val OP_TCL_AUTO_START = "android:auto_start" private val PLACEHOLDER_MARKERS = listOf("CTSDummy", "frameworkpackagestubs") const val BLOCKED_ERROR = @@ -63,7 +69,7 @@ object Permissions { @Volatile private var mLastFix: Map? = null - private val mVisibleActivities = java.util.concurrent.atomic.AtomicInteger(0) + private val mVisibleActivities = AtomicInteger(0) val activityVisible: Boolean get() = mVisibleActivities.get() > 0 @@ -78,10 +84,7 @@ object Permissions { } /** - * SYSTEM_ALERT_WINDOW - without it popups are accepted but never appear. - * - * @param context The context to check. - * @return True if permission is granted. + * Checks if the overlay permission is granted. */ fun overlay(context: Context): Boolean = try { Settings.canDrawOverlays(context) @@ -91,10 +94,7 @@ object Permissions { } /** - * REQUEST_INSTALL_PACKAGES - needed for the self-update to install its download. - * - * @param context The context to check. - * @return True if permission is granted. + * Checks if the package install permission is granted. */ fun installPackages(context: Context): Boolean = try { context.packageManager.canRequestPackageInstalls().also { @@ -107,17 +107,32 @@ object Permissions { } /** - * TCL's vendor app-op that decides whether Android may restart a killed service. - * - * @param context The context to check. - * @return True if allowed, false if denied, null if op is unavailable. + * Checks if a vendor-specific auto-start permission is granted. + * Implements a dispatcher to handle different manufacturer guards (TCL, Xiaomi, etc.). */ - fun autoStart(context: Context): Boolean? = try { + fun autoStart(context: Context): Boolean? { val ops = context.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager - val mode = checkAppOp(ops, OP_AUTO_START, context) - mode == AppOpsManager.MODE_ALLOWED - } catch (_: Throwable) { - null + + // 1. TCL Optimization (AppOp-based) + try { + // We use checkOpNoThrow to see if the op is even known to the system + @Suppress("DEPRECATION") + ops.checkOpNoThrow(OP_TCL_AUTO_START, Process.myUid(), context.packageName) + val mode = checkAppOp(ops, OP_TCL_AUTO_START, context) + return mode == AppOpsManager.MODE_ALLOWED + } catch (_: Throwable) {} + + // 2. Xiaomi / MIUI (Intent-based) + // On Xiaomi devices, background activity is strictly controlled. + // We show the row (return false) so users can reach the MIUI management screen. + if (Build.MANUFACTURER.contains("Xiaomi", ignoreCase = true) || + Build.BRAND.contains("Xiaomi", ignoreCase = true)) { + return false + } + + // Return null for hardware without known specific auto-start restrictions + // to gracefully hide the row in modular UIs. + return null } fun opMode(context: Context, op: String): String = try { @@ -136,19 +151,15 @@ object Permissions { private fun checkAppOp(ops: AppOpsManager, op: String, context: Context): Int { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { @Suppress("DEPRECATION") - ops.unsafeCheckOpNoThrow(op, android.os.Process.myUid(), context.packageName) + ops.unsafeCheckOpNoThrow(op, Process.myUid(), context.packageName) } else { @Suppress("DEPRECATION") - ops.checkOpNoThrow(op, android.os.Process.myUid(), context.packageName) + ops.checkOpNoThrow(op, Process.myUid(), context.packageName) } } /** * Returns whether a specific permission is granted. - * - * @param context The context to check. - * @param key The permission key (e.g. [KEY_OVERLAY]). - * @return True if granted, false if missing, null if not applicable. */ fun granted(context: Context, key: String): Boolean? = when (key) { KEY_OVERLAY -> overlay(context) @@ -161,7 +172,7 @@ object Permissions { KEY_ACCESSIBILITY -> PiPupAccessibilityService.isEnabledInSettings(context) KEY_AUTO_START -> autoStart(context) KEY_ENERGY -> { - val pm = context.getSystemService(Context.POWER_SERVICE) as android.os.PowerManager + val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager pm.isIgnoringBatteryOptimizations(context.packageName) } KEY_POWER -> PowerController.getSleepMethod(context) != null @@ -180,10 +191,6 @@ object Permissions { /** * Returns an ADB command string to grant the given permission. - * - * @param key The permission key. - * @param context The application context. - * @return The full adb shell command. */ fun adbCommand(key: String, context: Context): String { val pkg = context.packageName @@ -192,7 +199,11 @@ object Permissions { KEY_INSTALL -> "adb shell appops set --user current $pkg REQUEST_INSTALL_PACKAGES allow" KEY_ADMIN -> "adb shell dpm set-active-admin --user current $pkg/.service.AdminReceiver" KEY_ACCESSIBILITY -> "adb shell settings put secure --user current enabled_accessibility_services :$pkg/.service.PiPupAccessibilityService && adb shell settings put secure --user current accessibility_enabled 1" - KEY_AUTO_START -> "adb shell cmd appops set --user current $pkg android:auto_start allow" + KEY_AUTO_START -> { + if (autoStart(context) != null && !Build.MANUFACTURER.contains("Xiaomi", ignoreCase = true)) { + "adb shell cmd appops set --user current $pkg $OP_TCL_AUTO_START allow" + } else "" + } KEY_ENERGY -> "adb shell dumpsys deviceidle whitelist +$pkg" else -> "" } @@ -200,10 +211,6 @@ object Permissions { /** * Returns the raw system intent to open the settings screen for a permission. - * - * @param context The context. - * @param key The permission key. - * @return The Intent, or null if not supported. */ fun rawIntent(context: Context, key: String): Intent? = when (key) { KEY_OVERLAY -> Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, "package:${context.packageName}".toUri()) @@ -213,6 +220,13 @@ object Permissions { putExtra(DevicePolicyManager.EXTRA_ADD_EXPLANATION, context.getString(R.string.permission_admin_explanation)) } KEY_ACCESSIBILITY -> Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) + KEY_AUTO_START -> { + if (Build.MANUFACTURER.contains("Xiaomi", ignoreCase = true)) { + Intent().apply { + component = ComponentName("com.miui.securitycenter", "com.miui.permcenter.autostart.AutoStartManagementActivity") + } + } else null + } KEY_ENERGY -> Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS) else -> null } @@ -239,7 +253,6 @@ object Permissions { /** Returns an intent to fix the permission, or null if the device explicitly blocks it or has no screen. */ fun fixIntent(context: Context, key: String): Intent? { - // Overlay is special: even if op says blocked, the settings screen is the standard way. if (key != KEY_OVERLAY && opBlocked(context, key)) return null val intent = rawIntent(context, key) ?: return null @@ -259,8 +272,7 @@ object Permissions { fun launchFix(context: Context, key: String): Boolean { Log.d(LOG_TAG, "launchFix($key) called") - // Anchor focus to main menu item before leaving to ensure stable return - if (context is nl.rogro82.pipup.ui.SettingsActivity) { + if (context is SettingsActivity) { context.focusRail() } @@ -269,7 +281,7 @@ object Permissions { KEY_ACCESSIBILITY -> openTvAccessibilitySettings(context) KEY_ADMIN -> { val intent = rawIntent(context, KEY_ADMIN) ?: return false - if (context is nl.rogro82.pipup.ui.SettingsActivity) { + if (context is SettingsActivity) { context.requestAdminRights(intent) true } else { @@ -322,8 +334,8 @@ object Permissions { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } - if (context is nl.rogro82.pipup.ui.SettingsActivity) { - context.requestAdminRights(intent) // Re-use the same launcher for any activity result + if (context is SettingsActivity) { + context.requestAdminRights(intent) return true } @@ -349,7 +361,7 @@ object Permissions { private fun launchEnergyFix(context: Context): Boolean { val intent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS).apply { - if (context is android.app.Activity) { + if (context is Activity) { addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT) } else { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) @@ -359,8 +371,6 @@ object Permissions { try { context.startActivity(intent) - // Special Check for Energy Settings: If we are still in the app after 1.5s, - // the menu likely failed to open (common on TV). Fallback to App Info. Handler(Looper.getMainLooper()).postDelayed({ val isStillResumed = (context as? AppCompatActivity)?.lifecycle?.currentState?.isAtLeast(Lifecycle.State.RESUMED) ?: false if (isStillResumed) { @@ -379,45 +389,68 @@ object Permissions { } /** - * Centralized method to show a guided instruction dialog for any permission. - * Implements DRY by handling Why-text, ADB-hints and Restricted-hints in one place. + * Returns a human-readable label for the given permission key. */ - fun showFixDialog(context: Context, key: String) { - if (key == KEY_POWER) { - showPowerChoiceDialog(context) - return - } - - val label = when (key) { + fun getLabel(context: Context, key: String): String { + return when (key) { KEY_OVERLAY -> context.getString(R.string.permission_overlay) KEY_INSTALL -> context.getString(R.string.permission_install) + KEY_ENERGY -> context.getString(R.string.energy_optimization_title) KEY_ADMIN -> context.getString(R.string.permission_admin) KEY_ACCESSIBILITY -> context.getString(R.string.permission_accessibility) - KEY_ENERGY -> context.getString(R.string.energy_optimization_title) + KEY_AUTO_START -> context.getString(R.string.permission_autostart) + KEY_POWER -> { + val sleepMethod = PowerController.getSleepMethod(context) + if (sleepMethod != null) { + val methodLabel = if (sleepMethod == PowerController.METHOD_DEVICE_ADMIN) context.getString(R.string.permission_admin) else context.getString(R.string.permission_accessibility) + context.getString(R.string.settings_module_power) + " ($methodLabel)" + } else { + context.getString(R.string.settings_module_power) + } + } else -> key } + } - val appName = context.getString(R.string.app_name) - val whyRes = when (key) { + /** + * Returns a human-readable reason/why-text for the given permission key. + */ + fun getWhyText(context: Context, key: String): String? { + val resId = when (key) { KEY_OVERLAY -> R.string.permission_overlay_why KEY_INSTALL -> R.string.permission_install_why - KEY_ADMIN, KEY_ACCESSIBILITY -> R.string.permission_power_why + KEY_AUTO_START -> R.string.permission_autostart_why KEY_ENERGY -> R.string.permission_energy_why + KEY_ADMIN, KEY_ACCESSIBILITY, KEY_POWER -> R.string.permission_power_why else -> 0 } + return if (resId != 0) context.getString(resId) else null + } + + /** + * Shows a guided instruction dialog for any permission. + */ + fun showFixDialog(context: Context, key: String) { + if (key == KEY_POWER) { + showPowerChoiceDialog(context) + return + } + + val label = getLabel(context, key) + val appName = context.getString(R.string.app_name) + val why = getWhyText(context, key) val fixIntentAvailable = fixIntent(context, key) != null val message = SpannableStringBuilder() - if (whyRes != 0) { - message.append(context.getString(whyRes)) + if (why != null) { + message.append(why) } if (key == KEY_ENERGY) { message.append("\n\n").append(context.getString(R.string.energy_optimization_manual, appName)) } - // Add Restricted Settings hint for Sideloaded apps on Android 13+ if (key == KEY_ADMIN || key == KEY_ACCESSIBILITY) { message.append("\n\n").append(context.getString(R.string.permission_restricted_hint)) } @@ -508,7 +541,6 @@ object Permissions { } dialog.show() - // Focus first option for D-pad handler.post { container.getChildAt(1)?.requestFocus() } } @@ -568,7 +600,7 @@ object Permissions { "ok" to ok, "activity" to activity, "error" to error, - "at" to android.os.SystemClock.elapsedRealtime() + "at" to SystemClock.elapsedRealtime() ) } @@ -601,7 +633,7 @@ object Permissions { ) }, "lastFix" to mLastFix?.let { fix -> - fix + mapOf("secondsAgo" to (android.os.SystemClock.elapsedRealtime() - (fix["at"] as Long)) / 1000) - "at" + fix + mapOf("secondsAgo" to (SystemClock.elapsedRealtime() - (fix["at"] as Long)) / 1000) - "at" } ) } diff --git a/app/src/main/java/nl/rogro82/pipup/PiPupApp.kt b/app/src/main/java/nl/rogro82/pipup/PiPupApp.kt index 23ffbab..b6ec2cd 100644 --- a/app/src/main/java/nl/rogro82/pipup/PiPupApp.kt +++ b/app/src/main/java/nl/rogro82/pipup/PiPupApp.kt @@ -9,6 +9,7 @@ import nl.rogro82.pipup.core.modules.DiscoveryModule import nl.rogro82.pipup.core.modules.MediaModule import nl.rogro82.pipup.core.modules.PowerModule import nl.rogro82.pipup.core.modules.SystemModule +import nl.rogro82.pipup.core.modules.VendorModule import nl.rogro82.pipup.core.modules.DebugModule class PiPupApp : Application() { @@ -35,6 +36,7 @@ class PiPupApp : Application() { moduleManager.registerModule(PowerModule()) moduleManager.registerModule(DiscoveryModule()) moduleManager.registerModule(MediaModule()) + moduleManager.registerModule(VendorModule()) if (BuildConfig.DEBUG) { moduleManager.registerModule(DebugModule()) diff --git a/app/src/main/java/nl/rogro82/pipup/core/ModuleDefinitions.kt b/app/src/main/java/nl/rogro82/pipup/core/ModuleDefinitions.kt index 4ddd233..59f51e5 100644 --- a/app/src/main/java/nl/rogro82/pipup/core/ModuleDefinitions.kt +++ b/app/src/main/java/nl/rogro82/pipup/core/ModuleDefinitions.kt @@ -1,7 +1,9 @@ package nl.rogro82.pipup.core import androidx.annotation.DrawableRes +import androidx.annotation.LayoutRes import androidx.annotation.StringRes +import nl.rogro82.pipup.R /** * Supported types for modular settings. @@ -60,7 +62,8 @@ data class ModuleMenuDefinition( @DrawableRes val iconRes: Int, @StringRes val labelRes: Int, val priority: Int = 100, - val showInMainRail: Boolean = false + val showInMainRail: Boolean = false, + @LayoutRes val layoutRes: Int = R.layout.submenu_module_dynamic ) /** diff --git a/app/src/main/java/nl/rogro82/pipup/core/ModuleManager.kt b/app/src/main/java/nl/rogro82/pipup/core/ModuleManager.kt index 6184734..9cc92a4 100644 --- a/app/src/main/java/nl/rogro82/pipup/core/ModuleManager.kt +++ b/app/src/main/java/nl/rogro82/pipup/core/ModuleManager.kt @@ -1,9 +1,12 @@ package nl.rogro82.pipup.core +import android.content.Context import android.util.Log import androidx.annotation.OptIn import androidx.media3.common.util.UnstableApi import fi.iki.elonen.NanoHTTPD +import nl.rogro82.pipup.Permissions +import nl.rogro82.pipup.PiPupApp /** * Manages the lifecycle and request dispatching for optional PiPup modules. @@ -128,8 +131,19 @@ class ModuleManager { * Returns all modules that are logically enabled (not OFF). */ fun getEnabledModules(): List { - val context = moduleContext ?: return emptyList() - return modules.values.filter { context.settings.getModuleMode(it.id) != ModuleMode.OFF } + val s = PiPupApp.settings + return modules.values.filter { s.getModuleMode(it.id) != ModuleMode.OFF } + } + + /** + * Returns all permissions required by enabled modules that are currently missing. + */ + fun getEnabledModulesMissingPermissions(context: Context): List { + return getEnabledModules().flatMap { module -> + module.getRequiredPermissions().filter { key -> + Permissions.granted(context, key) == false + } + }.distinct() } /** @@ -163,6 +177,15 @@ class ModuleManager { return null } + /** + * Notifies modules about changes in the notification display state. + */ + fun notifyNotificationDisplayStateChanged(isDisplaying: Boolean) { + activeModules.forEach { id -> + modules[id]?.onNotificationDisplayStateChanged(isDisplaying) + } + } + /** * Allows enabled modules to add their own information to the global state report. */ diff --git a/app/src/main/java/nl/rogro82/pipup/core/NotificationManager.kt b/app/src/main/java/nl/rogro82/pipup/core/NotificationManager.kt index eafe69e..c4044a3 100644 --- a/app/src/main/java/nl/rogro82/pipup/core/NotificationManager.kt +++ b/app/src/main/java/nl/rogro82/pipup/core/NotificationManager.kt @@ -193,6 +193,7 @@ class NotificationManager( when (val s = state) { is NotificationState.Preparing -> { if (s.view == view) { + notifyStateChanged(true) if (showPopup(view, s.props)) { state = NotificationState.Displaying(s.props, view) processNextLocked() @@ -205,6 +206,7 @@ class NotificationManager( is NotificationState.DisplayingAndPreparing -> { if (s.prepView == view) { if (s.prepProps.overwrite) { + notifyStateChanged(true) replaceCurrentPopup(s.displayView, view, s.prepProps) state = NotificationState.Displaying(s.prepProps, view) processNextLocked() @@ -286,6 +288,10 @@ class NotificationManager( } } + private fun notifyStateChanged(isDisplaying: Boolean) { + (context.applicationContext as? PiPupApp)?.moduleManager?.notifyNotificationDisplayStateChanged(isDisplaying) + } + private fun performCancelAll() { synchronized(this) { queue.clear() @@ -309,10 +315,8 @@ class NotificationManager( else -> {} } state = NotificationState.Idle + notifyStateChanged(false) removeOverlay() - - // Memory cleanup: Clear any cached context if applicable - (context.applicationContext as? PiPupApp)?.moduleManager?.getModule("media") } } @@ -429,11 +433,7 @@ class NotificationManager( state = nextState if (state is NotificationState.Idle) { - (context.applicationContext as? PiPupApp)?.let { app -> - if (PiPupApp.settings.getModuleMode("media") == ModuleMode.ON) { - app.moduleManager.activateModule("media") - } - } + notifyStateChanged(false) if (queue.isEmpty()) removeOverlay() } diff --git a/app/src/main/java/nl/rogro82/pipup/core/PiPupModule.kt b/app/src/main/java/nl/rogro82/pipup/core/PiPupModule.kt index 1a3c201..1ced80b 100644 --- a/app/src/main/java/nl/rogro82/pipup/core/PiPupModule.kt +++ b/app/src/main/java/nl/rogro82/pipup/core/PiPupModule.kt @@ -4,15 +4,14 @@ import fi.iki.elonen.NanoHTTPD /** * Interface for optional PiPup modules. - * - * Modules can be enabled/disabled by the user and can hook into the WebServer - * request flow and the application state reporting. */ interface PiPupModule { /** Unique identifier for the module (e.g., "power"). */ val id: String /** User-friendly name of the module. */ val name: String + /** Description of the module functionality (Resource ID). */ + val descriptionRes: Int /** * List of modes supported by this module. @@ -30,6 +29,11 @@ interface PiPupModule { */ val supportedRoutes: List get() = emptyList() + /** + * Called when the notification display state changes. + */ + fun onNotificationDisplayStateChanged(isDisplaying: Boolean) {} + /** * Called when the module is enabled. * diff --git a/app/src/main/java/nl/rogro82/pipup/core/modules/DebugModule.kt b/app/src/main/java/nl/rogro82/pipup/core/modules/DebugModule.kt index b570c68..a31e3bd 100644 --- a/app/src/main/java/nl/rogro82/pipup/core/modules/DebugModule.kt +++ b/app/src/main/java/nl/rogro82/pipup/core/modules/DebugModule.kt @@ -14,6 +14,7 @@ import nl.rogro82.pipup.core.ModuleContext import nl.rogro82.pipup.core.PiPupModule import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit +import nl.rogro82.pipup.R /** * Module providing debug and diagnostic endpoints. @@ -27,6 +28,7 @@ class DebugModule : PiPupModule { override val id: String = "debug" override val name: String = "Debug & Diagnostics" + override val descriptionRes: Int = R.string.settings_nav_advanced override val supportedModes: List = listOf(ModuleMode.OFF, ModuleMode.ON) override val defaultMode: ModuleMode = ModuleMode.OFF diff --git a/app/src/main/java/nl/rogro82/pipup/core/modules/DiscoveryModule.kt b/app/src/main/java/nl/rogro82/pipup/core/modules/DiscoveryModule.kt index f2e5e3b..113a85a 100644 --- a/app/src/main/java/nl/rogro82/pipup/core/modules/DiscoveryModule.kt +++ b/app/src/main/java/nl/rogro82/pipup/core/modules/DiscoveryModule.kt @@ -6,6 +6,7 @@ import android.os.Build import android.util.Log import androidx.core.content.ContextCompat import nl.rogro82.pipup.BuildConfig +import nl.rogro82.pipup.R import nl.rogro82.pipup.core.ModuleContext import nl.rogro82.pipup.core.ModuleMode import nl.rogro82.pipup.core.PiPupModule @@ -23,6 +24,7 @@ class DiscoveryModule : PiPupModule { override val id: String = "discovery" override val name: String = "Network Discovery" + override val descriptionRes: Int = R.string.settings_module_discovery_desc override val supportedModes: List = listOf(ModuleMode.OFF, ModuleMode.ON) override val defaultMode: ModuleMode = ModuleMode.ON diff --git a/app/src/main/java/nl/rogro82/pipup/core/modules/MediaModule.kt b/app/src/main/java/nl/rogro82/pipup/core/modules/MediaModule.kt index 1604eea..73c1bd6 100644 --- a/app/src/main/java/nl/rogro82/pipup/core/modules/MediaModule.kt +++ b/app/src/main/java/nl/rogro82/pipup/core/modules/MediaModule.kt @@ -1,6 +1,7 @@ package nl.rogro82.pipup.core.modules import android.util.Log +import nl.rogro82.pipup.PiPupApp import nl.rogro82.pipup.R import nl.rogro82.pipup.core.ModuleMode import nl.rogro82.pipup.core.ModuleContext @@ -20,12 +21,23 @@ class MediaModule : PiPupModule { override val id: String = "media" override val name: String = "Rich Media Support" + override val descriptionRes: Int = R.string.settings_module_media_desc override val supportedModes: List = listOf(ModuleMode.OFF, ModuleMode.ECO, ModuleMode.ON) override val defaultMode: ModuleMode = ModuleMode.ECO override val supportedRoutes: List = emptyList() private var moduleContext: ModuleContext? = null + override fun onNotificationDisplayStateChanged(isDisplaying: Boolean) { + if (!isDisplaying) { + // Engine warming: Re-activate module when idle to ensure it stays in memory (if in ON mode) + val settings = PiPupApp.settings + if (settings.getModuleMode(id) == ModuleMode.ON) { + moduleContext?.let { (it.androidContext.applicationContext as? PiPupApp)?.moduleManager?.activateModule(id) } + } + } + } + override fun onEnable(context: ModuleContext) { Log.d(TAG, "Media module enabled") this.moduleContext = context diff --git a/app/src/main/java/nl/rogro82/pipup/core/modules/PowerModule.kt b/app/src/main/java/nl/rogro82/pipup/core/modules/PowerModule.kt index 640a495..ba93b52 100644 --- a/app/src/main/java/nl/rogro82/pipup/core/modules/PowerModule.kt +++ b/app/src/main/java/nl/rogro82/pipup/core/modules/PowerModule.kt @@ -21,6 +21,7 @@ class PowerModule : PiPupModule { override val id: String = "power" override val name: String = "Power Control" + override val descriptionRes: Int = R.string.settings_module_power_desc override val supportedModes: List = listOf(ModuleMode.OFF, ModuleMode.ON) override val defaultMode: ModuleMode = ModuleMode.OFF diff --git a/app/src/main/java/nl/rogro82/pipup/core/modules/SystemModule.kt b/app/src/main/java/nl/rogro82/pipup/core/modules/SystemModule.kt index 3276e8a..e789637 100644 --- a/app/src/main/java/nl/rogro82/pipup/core/modules/SystemModule.kt +++ b/app/src/main/java/nl/rogro82/pipup/core/modules/SystemModule.kt @@ -32,6 +32,7 @@ class SystemModule : PiPupModule { override val id: String = "system" override val name: String = "System Diagnostics" + override val descriptionRes: Int = R.string.settings_nav_permissions // Use a relevant existing string override val supportedModes: List = listOf(ModuleMode.ON) override val defaultMode: ModuleMode = ModuleMode.ON diff --git a/app/src/main/java/nl/rogro82/pipup/core/modules/VendorModule.kt b/app/src/main/java/nl/rogro82/pipup/core/modules/VendorModule.kt new file mode 100644 index 0000000..8840af4 --- /dev/null +++ b/app/src/main/java/nl/rogro82/pipup/core/modules/VendorModule.kt @@ -0,0 +1,76 @@ +package nl.rogro82.pipup.core.modules + +import android.util.Log +import nl.rogro82.pipup.Permissions +import nl.rogro82.pipup.R +import nl.rogro82.pipup.core.ModuleContext +import nl.rogro82.pipup.core.ModuleMenuDefinition +import nl.rogro82.pipup.core.ModuleMode +import nl.rogro82.pipup.core.PiPupModule +import nl.rogro82.pipup.core.SettingType +import nl.rogro82.pipup.core.ModuleSettingDefinition + +/** + * Module for vendor-specific optimizations and workarounds. + * Primarily addresses background freezing issues on TCL devices. + */ +class VendorModule : PiPupModule { + + companion object { + private const val TAG = "VendorModule" + } + + override val id: String = "vendor" + override val name: String = "Vendor Optimizations" + override val descriptionRes: Int = R.string.settings_module_vendor_desc + + override val supportedModes: List = listOf(ModuleMode.OFF, ModuleMode.ON) + override val defaultMode: ModuleMode = ModuleMode.OFF + + private var moduleContext: ModuleContext? = null + + override fun onEnable(context: ModuleContext) { + Log.d(TAG, "Vendor module enabled") + this.moduleContext = context + } + + override fun onDisable() { + Log.d(TAG, "Vendor module disabled") + moduleContext = null + } + + override fun getRequiredPermissions(): List { + // Return KEY_AUTO_START purely based on module activation. + // PermissionsSubmenu or Permissions.granted will handle the hardware-specific visibility/state. + return listOf(Permissions.KEY_AUTO_START) + } + + override fun getSettingsMenu(): ModuleMenuDefinition { + return ModuleMenuDefinition( + iconRes = R.drawable.ic_module_vendor, + labelRes = R.string.settings_module_vendor, + priority = 85, // After Modules, before Permissions + layoutRes = R.layout.submenu_vendor + ) + } + + override fun getSettingsMetadata(): List { + return listOf( + ModuleSettingDefinition( + key = "tcl_optimization", + type = SettingType.BOOLEAN, + labelRes = R.string.settings_vendor_tcl_keepalive, + descriptionRes = R.string.settings_vendor_tcl_keepalive_desc, + defaultValue = false + ) + ) + } + + override fun augmentState(state: MutableMap) { + val context = moduleContext?.androidContext ?: return + val vendorState = mutableMapOf( + "isTcl" to (Permissions.autoStart(context) != null) + ) + state["vendor"] = vendorState + } +} diff --git a/app/src/main/java/nl/rogro82/pipup/service/PipUpService.kt b/app/src/main/java/nl/rogro82/pipup/service/PipUpService.kt index 4fb1705..6c070a9 100644 --- a/app/src/main/java/nl/rogro82/pipup/service/PipUpService.kt +++ b/app/src/main/java/nl/rogro82/pipup/service/PipUpService.kt @@ -34,7 +34,6 @@ import nl.rogro82.pipup.UpdateManager import nl.rogro82.pipup.UpdateWorker import nl.rogro82.pipup.applyAppLocaleAndTheme import nl.rogro82.pipup.colorToHex -import nl.rogro82.pipup.core.ModuleMode import nl.rogro82.pipup.core.ModuleContext import nl.rogro82.pipup.core.NotificationManager import nl.rogro82.pipup.core.WebServer @@ -191,25 +190,22 @@ class PipUpService : Service() { override fun onBind(intent: Intent?): IBinder? = null - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = START_STICKY + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + // Answer every startForegroundService() - mandatory even if already in foreground + // to prevent RemoteServiceException on some Android versions/vendors. + updateForegroundNotification(settings.language) + return START_STICKY + } private fun handleRequest(session: NanoHTTPD.IHTTPSession): NanoHTTPD.Response { val uri = session.uri.lowercase() val method = session.method - // 1. Check if module-specific routes are blocked by activation mode - if (uri == "/power" && settings.getModuleMode("power") == ModuleMode.OFF) { - val localizedContext = getLocalizedContext(settings.language) - val moduleName = localizedContext.getString(R.string.settings_module_power) - showToast(localizedContext.getString(R.string.error_module_disabled, moduleName)) - return NanoHTTPD.newFixedLengthResponse(NanoHTTPD.Response.Status.FORBIDDEN, "text/plain", "Module disabled") - } - - // 2. Dispatch to ModuleManager (Handles core API via SystemModule + other modules) + // 1. Dispatch to ModuleManager (Handles core API via SystemModule + other modules) val moduleResponse = moduleManager.handleRequest(session) if (moduleResponse != null) return moduleResponse - // 3. Fallback for unhandled routes + // 2. Fallback for unhandled routes return try { when (uri) { "/" -> { diff --git a/app/src/main/java/nl/rogro82/pipup/ui/AdvancedSubmenu.kt b/app/src/main/java/nl/rogro82/pipup/ui/AdvancedSubmenu.kt index 3d3fbcb..8dcc4cc 100644 --- a/app/src/main/java/nl/rogro82/pipup/ui/AdvancedSubmenu.kt +++ b/app/src/main/java/nl/rogro82/pipup/ui/AdvancedSubmenu.kt @@ -19,6 +19,7 @@ import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.SwitchCompat import androidx.media3.common.util.UnstableApi import nl.rogro82.pipup.* +import nl.rogro82.pipup.core.ModuleMode import nl.rogro82.pipup.service.PipUpService import nl.rogro82.pipup.core.modules.DiscoveryModule @@ -109,7 +110,7 @@ class AdvancedSubmenu( val service = PipUpService.getInstance() val discovery = service?.moduleManager?.getModule("discovery") as? DiscoveryModule - if (discovery == null || !settings.discoveryModuleEnabled) { + if (discovery == null || settings.getModuleMode("discovery") == ModuleMode.OFF) { context.showToast(context.getString(R.string.error_module_disabled, context.getString(R.string.settings_module_discovery))) showImportIpDialog() return diff --git a/app/src/main/java/nl/rogro82/pipup/ui/ModulesSubmenu.kt b/app/src/main/java/nl/rogro82/pipup/ui/ModulesSubmenu.kt index 323174b..ef78252 100644 --- a/app/src/main/java/nl/rogro82/pipup/ui/ModulesSubmenu.kt +++ b/app/src/main/java/nl/rogro82/pipup/ui/ModulesSubmenu.kt @@ -1,17 +1,24 @@ package nl.rogro82.pipup.ui import android.content.Context +import android.view.LayoutInflater import android.view.View import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.TextView import androidx.appcompat.widget.SwitchCompat import androidx.media3.common.util.UnstableApi import nl.rogro82.pipup.AppSettings import nl.rogro82.pipup.Permissions +import nl.rogro82.pipup.PiPupApp import nl.rogro82.pipup.R +import nl.rogro82.pipup.core.ModuleMode +import nl.rogro82.pipup.core.PiPupModule import nl.rogro82.pipup.core.PowerController /** * Submenu for managing optional PiPup modules. + * Dynamically renders toggles for all registered modules. */ @UnstableApi class ModulesSubmenu( @@ -21,63 +28,64 @@ class ModulesSubmenu( previewArea: FrameLayout ) : SubmenuBase(context, settings, onSettingsChanged, previewArea) { + private lateinit var rootContainer: LinearLayout + override fun onBind(root: View) { - // Power Control Module - bindModuleToggle( - root, - R.id.container_power_module, - R.id.switch_power_module, - { settings.powerModuleEnabled }, - { settings.powerModuleEnabled = it }, - { if (it && PowerController.getSleepMethod(context) == null) Permissions.showFixDialog(context, Permissions.KEY_POWER) } - ) - - // Network Discovery Module - bindModuleToggle( - root, - R.id.container_discovery_module, - R.id.switch_discovery_module, - { settings.discoveryModuleEnabled }, - { settings.discoveryModuleEnabled = it } - ) - - // Rich Media Module - bindModuleToggle( - root, - R.id.container_media_module, - R.id.switch_media_module, - { settings.mediaModuleEnabled }, - { settings.mediaModuleEnabled = it } - ) + rootContainer = root as? LinearLayout ?: return + render() } - private fun bindModuleToggle( - root: View, - containerId: Int, - switchId: Int, - getter: () -> Boolean, - onToggle: (Boolean) -> Unit, - afterToggle: ((Boolean) -> Unit)? = null - ) { - root.findViewById(containerId)?.apply { - val sw = findViewById(switchId) - sw.isChecked = getter() - - // Disable direct interaction with the switch to avoid double-toggles - sw.isClickable = false - sw.isFocusable = false - - setOnClickListener { - val newState = !getter() - sw.isChecked = newState - onToggle(newState) - - notifySettingsChanged() - afterToggle?.invoke(newState) - onSettingsChanged(false) - } - onFocusChangeListener = View.OnFocusChangeListener { v, f -> if (f) updatePreviewPosition(v) } + private fun render() { + // Clear all except the title + val titleView = rootContainer.getChildAt(0) + rootContainer.removeAllViews() + rootContainer.addView(titleView) + + val mm = (context.applicationContext as PiPupApp).moduleManager + val modules = mm.getAllModules().filter { it.id != "system" && it.id != "debug" } + + modules.forEach { module -> + renderModuleToggle(module) } + + settingsActivity?.setupSubmenuFocus() } + private fun renderModuleToggle(module: PiPupModule) { + val view = LayoutInflater.from(context).inflate(R.layout.item_setting_toggle_with_desc, rootContainer, false) + + view.findViewById(R.id.setting_label)?.text = module.name + view.findViewById(R.id.setting_desc)?.apply { + setText(module.descriptionRes) + visibility = View.VISIBLE + } + + val sw = view.findViewById(R.id.setting_switch) + val currentMode = settings.getModuleMode(module.id) + sw?.isChecked = currentMode != ModuleMode.OFF + + view.setOnClickListener { + val next = if (settings.getModuleMode(module.id) == ModuleMode.OFF) { + // When enabling, try to restore preferred mode (ON or ECO) + settings.getPreferredMode(module.id).takeIf { it != ModuleMode.OFF } ?: ModuleMode.ON + } else { + ModuleMode.OFF + } + + settings.setModuleMode(module.id, next) + (context.applicationContext as PiPupApp).moduleManager.updateModuleState(module.id, next) + sw?.isChecked = next != ModuleMode.OFF + + notifySettingsChanged() + + // Special handling for Power Module permission nag + if (module.id == "power" && next != ModuleMode.OFF && PowerController.getSleepMethod(context) == null) { + Permissions.showFixDialog(context, Permissions.KEY_POWER) + } + + onSettingsChanged(false) + } + + rootContainer.addView(view) + } } diff --git a/app/src/main/java/nl/rogro82/pipup/ui/PermissionsSubmenu.kt b/app/src/main/java/nl/rogro82/pipup/ui/PermissionsSubmenu.kt index f6c29a0..6c9b168 100644 --- a/app/src/main/java/nl/rogro82/pipup/ui/PermissionsSubmenu.kt +++ b/app/src/main/java/nl/rogro82/pipup/ui/PermissionsSubmenu.kt @@ -2,6 +2,8 @@ package nl.rogro82.pipup.ui import android.content.Context import android.graphics.Typeface +import android.os.PowerManager +import android.view.KeyEvent import android.view.View import android.widget.FrameLayout import android.widget.LinearLayout @@ -9,13 +11,18 @@ import android.widget.TextView import androidx.core.content.ContextCompat import androidx.media3.common.util.UnstableApi import nl.rogro82.pipup.AppSettings +import nl.rogro82.pipup.BuildConfig import nl.rogro82.pipup.Permissions +import nl.rogro82.pipup.PiPupApp import nl.rogro82.pipup.R +import nl.rogro82.pipup.core.ModuleMode import nl.rogro82.pipup.dpToPx import nl.rogro82.pipup.core.PowerController +import nl.rogro82.pipup.core.SettingCategory /** * Submenu for central permission management. + * Adheres to modular architecture: asks ModuleManager for permissions instead of hardcoding. */ @UnstableApi class PermissionsSubmenu( @@ -28,8 +35,6 @@ class PermissionsSubmenu( private val refreshTask = object : Runnable { override fun run() { val root = rootView - // Only continue if we are still the active submenu. - // We wait for attachment if it hasn't happened yet. if (root != null && (settingsActivity?.getCurrentSubmenuLayout() == R.layout.submenu_permissions)) { if (root.isAttachedToWindow) { refreshPermissionList(root) @@ -45,11 +50,7 @@ class PermissionsSubmenu( override fun onBind(root: View) { rootView = root - - // Stop any previous task instances to avoid parallel loops handler.removeCallbacks(refreshTask) - - // Initial render logic refreshPermissionList(root) handler.postDelayed(refreshTask, 2000) } @@ -60,23 +61,25 @@ class PermissionsSubmenu( } private fun refreshPermissionList(root: View) { - // Robustness: Handle both the direct root (from ViewStub) or its container val submenuRoot: LinearLayout? = root.findViewById(R.id.permissions_root) ?: (root as? LinearLayout) if (submenuRoot == null || (submenuRoot.id != R.id.permissions_root && root.id != R.id.permissions_root)) return - // Use a signature to avoid unnecessary UI rebuilds val sleepMethod = PowerController.getSleepMethod(context) - val powerModuleEnabled = settings.powerModuleEnabled + val powerModuleEnabled = settings.getModuleMode("power") != ModuleMode.OFF val overlayGranted = Permissions.overlay(context) val installGranted = Permissions.installPackages(context) - val energyGranted = (context.getSystemService(Context.POWER_SERVICE) as android.os.PowerManager).isIgnoringBatteryOptimizations(context.packageName) + val energyGranted = (context.getSystemService(Context.POWER_SERVICE) as PowerManager).isIgnoringBatteryOptimizations(context.packageName) + + // Modular integration: Get permissions required by currently enabled modules + val mm = (context.applicationContext as PiPupApp).moduleManager + val modulePermissions = mm.getEnabledModules().flatMap { it.getRequiredPermissions() }.toSet() - val currentSignature = "pow:$powerModuleEnabled:$sleepMethod|ov:$overlayGranted|in:$installGranted|en:$energyGranted" + val currentSignature = "pow:$powerModuleEnabled:$sleepMethod|ov:$overlayGranted|in:$installGranted|en:$energyGranted|mods:${modulePermissions.joinToString(",")}" if (submenuRoot.tag == currentSignature) return submenuRoot.tag = currentSignature - // 1. Clear everything except the first child (the title) + // 1. Clear dynamic content val childCount = submenuRoot.childCount if (childCount > 1) { submenuRoot.removeViews(1, childCount - 1) @@ -86,16 +89,8 @@ class PermissionsSubmenu( addSectionHeader(submenuRoot, context.getString(R.string.permission_header_required)) addPermissionRow(submenuRoot, Permissions.KEY_OVERLAY, isOptional = false) - // Dynamically add module-required permissions - val mm = (context.applicationContext as nl.rogro82.pipup.PiPupApp).moduleManager - val modulePermissions = mm.getEnabledModules().flatMap { it.getRequiredPermissions() }.toSet() - modulePermissions.forEach { key -> - addPermissionRow( - submenuRoot, - key, - isOptional = false - ) + addPermissionRow(submenuRoot, key, isOptional = false) } // 3. OPTIONAL SECTION @@ -103,9 +98,8 @@ class PermissionsSubmenu( addPermissionRow(submenuRoot, Permissions.KEY_INSTALL, isOptional = true) addPermissionRow(submenuRoot, Permissions.KEY_ENERGY, isOptional = true) - renderModuleSettings(submenuRoot, nl.rogro82.pipup.core.SettingCategory.PERMISSIONS) + renderModuleSettings(submenuRoot, SettingCategory.PERMISSIONS) - // 4. Trigger focus recalculation settingsActivity?.setupSubmenuFocus() } @@ -122,27 +116,17 @@ class PermissionsSubmenu( } private fun addPermissionRow(container: LinearLayout, key: String, isOptional: Boolean = false) { - val granted = Permissions.granted(context, key) ?: false - val label = when(key) { - Permissions.KEY_OVERLAY -> context.getString(R.string.permission_overlay) - Permissions.KEY_ENERGY -> context.getString(R.string.energy_optimization_title) - Permissions.KEY_INSTALL -> context.getString(R.string.permission_install) - Permissions.KEY_ADMIN -> context.getString(R.string.permission_admin) - Permissions.KEY_ACCESSIBILITY -> context.getString(R.string.permission_accessibility) - Permissions.KEY_POWER -> { - val sleepMethod = PowerController.getSleepMethod(context) - if (sleepMethod != null) { - val methodLabel = if (sleepMethod == PowerController.METHOD_DEVICE_ADMIN) context.getString(R.string.permission_admin) else context.getString(R.string.permission_accessibility) - context.getString(R.string.settings_module_power) + " ($methodLabel)" - } else { - context.getString(R.string.settings_module_power) - } - } - else -> key - } + val granted = Permissions.granted(context, key) + val isGranted = granted == true + val isNotSupported = granted == null + + // UX Refinement: Hide N/A permissions in Release builds. + // Developers can still see them (grayed out) in Debug builds for integration testing. + if (isNotSupported && !BuildConfig.DEBUG) return + + val label = Permissions.getLabel(context, key) val row = LinearLayout(context).apply { - // Stable ID is crucial: Android uses it to restore focus after a list refresh. id = key.hashCode() and 0x7FFFFFFF orientation = LinearLayout.VERTICAL setPadding(context.dpToPx(16), context.dpToPx(12), context.dpToPx(16), context.dpToPx(16)) @@ -155,48 +139,44 @@ class PermissionsSubmenu( isFocusableInTouchMode = true setOnClickListener { - if (!granted) { + if (!isGranted) { Permissions.showFixDialog(context, key) } } onFocusChangeListener = View.OnFocusChangeListener { v, f -> - if (f) { - updatePreviewPosition(v) - } + if (f) updatePreviewPosition(v) } - // Trap focus on the right side to prevent it from disappearing setOnKeyListener { _, keyCode, event -> - event.action == android.view.KeyEvent.ACTION_DOWN && keyCode == android.view.KeyEvent.KEYCODE_DPAD_RIGHT + event.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_DPAD_RIGHT } } val title = TextView(context).apply { - text = if (granted) context.getString(R.string.permission_granted, label) else context.getString(R.string.permission_missing, label) + text = when { + isNotSupported -> context.getString(R.string.permission_granted, label) + " (N/A)" + isGranted -> context.getString(R.string.permission_granted, label) + else -> context.getString(R.string.permission_missing, label) + } textSize = 18f val statusColor = when { - granted -> R.color.status_green + isNotSupported -> R.color.colorOnSurfaceVariant + isGranted -> R.color.status_green isOptional -> R.color.status_orange else -> R.color.status_red } setTextColor(ContextCompat.getColor(context, statusColor)) - if (!granted) setTypeface(null, Typeface.BOLD) + if (!isGranted && !isNotSupported) setTypeface(null, Typeface.BOLD) } row.addView(title) - val whyRes = when(key) { - Permissions.KEY_OVERLAY -> R.string.permission_overlay_why - Permissions.KEY_ENERGY -> R.string.permission_energy_why - Permissions.KEY_INSTALL -> R.string.permission_install_why - Permissions.KEY_ADMIN, Permissions.KEY_ACCESSIBILITY, Permissions.KEY_POWER -> R.string.permission_power_why - else -> null - } + val why = Permissions.getWhyText(context, key) - if (whyRes != null && (!granted || key in setOf(Permissions.KEY_OVERLAY, Permissions.KEY_ENERGY, Permissions.KEY_INSTALL, Permissions.KEY_ADMIN, Permissions.KEY_ACCESSIBILITY, Permissions.KEY_POWER))) { + if (why != null && (isNotSupported || !isGranted || key in setOf(Permissions.KEY_OVERLAY, Permissions.KEY_ENERGY, Permissions.KEY_INSTALL, Permissions.KEY_ADMIN, Permissions.KEY_ACCESSIBILITY, Permissions.KEY_POWER))) { row.addView(TextView(context).apply { - text = context.getString(whyRes) + text = if (isNotSupported) "This permission is not applicable to your hardware." else why textSize = 14f alpha = 0.8f setTextColor(ContextCompat.getColor(this@PermissionsSubmenu.context, R.color.colorOnSurfaceVariant)) @@ -204,7 +184,7 @@ class PermissionsSubmenu( }) } - if (!granted) { + if (!isGranted && !isNotSupported) { val adb = Permissions.adbCommand(key, context) if (adb.isNotEmpty()) { row.addView(TextView(context).apply { diff --git a/app/src/main/java/nl/rogro82/pipup/ui/SettingsActivity.kt b/app/src/main/java/nl/rogro82/pipup/ui/SettingsActivity.kt index 55531b3..a48c264 100644 --- a/app/src/main/java/nl/rogro82/pipup/ui/SettingsActivity.kt +++ b/app/src/main/java/nl/rogro82/pipup/ui/SettingsActivity.kt @@ -1,22 +1,28 @@ package nl.rogro82.pipup.ui +import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.res.ColorStateList +import android.graphics.Bitmap import android.graphics.Paint +import android.graphics.Rect import android.os.Bundle import android.os.Handler import android.os.Looper +import android.util.Log import android.view.Gravity import android.view.KeyEvent import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.ViewStub import android.widget.ArrayAdapter import android.widget.FrameLayout import android.widget.ImageView import android.widget.ScrollView +import android.widget.SeekBar import android.widget.TextView import androidx.activity.OnBackPressedCallback import androidx.activity.result.contract.ActivityResultContracts @@ -27,12 +33,15 @@ import androidx.core.graphics.createBitmap import androidx.core.graphics.toColorInt import androidx.core.view.isVisible import androidx.media3.common.util.UnstableApi +import nl.rogro82.pipup.AppSettings +import nl.rogro82.pipup.MainActivity import nl.rogro82.pipup.PiPupApp import nl.rogro82.pipup.Permissions import nl.rogro82.pipup.PopupProps import nl.rogro82.pipup.R import nl.rogro82.pipup.applyAppLocaleAndTheme import nl.rogro82.pipup.colorToHex +import nl.rogro82.pipup.core.PiPupModule import nl.rogro82.pipup.databinding.ActivitySettingsBinding import nl.rogro82.pipup.registerProtectedReceiver import nl.rogro82.pipup.showToast @@ -66,6 +75,15 @@ class SettingsActivity : AppCompatActivity() { private val moduleControllers = mutableMapOf() private val inflatedSubmenus = mutableMapOf() + /** + * Registry for module-specific submenu controllers. + * New modules can "dock" by providing their layoutRes in ModuleMenuDefinition. + */ + private val moduleSubmenuFactories = mapOf Unit, ViewGroup) -> SubmenuController>( + R.layout.submenu_vendor to ::VendorSubmenu, + R.layout.submenu_module_dynamic to ::ModuleSubmenu + ) + private val coreRailItems = listOf( NavItem(R.id.nav_item_general, R.string.settings_nav_general, R.drawable.ic_general_style, 10, R.layout.submenu_general), NavItem(R.id.nav_item_background, R.string.settings_nav_background, R.drawable.ic_bg, 20, R.layout.submenu_background), @@ -90,11 +108,11 @@ class SettingsActivity : AppCompatActivity() { val moduleId: String? = null ) - private val settingsReceiver = object : android.content.BroadcastReceiver() { + private val settingsReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (intent?.action == PiPupApp.ACTION_SETTINGS_CHANGED) { if (intent.getStringExtra("origin") == "remote") { - android.util.Log.d("SettingsActivity", "Remote settings change detected, refreshing UI") + Log.d("SettingsActivity", "Remote settings change detected, refreshing UI") if (!isFinishing && !isDestroyed) { recreate() } @@ -127,7 +145,7 @@ class SettingsActivity : AppCompatActivity() { ) } - private var cachedPlaceholder: android.graphics.Bitmap? = null + private var cachedPlaceholder: Bitmap? = null override fun onCreate(savedInstanceState: Bundle?) { // Apply theme and locale before super.onCreate to ensure the UI reflects settings @@ -192,10 +210,10 @@ class SettingsActivity : AppCompatActivity() { // 2. Module specific tabs val moduleItems = moduleManager.getEnabledModules() - .mapNotNull { it.getSettingsMenu()?.let { menu -> it.id to menu } } - .map { (moduleId, menu) -> - val existingId = dynamicRailItems.find { it.moduleId == moduleId }?.id ?: View.generateViewId() - NavItem(existingId, menu.labelRes, menu.iconRes, menu.priority, moduleId = moduleId) + .mapNotNull { it.getSettingsMenu()?.let { menu -> it to menu } } + .map { (module, menu) -> + val existingId = dynamicRailItems.find { it.moduleId == module.id }?.id ?: View.generateViewId() + NavItem(existingId, menu.labelRes, menu.iconRes, menu.priority, layoutRes = menu.layoutRes, moduleId = module.id) } newDynamicItems.addAll(moduleItems) @@ -218,7 +236,9 @@ class SettingsActivity : AppCompatActivity() { return if (item.moduleId != null) { val module = (application as PiPupApp).moduleManager.getModule(item.moduleId)!! moduleControllers.getOrPut(item.moduleId) { - ModuleSubmenu(this, settings, module, { updatePreview(it) }, binding.previewArea) + // Modular creation: Use factory from registry based on the requested layoutRes + val factory = moduleSubmenuFactories[item.layoutRes] ?: moduleSubmenuFactories[R.layout.submenu_module_dynamic]!! + factory(this, settings, module, { updatePreview(it) }, binding.previewArea) } } else { getController(item.layoutRes) @@ -264,7 +284,7 @@ class SettingsActivity : AppCompatActivity() { // On Android TV, focus requests on off-screen items are often silently ignored. val parentScroll = (target.parent?.parent as? ScrollView) ?: (target.parent as? ScrollView) parentScroll?.let { scroll -> - val rect = android.graphics.Rect() + val rect = Rect() target.getDrawingRect(rect) scroll.offsetDescendantRectToMyCoords(target, rect) val centerY = rect.top - (scroll.height / 2) + (target.height / 2) @@ -314,7 +334,7 @@ class SettingsActivity : AppCompatActivity() { view.setOnFocusChangeListener { v, hasFocus -> if (hasFocus) { (v.parent.parent as? ScrollView)?.let { scroll -> - val rect = android.graphics.Rect() + val rect = Rect() v.getDrawingRect(rect) scroll.offsetDescendantRectToMyCoords(v, rect) val pivotY = scroll.height * 0.3f @@ -358,9 +378,7 @@ class SettingsActivity : AppCompatActivity() { private fun loadSubmenuForItem(item: NavItem?) { if (item == null) return currentNavId = item.id - - val layoutRes = if (item.moduleId != null) R.layout.submenu_module_dynamic else item.layoutRes - currentLayoutRes = layoutRes + currentLayoutRes = item.layoutRes val stubId = getStubIdForNavItem(item) @@ -369,7 +387,7 @@ class SettingsActivity : AppCompatActivity() { // 2. Inflate or just show the target view val root = inflatedSubmenus.getOrPut(stubId) { - val stub = findViewById(stubId) + val stub = findViewById(stubId) if (stub != null) { stub.inflate() } else { @@ -412,12 +430,12 @@ class SettingsActivity : AppCompatActivity() { * Ensures MainActivity is at the root so the user can navigate back to it. */ fun resetSettingsUI() { - android.util.Log.i("SettingsActivity", "Resetting Settings UI and restarting app task") + Log.i("SettingsActivity", "Resetting Settings UI and restarting app task") lastFocusedViewId = View.NO_ID currentNavId = R.id.nav_item_general // 1. Prepare intent for MainActivity (the root) - val mainIntent = Intent(this, nl.rogro82.pipup.MainActivity::class.java).apply { + val mainIntent = Intent(this, MainActivity::class.java).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) } @@ -512,7 +530,7 @@ class SettingsActivity : AppCompatActivity() { child.nextFocusDownId = if (i < focusableChildren.size - 1) focusableChildren[i + 1].id else child.id // Trap focus on the right side to prevent it from disappearing. - if (child !is android.widget.SeekBar) { + if (child !is SeekBar) { child.setOnKeyListener { _, keyCode, event -> if (event.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) { return@setOnKeyListener true @@ -528,7 +546,7 @@ class SettingsActivity : AppCompatActivity() { val scroll = binding.settingsScroll val c = binding.submenuContainer - val rect = android.graphics.Rect() + val rect = Rect() v.getDrawingRect(rect) scroll.offsetDescendantRectToMyCoords(v, rect) diff --git a/app/src/main/java/nl/rogro82/pipup/ui/VendorSubmenu.kt b/app/src/main/java/nl/rogro82/pipup/ui/VendorSubmenu.kt new file mode 100644 index 0000000..08776bd --- /dev/null +++ b/app/src/main/java/nl/rogro82/pipup/ui/VendorSubmenu.kt @@ -0,0 +1,116 @@ +package nl.rogro82.pipup.ui + +import android.graphics.Typeface +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.TextView +import androidx.annotation.OptIn +import androidx.core.content.ContextCompat +import androidx.media3.common.util.UnstableApi +import nl.rogro82.pipup.AppSettings +import nl.rogro82.pipup.Permissions +import nl.rogro82.pipup.R +import nl.rogro82.pipup.core.PiPupModule +import nl.rogro82.pipup.dpToPx +import nl.rogro82.pipup.service.PiPupAccessibilityService + +/** + * Specialized submenu for the Vendor module. + * Displays vendor-specific settings and status information like the Keep-Alive state. + */ +@OptIn(UnstableApi::class) +class VendorSubmenu( + activity: SettingsActivity, + settings: AppSettings, + private val module: PiPupModule, + onPreviewUpdate: (Boolean) -> Unit, + previewContainer: ViewGroup +) : SubmenuBase(activity, settings, onPreviewUpdate, previewContainer as FrameLayout) { + + private lateinit var rootContainer: LinearLayout + + private val refreshTask = object : Runnable { + override fun run() { + if (::rootContainer.isInitialized && rootContainer.isAttachedToWindow) { + updateStatus() + handler.postDelayed(this, 3000) + } + } + } + + override fun onBind(root: View) { + rootContainer = root.findViewById(R.id.module_settings_container) ?: return + render() + handler.postDelayed(refreshTask, 1000) + } + + override fun onBackPress(): Boolean { + handler.removeCallbacks(refreshTask) + return super.onBackPress() + } + + private fun render() { + rootContainer.removeAllViews() + + // 1. Settings Section + val metadata = module.getSettingsMetadata() + metadata.forEach { def -> + renderSetting(rootContainer, module, def) + } + + // 2. Status Section + addSectionHeader(rootContainer, context.getString(R.string.server_landing_status)) + + val statusView = LayoutInflater.from(context).inflate(R.layout.item_setting_toggle, rootContainer, false) + statusView.id = R.id.vendor_status_row + statusView.findViewById(R.id.setting_switch)?.visibility = View.GONE + statusView.findViewById(R.id.setting_label)?.text = context.getString(R.string.settings_vendor_tcl_keepalive) + statusView.isFocusable = false + + rootContainer.addView(statusView) + updateStatus() + + settingsActivity?.setupSubmenuFocus() + } + + private fun updateStatus() { + val row = rootContainer.findViewById(R.id.vendor_status_row) ?: return + val label = row.findViewById(R.id.setting_label) ?: return + + val isTcl = Permissions.autoStart(context) != null + val accEnabled = PiPupAccessibilityService.isEnabledInSettings(context) + val accRunning = PiPupAccessibilityService.isAvailable() + + val statusText = when { + !isTcl -> context.getString(R.string.vendor_status_not_tcl) + accRunning -> context.getString(R.string.vendor_status_active) + accEnabled -> context.getString(R.string.vendor_status_waiting) + else -> context.getString(R.string.vendor_status_inactive) + } + + val color = when { + !isTcl -> R.color.colorOnSurfaceVariant + accRunning -> R.color.status_green + accEnabled -> R.color.status_orange + else -> R.color.status_red + } + + label.text = context.getString(R.string.settings_module_setting_format, context.getString(R.string.settings_vendor_tcl_keepalive), statusText) + label.setTextColor(ContextCompat.getColor(context, color)) + } + + private fun addSectionHeader(container: LinearLayout, title: String) { + container.addView( + TextView(context).apply { + text = title.uppercase() + textSize = 14f + setTypeface(null, Typeface.BOLD) + setTextColor(ContextCompat.getColor(context, R.color.colorOnSurfaceVariant)) + setPadding(context.dpToPx(16), context.dpToPx(24), 0, context.dpToPx(8)) + } + ) + } +} diff --git a/app/src/main/res/drawable/ic_module_vendor.xml b/app/src/main/res/drawable/ic_module_vendor.xml new file mode 100644 index 0000000..e91f3e0 --- /dev/null +++ b/app/src/main/res/drawable/ic_module_vendor.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/item_setting_toggle_with_desc.xml b/app/src/main/res/layout/item_setting_toggle_with_desc.xml new file mode 100644 index 0000000..3590f5a --- /dev/null +++ b/app/src/main/res/layout/item_setting_toggle_with_desc.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/submenu_modules.xml b/app/src/main/res/layout/submenu_modules.xml index d3976e2..052ca78 100644 --- a/app/src/main/res/layout/submenu_modules.xml +++ b/app/src/main/res/layout/submenu_modules.xml @@ -157,4 +157,52 @@ android:focusable="false" /> + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/submenu_vendor.xml b/app/src/main/res/layout/submenu_vendor.xml new file mode 100644 index 0000000..2ef05e2 --- /dev/null +++ b/app/src/main/res/layout/submenu_vendor.xml @@ -0,0 +1,7 @@ + + + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 20ce8de..2d29574 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -278,4 +278,16 @@ Dokumentation und API-Nutzung finden Sie auf GitHub. Läuft auf %1$s (Android %2$s) + + + Hersteller-Einstellungen + Herstellerspezifische Optimierungen für eine bessere Hintergrundstabilität (z.B. TCL Keep-Alive). + TCL Keep-Alive + Hebt die Prozess-Priorität an, um ein Einfrieren durch den TCL Vendor Guard zu verhindern. Erfordert den Accessibility Service. + Kein TCL-Gerät (Keep-Alive nicht erforderlich) + AKTIV (Prozess geschützt) + WARTET (Service aktiviert, aber nicht gebunden) + INAKTIV (Accessibility Service erforderlich) + Auto-Start + Erlaubt dem System, den Dienst nach einem Neustart oder Kill automatisch wiederherzustellen. diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml index 13e3b70..ada8911 100644 --- a/app/src/main/res/values/ids.xml +++ b/app/src/main/res/values/ids.xml @@ -16,4 +16,7 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 19e8005..33ca054 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -279,4 +279,16 @@ Documentation and API usage can be found on GitHub. Running on %1$s (Android %2$s) + + + Vendor Settings + Vendor-specific optimizations for better background stability (e.g. TCL Keep-Alive). + TCL Keep-Alive + Increases process priority to prevent freezing by TCL Vendor Guard. Requires Accessibility Service. + Not a TCL device (Keep-Alive not required) + ACTIVE (Process protected) + WAITING (Service enabled but not bound) + INACTIVE (Accessibility Service required) + Auto-Start + Allows the system to automatically restore the service after a reboot or kill. diff --git a/API.md b/docs/api.md similarity index 100% rename from API.md rename to docs/api.md diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..07153b3 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,47 @@ +# PiPup Architecture Overview + +PiPup follows a strict modular "Docking" architecture. The core system is designed to be "blind" to specific module implementations, providing only the necessary infrastructure for modules to register their functionality. + +## Core Components + +### 1. PipUpService + +The main background service (`PipUpService.kt`) hosts the NanoHTTPD web server and manages the `NotificationManager`. It acts as the primary entry point for all remote requests but delegates processing to the `ModuleManager`. + +### 2. ModuleManager + +The `ModuleManager.kt` is the brain of the modular system. It: + +- Maintains a registry of all available `PiPupModule` instances. +- Manages module lifecycles (ON, OFF, ECO). +- Dispatches HTTP requests to the appropriate module based on `supportedRoutes`. +- Augments the global `/state` response with module-specific data. + +### 3. NotificationManager + +Responsible for the display queue and view lifecycle of overlays. It ensures that only one popup is shown at a time and performs aggressive cleanup of views and resources (like WebViews) to maintain a low RAM footprint. + +## Data Flow + +```mermaid +graph TD + Client[Remote Client] -->|HTTP Request| WebServer[NanoHTTPD in PipUpService] + WebServer -->|Dispatch| ModuleManager + ModuleManager -->|Handle| Module[Registered PiPupModule] + Module -->|Enqueue Popup| NotificationManager + NotificationManager -->|Render| UI[PopupView on WindowManager] +``` + +## UI Registry & Docking + +In the `SettingsActivity.kt`, a centralized `moduleSubmenuFactories` registry is used to map layout resources to specialized UI controllers. + +- Modules can provide a custom `layoutRes` in their `ModuleMenuDefinition`. +- The Activity creates the corresponding controller (e.g., `VendorSubmenu`) without knowing about the module's identity. +- If no custom layout is provided, the generic `ModuleSubmenu` is used. + +## Design Principles + +- **The Core is Blind:** No `if (moduleId == "...")` checks are allowed in the core or UI classes. +- **Resource Efficiency:** Modules in `ECO` mode are unloaded when idle to save RAM on Android TV devices. +- **Centralized Permissions:** All strings and ADB commands for permissions live in `Permissions.kt`, but visibility is driven by module requirements. diff --git a/docs/module-development.md b/docs/module-development.md new file mode 100644 index 0000000..af23949 --- /dev/null +++ b/docs/module-development.md @@ -0,0 +1,91 @@ +# PiPup Module Development Guide + +This guide describes how to create and integrate a new module into the PiPup ecosystem using the modular "Gold Standard." + +## 1. Implement PiPupModule + +Create a new class that implements the `PiPupModule` interface. + +```kotlin +class MyAwesomeModule : PiPupModule { + override val id: String = "awesome" + override val name: String = "Awesome Feature" + override val descriptionRes: Int = R.string.my_module_desc + + override val supportedModes: List = listOf(ModuleMode.OFF, ModuleMode.ON) + override val defaultMode: ModuleMode = ModuleMode.OFF + + override fun onEnable(context: ModuleContext) { + // Initialization logic + } + + override fun onDisable() { + // Teardown logic + } +} +``` + +## 2. Register the Module + +Add your module instance to `PiPupApp.kt`: + +```kotlin +moduleManager.registerModule(MyAwesomeModule()) +``` + +## 3. UI Integration (Optional) + +### Global Settings Tabs + +If your module adds settings to existing tabs (like General or Performance), return metadata with a `category`: + +```kotlin +override fun getSettingsMetadata() = listOf( + ModuleSettingDefinition( + key = "my_setting", + type = SettingType.BOOLEAN, + labelRes = R.string.label, + category = SettingCategory.GENERAL + ) +) +``` + +### Dedicated Module Submenu + +To have a separate entry in the settings rail, implement `getSettingsMenu()`: + +```kotlin +override fun getSettingsMenu() = ModuleMenuDefinition( + iconRes = R.drawable.my_icon, + labelRes = R.string.my_menu_label, + priority = 100, + layoutRes = R.layout.my_custom_layout // Use R.layout.submenu_module_dynamic for generic toggle list +) +``` + +If you use a custom `layoutRes`, remember to register your controller in `SettingsActivity.moduleSubmenuFactories`. + +## 4. HTTP Routes (Optional) + +Define routes your module should handle: + +```kotlin +override val supportedRoutes: List = listOf("/my-endpoint") + +override fun handleRequest(session: NanoHTTPD.IHTTPSession): NanoHTTPD.Response? { + if (session.uri == "/my-endpoint") { + return NanoHTTPD.newFixedLengthResponse("Hello!") + } + return null +} +``` + +## 5. Permissions (Optional) + +If your module requires specific permissions, return their keys: + +```kotlin +override fun getRequiredPermissions() = listOf(Permissions.KEY_OVERLAY) +``` + +New permission keys must be implemented in the centralized `Permissions.kt` manager. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1a95082..c49bd4e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,9 +18,9 @@ leanback = "1.2.0" media3 = "1.11.1" nanohttpd = "2.3.1" # @keep -pipup = "0.9.2" +pipup = "10.0.0" testRunner = "1.7.0" -versions-plugin = "0.62.0" +versions-plugin = "0.63.0" work = "2.11.2" [libraries]