diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index 1bb31df..4698b2f 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -51,6 +51,11 @@ body:
label: App Version
description: "Which version is causing the issue? (Check tags: https://github.com/D4vidDf/HyperBridge/tags)"
options:
+ - v0.5.5
+ - v0.5.4
+ - v0.5.3
+ - v0.5.2
+ - v0.5.0
- v0.4.0 (Stable)
- v0.4.0-rc1
- Dev Build (Please specify in description)
diff --git a/.gitignore b/.gitignore
index aa724b7..a382a9c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,15 +1,64 @@
+# Built application files
+*.apk
+*.aar
+*.ap_
+*.aab
+
+# Files for the ART/Dalvik VM
+*.dex
+
+# Java class files
+*.class
+
+# Generated files
+bin/
+gen/
+out/
+
+# Gradle files
+.gradle/
+build/
+
+# Local configuration file (sdk path, etc)
+local.properties
+
+# Proguard folder generated by Eclipse
+proguard/
+
+# Log Files
+*.log
+
+# Android Studio Navigation editor temp files
+.navigation/
+
+# Android Studio captures folder
+captures/
+
+# IntelliJ
*.iml
-.gradle
-/local.properties
-/.idea/caches
-/.idea/libraries
-/.idea/modules.xml
-/.idea/workspace.xml
-/.idea/navEditor.xml
-/.idea/assetWizardSettings.xml
-.DS_Store
-/build
-/captures
+.idea/
+!.idea/codeStyles/
+!.idea/runConfigurations/
+!.idea/vcs.xml
+
+# Keystore files
+*.jks
+*.keystore
+
+# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
-.cxx
-local.properties
+.cxx/
+
+# Mac OS X
+.DS_Store
+
+# Compose compiler metrics and reports
+*compose_metrics.txt
+*compose_reports.txt
+
+# Custom HyperBridge project additions
+caps_build.txt
+gradle/gradle-daemon-jvm.properties
+.idea/deploymentTargetSelector.xml
+.idea/deploymentTargetSelector.xml
+.idea/misc.xml
diff --git a/.idea/CrowdinSettingsPlugin.xml b/.idea/CrowdinSettingsPlugin.xml
index 018093b..abd84f1 100644
--- a/.idea/CrowdinSettingsPlugin.xml
+++ b/.idea/CrowdinSettingsPlugin.xml
@@ -4,6 +4,7 @@
+
\ No newline at end of file
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
index b268ef3..ca16a99 100644
--- a/.idea/deploymentTargetSelector.xml
+++ b/.idea/deploymentTargetSelector.xml
@@ -4,6 +4,7 @@
+
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 639c779..02c4aa5 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -6,7 +6,6 @@
-
-
+
@@ -31,18 +31,21 @@
## 🚀 Features
* **Native Visuals:** Transforms notifications into HyperOS system-style islands.
-* **🎨 Theme Engine (New):** Customize every pixel.
+* **🎨 Theme Engine:** Customize every pixel.
* **Theme Creator:** Built-in editor to design your own themes with real-time previews.
* **Smart Colors:** Automatically extract vibrant brand colors from app icons.
* **Icon Shaping:** Choose between shapes like *Squircle*, *Clover*, *Arch*, and *Cookie*.
* **Granular Control:** Per-app overrides for colors, icons, and action styles.
-* **🧩 Widgets (New):** Pin standard Android widgets to the island layer for quick access—even on the Lockscreen!
+ * **Smart Icon Tinting:** Intelligently tints dark/monochrome icons to remain visible.
+* **🧩 Widgets:** Pin standard Android widgets to the island layer for quick access—even on the Lockscreen!
+* **🧠 Intelligent Permanent Island:** The permanent island now automatically hides itself whenever an active widget is shown on the screen, preventing awkward overlaps.
* **Smart Integration:**
* **🎵 Media:** Show album art and "Now Playing" status with visualizer support.
* **🧭 Navigation:** Real-time turn-by-turn instructions (Google Maps, Waze).
- * **⬇️ Downloads:** Circular progress ring with a satisfying "Green Tick" animation upon completion.
+ * **⬇️ Downloads:** Dedicated circular progress layout with a satisfying "Green Tick" animation upon completion.
* **📞 Calls:** Dedicated layout for incoming and active calls with timers.
* **🛡️ Spoiler Protection:** Define blocked terms globally or per-app to prevent specific notifications (e.g., message spoilers) from popping up on the Island.
+* **Sui & Shizuku Support:** Fully supports Sui and Shizuku for enhanced network operations and seamless integration on rooted devices.
* **Total Control:** Choose exactly which apps trigger the island, customize timeouts, and toggle floating behavior per app.
## 👩💻 For Developers: Create Themes
diff --git a/app/.gitignore b/app/.gitignore
index 42afabf..2bdf0f6 100644
--- a/app/.gitignore
+++ b/app/.gitignore
@@ -1 +1,3 @@
-/build
\ No newline at end of file
+/build
+/debug
+/release
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 03438ff..762166c 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,9 +1,8 @@
plugins {
alias(libs.plugins.android.application)
- alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
- id("org.jetbrains.kotlin.plugin.serialization") version "2.3.0"
+ id("org.jetbrains.kotlin.plugin.serialization") version "2.3.21"
}
android {
@@ -15,10 +14,9 @@ android {
defaultConfig {
applicationId = "com.d4viddf.hyperbridge"
minSdk = 35
- targetSdk = 36
- versionCode = 16
- versionName = "0.5.0-alpha01"
-
+ targetSdk = 37
+ versionCode = 30
+ versionName = "0.5.5"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -37,12 +35,8 @@ android {
}
buildFeatures {
compose = true
- }
-}
-
-kotlin {
- compilerOptions {
- jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11)
+ buildConfig = true
+ aidl = true
}
}
@@ -59,7 +53,6 @@ dependencies {
implementation(libs.androidx.ui)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.palette.ktx)
- implementation(libs.androidx.material3)
ksp(libs.androidx.room.compiler)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
@@ -72,13 +65,23 @@ dependencies {
debugImplementation(libs.androidx.compose.ui.test.manifest)
implementation(libs.hyperisland.kit)
+ implementation(libs.haze)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.gson)
implementation(libs.kotlinx.serialization.json)
+
+ // Navigation 3
+ implementation(libs.androidx.navigation3.runtime)
+ implementation(libs.androidx.navigation3.ui)
+ implementation(libs.androidx.lifecycle.viewmodel.navigation3)
+
+ // Shizuku
+ implementation(libs.api)
+ implementation(libs.provider)
}
configurations.all {
exclude(group = "com.intellij", module = "annotations")
-}
\ No newline at end of file
+}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index 481bb43..a35b8f1 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -18,4 +18,12 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
-#-renamesourcefileattribute SourceFile
\ No newline at end of file
+#-renamesourcefileattribute SourceFile
+
+# Strip Debug and Info logs in Release (AGP 9.1+ named level syntax)
+-assumenosideeffects class android.util.Log {
+ public static boolean isLoggable(java.lang.String, int);
+ public static int v(...);
+ public static int d(...);
+ public static int i(...);
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 2d4f0b6..d9c9b63 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -4,12 +4,13 @@
-
+ tools:ignore="PackageVisibilityPolicy,QueryAllPackagesPermission" />
+
+
@@ -22,6 +23,7 @@
+ android:exported="true">
+
+
+
+
+
+
@@ -99,6 +107,15 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/aidl/com/d4viddf/hyperbridge/IPrivilegedLogCallback.aidl b/app/src/main/aidl/com/d4viddf/hyperbridge/IPrivilegedLogCallback.aidl
new file mode 100644
index 0000000..ad82fbf
--- /dev/null
+++ b/app/src/main/aidl/com/d4viddf/hyperbridge/IPrivilegedLogCallback.aidl
@@ -0,0 +1,5 @@
+package com.d4viddf.hyperbridge;
+
+interface IPrivilegedLogCallback {
+ void log(int level, String tag, String message);
+}
diff --git a/app/src/main/aidl/com/d4viddf/hyperbridge/IPrivilegedService.aidl b/app/src/main/aidl/com/d4viddf/hyperbridge/IPrivilegedService.aidl
new file mode 100644
index 0000000..c6069fb
--- /dev/null
+++ b/app/src/main/aidl/com/d4viddf/hyperbridge/IPrivilegedService.aidl
@@ -0,0 +1,8 @@
+package com.d4viddf.hyperbridge;
+
+import com.d4viddf.hyperbridge.IPrivilegedLogCallback;
+
+interface IPrivilegedService {
+ void setLogCallback(IPrivilegedLogCallback callback);
+ boolean setPackageNetworkingEnabled(int uid, boolean enabled);
+}
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/MainActivity.kt b/app/src/main/java/com/d4viddf/hyperbridge/MainActivity.kt
index ace23f1..5316ab1 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/MainActivity.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/MainActivity.kt
@@ -1,20 +1,23 @@
package com.d4viddf.hyperbridge
+import android.content.Context
import android.os.Bundle
-import android.widget.Toast
+import androidx.activity.BackEventCompat
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
-import androidx.compose.animation.AnimatedContent
-import androidx.compose.animation.SizeTransform
+import androidx.compose.animation.EnterTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@@ -29,230 +32,235 @@ 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.graphics.TransformOrigin
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.navigation3.ui.NavDisplay
import com.d4viddf.hyperbridge.data.AppPreferences
import com.d4viddf.hyperbridge.data.db.AppDatabase
-import com.d4viddf.hyperbridge.data.model.HyperBridgeBackup
import com.d4viddf.hyperbridge.ui.components.ChangelogSheet
-import com.d4viddf.hyperbridge.ui.components.PriorityEducationDialog
-import com.d4viddf.hyperbridge.ui.screens.home.HomeScreen
-import com.d4viddf.hyperbridge.ui.screens.onboarding.OnboardingScreen
-import com.d4viddf.hyperbridge.ui.screens.settings.AppPriorityScreen
-import com.d4viddf.hyperbridge.ui.screens.settings.BackupSettingsScreen
-import com.d4viddf.hyperbridge.ui.screens.settings.BlocklistAppListScreen
-import com.d4viddf.hyperbridge.ui.screens.settings.ChangelogHistoryScreen
-import com.d4viddf.hyperbridge.ui.screens.settings.GlobalBlocklistScreen
-import com.d4viddf.hyperbridge.ui.screens.settings.GlobalSettingsScreen
-import com.d4viddf.hyperbridge.ui.screens.settings.ImportPreviewScreen
-import com.d4viddf.hyperbridge.ui.screens.settings.InfoScreen
-import com.d4viddf.hyperbridge.ui.screens.settings.LicensesScreen
-import com.d4viddf.hyperbridge.ui.screens.settings.NavCustomizationScreen
-import com.d4viddf.hyperbridge.ui.screens.settings.PrioritySettingsScreen
-import com.d4viddf.hyperbridge.ui.screens.settings.SetupHealthScreen
+import com.d4viddf.hyperbridge.ui.navigation.Navigator
+import com.d4viddf.hyperbridge.ui.navigation.Screen
+import com.d4viddf.hyperbridge.ui.navigation.mainNavGraph
+import com.d4viddf.hyperbridge.ui.navigation.rememberNavigationState
+import com.d4viddf.hyperbridge.ui.navigation.toEntries
import com.d4viddf.hyperbridge.ui.theme.HyperBridgeTheme
import com.d4viddf.hyperbridge.util.BackupManager
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
+import androidx.activity.enableEdgeToEdge
+
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
setContent {
HyperBridgeTheme {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
- MainRootNavigation()
+ MainRootNavigation(onExit = { finish() })
}
}
}
}
}
-enum class Screen(val depth: Int) {
- ONBOARDING(0), HOME(1), INFO(2), SETUP(3), LICENSES(3), BEHAVIOR(3), GLOBAL_SETTINGS(3), HISTORY(3),
- BACKUP(3), IMPORT_PREVIEW(4), // Backup Flow
- NAV_CUSTOMIZATION(4), APP_PRIORITY(4), GLOBAL_BLOCKLIST(4), BLOCKLIST_APPS(5)
-}
-
@Composable
-fun MainRootNavigation() {
+fun MainRootNavigation(onExit: () -> Unit) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val preferences = remember { AppPreferences(context) }
- // --- 1. INITIALIZE DB & MANAGER ---
- val database = remember { AppDatabase.getDatabase(context) }
- val backupManager = remember { BackupManager(context, preferences, database) }
+ // Let's use lazy delegates
+ val database by remember { lazy { AppDatabase.getDatabase(context) } }
+ val backupManager by remember { lazy { BackupManager(context, preferences, database) } }
- val packageInfo = remember { try { context.packageManager.getPackageInfo(context.packageName, 0) } catch (e: Exception) { null } }
+ val packageInfo by androidx.compose.runtime.produceState(initialValue = null) {
+ value = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
+ try { context.packageManager.getPackageInfo(context.packageName, 0) } catch (_: Exception) { null }
+ }
+ }
@Suppress("DEPRECATION")
val currentVersionCode = packageInfo?.longVersionCode?.toInt() ?: 0
- val currentVersionName = packageInfo?.versionName ?: "0.4.2"
+ val currentVersionName = packageInfo?.versionName ?: "0.5.0"
- // --- 2. ROBUST DATA COLLECTION ---
val isSetupComplete by produceState(initialValue = null) {
preferences.isSetupComplete.collect { value = it }
}
- val lastSeenVersion by preferences.lastSeenVersion.collectAsState(initial = currentVersionCode)
- val isPriorityEduShown by preferences.isPriorityEduShown.collectAsState(initial = true)
+ if (isSetupComplete == null) {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator()
+ }
+ } else {
+ androidx.compose.runtime.key(isSetupComplete) {
+ MainNavigationContent(
+ isSetupComplete = isSetupComplete!!,
+ context = context,
+ scope = scope,
+ preferences = preferences,
+ backupManager = backupManager,
+ currentVersionCode = currentVersionCode,
+ currentVersionName = currentVersionName,
+ onExit = onExit
+ )
+ }
+ }
+}
+
+@Composable
+private fun MainNavigationContent(
+ isSetupComplete: Boolean,
+ context: Context,
+ scope: CoroutineScope,
+ preferences: AppPreferences,
+ backupManager: BackupManager,
+ currentVersionCode: Int,
+ currentVersionName: String,
+ onExit: () -> Unit
+) {
+ val isInitiallySetup = remember { isSetupComplete }
+ val lastSeenVersion by preferences.lastSeenVersion.collectAsState(initial = -1)
- var currentScreen by remember { mutableStateOf(null) }
var showChangelog by remember { mutableStateOf(false) }
- var showPriorityEdu by remember { mutableStateOf(false) }
- var navConfigPackage by remember { mutableStateOf(null) }
+
+ // Check for Troubleshoot Intent
+ val activity = context as? AppCompatActivity
+ val shouldOpenTroubleshoot = activity?.intent?.getBooleanExtra("open_troubleshoot", false) ?: false
+ var showTroubleshootDialog by remember { mutableStateOf(shouldOpenTroubleshoot) }
- // State to hold the parsed backup file before restoring
- var pendingImportBackup by remember { mutableStateOf(null) }
+ val initialStartRoute = remember(isSetupComplete) { if (isSetupComplete) Screen.Home else Screen.Onboarding }
+ val allPossibleTopLevel = remember(isSetupComplete) { setOf(Screen.Onboarding, Screen.Home) }
- // --- 3. ROUTING LOGIC ---
- LaunchedEffect(isSetupComplete) {
- if (isSetupComplete != null) {
- if (currentScreen == null) {
- currentScreen = if (isSetupComplete == true) Screen.HOME else Screen.ONBOARDING
- }
+ val navigationState = rememberNavigationState(
+ startRoute = initialStartRoute,
+ topLevelRoutes = allPossibleTopLevel
+ )
+ val navigator = remember(navigationState) { Navigator(navigationState) }
- if (isSetupComplete == true) {
- if (currentVersionCode > lastSeenVersion) {
- showChangelog = true
- } else if (!isPriorityEduShown && !showChangelog) {
- showPriorityEdu = true
- }
+ LaunchedEffect(isSetupComplete, lastSeenVersion) {
+ if (isSetupComplete && isInitiallySetup && lastSeenVersion != -1) {
+ if (currentVersionCode > lastSeenVersion) {
+ showChangelog = true
}
}
}
- // --- 4. BACK HANDLER ---
- BackHandler(enabled = currentScreen != Screen.HOME && currentScreen != Screen.ONBOARDING) {
- currentScreen = when (currentScreen) {
- Screen.IMPORT_PREVIEW -> Screen.BACKUP
- Screen.BACKUP -> Screen.INFO
- Screen.BLOCKLIST_APPS -> Screen.GLOBAL_BLOCKLIST
- Screen.GLOBAL_BLOCKLIST -> Screen.INFO
- Screen.NAV_CUSTOMIZATION -> if (navConfigPackage != null) Screen.HOME else Screen.GLOBAL_SETTINGS
- Screen.GLOBAL_SETTINGS -> Screen.INFO
- Screen.APP_PRIORITY -> Screen.BEHAVIOR
- Screen.HISTORY -> Screen.INFO
- Screen.BEHAVIOR, Screen.SETUP, Screen.LICENSES -> Screen.INFO
- Screen.INFO -> Screen.HOME
- else -> Screen.HOME
- }
- }
+ val entryProvider = mainNavGraph(
+ context = context,
+ scope = scope,
+ preferences = preferences,
+ navigator = navigator,
+ backupManager = backupManager,
+ currentVersionCode = currentVersionCode,
+ onExit = onExit
+ )
- // --- 5. RENDER SCREENS ---
- if (isSetupComplete == null || currentScreen == null) {
- Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
- CircularProgressIndicator()
+ BackHandler {
+ if (!navigator.goBack()) {
+ onExit()
}
- } else {
- AnimatedContent(
- targetState = currentScreen!!,
- transitionSpec = {
- if (targetState.depth > initialState.depth) {
- (slideInHorizontally { width -> width } + fadeIn(tween(400))).togetherWith(slideOutHorizontally { width -> -width / 3 } + fadeOut(tween(400)))
- } else {
- (slideInHorizontally { width -> -width } + fadeIn(tween(400))).togetherWith(slideOutHorizontally { width -> width / 3 } + fadeOut(tween(400)))
- } using SizeTransform(clip = false)
- },
- label = "ScreenTransition"
- ) { target ->
- when (target) {
- Screen.ONBOARDING -> OnboardingScreen {
- scope.launch {
- preferences.setSetupComplete(true)
- preferences.setLastSeenVersion(currentVersionCode)
- preferences.setPriorityEduShown(true)
- currentScreen = Screen.HOME
- }
- }
- Screen.HOME -> HomeScreen(
- onSettingsClick = { currentScreen = Screen.INFO },
- onNavConfigClick = { pkg -> navConfigPackage = pkg; currentScreen = Screen.NAV_CUSTOMIZATION }
- )
- Screen.INFO -> InfoScreen(
- onBack = { currentScreen = Screen.HOME },
- onSetupClick = { currentScreen = Screen.SETUP },
- onLicensesClick = { currentScreen = Screen.LICENSES },
- onBehaviorClick = { currentScreen = Screen.BEHAVIOR },
- onGlobalSettingsClick = { currentScreen = Screen.GLOBAL_SETTINGS },
- onHistoryClick = { currentScreen = Screen.HISTORY },
- onBlocklistClick = { currentScreen = Screen.GLOBAL_BLOCKLIST },
- onBackupClick = { currentScreen = Screen.BACKUP }
- )
- Screen.GLOBAL_SETTINGS -> GlobalSettingsScreen(onBack = { currentScreen = Screen.INFO }, onNavSettingsClick = { navConfigPackage = null; currentScreen = Screen.NAV_CUSTOMIZATION })
- Screen.NAV_CUSTOMIZATION -> NavCustomizationScreen(onBack = { currentScreen = if (navConfigPackage != null) Screen.HOME else Screen.GLOBAL_SETTINGS }, packageName = navConfigPackage)
- Screen.SETUP -> SetupHealthScreen(onBack = { currentScreen = Screen.INFO })
- Screen.LICENSES -> LicensesScreen(onBack = { currentScreen = Screen.INFO })
- Screen.BEHAVIOR -> PrioritySettingsScreen(onBack = { currentScreen = Screen.INFO }, onNavigateToPriorityList = { currentScreen = Screen.APP_PRIORITY })
- Screen.APP_PRIORITY -> AppPriorityScreen(onBack = { currentScreen = Screen.BEHAVIOR })
- Screen.HISTORY -> ChangelogHistoryScreen(onBack = { currentScreen = Screen.INFO })
-
- Screen.GLOBAL_BLOCKLIST -> GlobalBlocklistScreen(
- onBack = { currentScreen = Screen.INFO },
- onNavigateToAppList = { currentScreen = Screen.BLOCKLIST_APPS }
- )
- Screen.BLOCKLIST_APPS -> BlocklistAppListScreen(
- onBack = { currentScreen = Screen.GLOBAL_BLOCKLIST }
- )
-
- // --- BACKUP FLOW ---
- Screen.BACKUP -> BackupSettingsScreen(
- onBack = { currentScreen = Screen.INFO },
- backupManager = backupManager,
- onBackupFileLoaded = { backup ->
- pendingImportBackup = backup
- currentScreen = Screen.IMPORT_PREVIEW
- }
- )
+ }
- Screen.IMPORT_PREVIEW -> {
- if (pendingImportBackup != null) {
- ImportPreviewScreen(
- backupData = pendingImportBackup!!,
- onBack = { currentScreen = Screen.BACKUP },
- onConfirmRestore = { selection ->
- scope.launch {
- val result = backupManager.restoreBackup(pendingImportBackup!!, selection)
- if (result.isSuccess) {
- Toast.makeText(context, context.getString(R.string.import_success), Toast.LENGTH_LONG).show()
- // Force restart navigation to reflect changes immediately
- currentScreen = Screen.HOME
- } else {
- Toast.makeText(context, context.getString(R.string.import_failed, result.exceptionOrNull()?.message), Toast.LENGTH_LONG).show()
- }
- }
- }
- )
- } else {
- // Fallback logic
- LaunchedEffect(Unit) { currentScreen = Screen.BACKUP }
- }
- }
+ NavDisplay(
+ entries = navigationState.toEntries(entryProvider),
+ onBack = {
+ if (!navigator.goBack()) {
+ onExit()
}
- }
- }
+ },
+ transitionSpec = {
+ slideInHorizontally(
+ initialOffsetX = { it },
+ animationSpec = tween(400)
+ ) + fadeIn(animationSpec = tween(400)) togetherWith slideOutHorizontally(
+ targetOffsetX = { -it / 3 },
+ animationSpec = tween(400)
+ ) + fadeOut(animationSpec = tween(400))
+ },
+ popTransitionSpec = {
+ slideInHorizontally(
+ initialOffsetX = { -it / 3 },
+ animationSpec = tween(400)
+ ) + fadeIn(animationSpec = tween(400)) togetherWith slideOutHorizontally(
+ targetOffsetX = { it },
+ animationSpec = tween(400)
+ ) + fadeOut(animationSpec = tween(400))
+ },
+ predictivePopTransitionSpec = { swipeEdge ->
+ val origin = if (swipeEdge == BackEventCompat.EDGE_LEFT) {
+ TransformOrigin(0.92f, 0.5f)
+ } else {
+ TransformOrigin(0.08f, 0.5f)
+ }
+ EnterTransition.None togetherWith (
+ scaleOut(
+ targetScale = 0.86f,
+ transformOrigin = origin
+ ) + slideOutHorizontally(
+ targetOffsetX = { if (swipeEdge == BackEventCompat.EDGE_LEFT) -it / 30 else it / 30 }
+ ) + fadeOut()
+ )
+ },
+ modifier = Modifier
+ .fillMaxSize()
+ .clip(RoundedCornerShape(32.dp))
+ )
if (showChangelog) {
- ChangelogSheet (
+ ChangelogSheet(
currentVersionName = currentVersionName,
- changelogText = stringResource(R.string.changelog_0_4_0),
+ changelogText = stringResource(R.string.changelog_0_5_5),
onDismiss = {
showChangelog = false
scope.launch {
preferences.setLastSeenVersion(currentVersionCode)
- if (!isPriorityEduShown) showPriorityEdu = true
}
}
)
}
- if (showPriorityEdu) {
- PriorityEducationDialog(
- onDismiss = { showPriorityEdu = false; scope.launch { preferences.setPriorityEduShown(true) } },
- onConfigure = {
- showPriorityEdu = false
- scope.launch { preferences.setPriorityEduShown(true) }
- currentScreen = Screen.BEHAVIOR
+ if (showTroubleshootDialog) {
+ androidx.compose.material3.AlertDialog(
+ onDismissRequest = { showTroubleshootDialog = false },
+ title = {
+ androidx.compose.material3.Text(stringResource(R.string.featured_notifications_troubleshoot_title))
+ },
+ text = {
+ androidx.compose.foundation.layout.Column {
+ androidx.compose.material3.Text(stringResource(R.string.featured_notifications_troubleshoot_desc))
+ androidx.compose.foundation.layout.Spacer(Modifier.height(16.dp))
+ androidx.compose.material3.Text(
+ stringResource(R.string.featured_notifications_shizuku_alternative),
+ style = MaterialTheme.typography.titleSmall
+ )
+ androidx.compose.foundation.layout.Spacer(Modifier.height(4.dp))
+ androidx.compose.material3.Text(
+ stringResource(R.string.featured_notifications_shizuku_desc),
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ },
+ confirmButton = {
+ androidx.compose.material3.TextButton(
+ onClick = {
+ val intent = android.content.Intent(android.provider.Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
+ putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, context.packageName)
+ }
+ context.startActivity(intent)
+ showTroubleshootDialog = false
+ }
+ ) {
+ androidx.compose.material3.Text(stringResource(R.string.featured_notifications_open_settings))
+ }
+ },
+ dismissButton = {
+ androidx.compose.material3.TextButton(onClick = { showTroubleshootDialog = false }) {
+ androidx.compose.material3.Text(stringResource(android.R.string.ok))
+ }
}
)
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/data/AppCacheManager.kt b/app/src/main/java/com/d4viddf/hyperbridge/data/AppCacheManager.kt
index e53bd1d..4f87191 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/data/AppCacheManager.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/data/AppCacheManager.kt
@@ -11,7 +11,7 @@ import androidx.core.content.edit
/**
* Persists App Names and Icons so they can be displayed even after the app is uninstalled.
*/
-class AppCacheManager(private val context: Context) {
+class AppCacheManager(context: Context) {
private val prefs = context.getSharedPreferences("app_metadata_cache", Context.MODE_PRIVATE)
private val iconsDir = File(context.filesDir, "cached_icons").apply { mkdirs() }
@@ -24,7 +24,7 @@ class AppCacheManager(private val context: Context) {
if (icon != null) {
try {
val file = File(iconsDir, "$packageName.png")
- // Only save if it doesn't exist or we want to update it (optional optimization)
+ // Only save if it doesn't exist, or we want to update it (optional optimization)
if (!file.exists()) {
FileOutputStream(file).use { out ->
icon.compress(Bitmap.CompressFormat.PNG, 100, out)
@@ -45,7 +45,7 @@ class AppCacheManager(private val context: Context) {
return if (file.exists()) {
try {
BitmapFactory.decodeFile(file.absolutePath)
- } catch (e: Exception) {
+ } catch (_: Exception) {
null
}
} else {
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/data/AppPreferences.kt b/app/src/main/java/com/d4viddf/hyperbridge/data/AppPreferences.kt
index abbdf7f..0300022 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/data/AppPreferences.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/data/AppPreferences.kt
@@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
+import java.util.concurrent.ConcurrentHashMap
private val Context.legacyDataStore: DataStore by preferencesDataStore(name = "settings")
@@ -30,10 +31,35 @@ class AppPreferences(context: Context) {
private val dao = AppDatabase.getDatabase(context).settingsDao()
private val legacyDataStore = context.applicationContext.legacyDataStore
+ private val memoryCache = ConcurrentHashMap()
+
init {
+ // --- MEMORY CACHE LOGIC ---
+ CoroutineScope(Dispatchers.IO).launch {
+ dao.getAllFlow().collect { list ->
+ val newCache = ConcurrentHashMap()
+ list.forEach { newCache[it.key] = it.value }
+ memoryCache.clear()
+ memoryCache.putAll(newCache)
+ }
+ }
+
// --- MIGRATION LOGIC ---
CoroutineScope(Dispatchers.IO).launch {
try {
+ // Wait for user unlock before attempting to migrate from legacy DataStore (CE storage)
+ val userManager = context.getSystemService(Context.USER_SERVICE) as android.os.UserManager
+ if (!userManager.isUserUnlocked) {
+ return@launch
+ }
+
+ // Force Onboarding reset for new permissions
+ val lastResetVersion = dao.getSetting("onboarding_reset_version")?.toIntOrNull() ?: 0
+ if (lastResetVersion < 19) {
+ dao.insert(AppSetting(SettingsKeys.SETUP_COMPLETE, "false"))
+ dao.insert(AppSetting("onboarding_reset_version", "19"))
+ }
+
val isMigrated = dao.getSetting(SettingsKeys.MIGRATION_COMPLETE) == "true"
if (!isMigrated) {
val legacyPrefs = legacyDataStore.data.first().asMap()
@@ -49,6 +75,64 @@ class AppPreferences(context: Context) {
}
dao.insert(AppSetting(SettingsKeys.MIGRATION_COMPLETE, "true"))
}
+
+ // Grant DOWNLOAD notification type if PROGRESS was previously enabled
+ val isDownloadMigrated = dao.getSetting("download_type_migration_complete") == "true"
+ if (!isDownloadMigrated) {
+ // 1. Global notification types migration
+ val globalTypesStr = dao.getSetting(GLOBAL_NOTIFICATION_TYPES_KEY)
+ if (globalTypesStr != null) {
+ val globalTypes = globalTypesStr.deserializeSet()
+ if (globalTypes.contains("PROGRESS") && !globalTypes.contains("DOWNLOAD")) {
+ val newGlobalTypes = globalTypes + "DOWNLOAD"
+ dao.insert(AppSetting(GLOBAL_NOTIFICATION_TYPES_KEY, newGlobalTypes.serialize()))
+ }
+ }
+
+ // 2. App-specific notification types migration
+ val suffixes = listOf("_float", "_shade", "_timeout", "_float_timeout", "_remove_notif", "_blocked", "_nav_left", "_nav_right", "_use_native")
+ val allSettings = dao.getAllSync()
+ allSettings.forEach { setting ->
+ val key = setting.key
+ if (key.startsWith("config_") && suffixes.none { key.endsWith(it) }) {
+ val types = setting.value.deserializeSet()
+ if (types.contains("PROGRESS") && !types.contains("DOWNLOAD")) {
+ val newTypes = types + "DOWNLOAD"
+ dao.insert(AppSetting(key, newTypes.serialize()))
+ }
+ }
+ }
+
+ dao.insert(AppSetting("download_type_migration_complete", "true"))
+ }
+
+ // Grant DOWNLOAD and MESSAGE to all active apps and globally
+ val isDownloadMessageMigrated = dao.getSetting("download_message_migration_complete") == "true"
+ if (!isDownloadMessageMigrated) {
+ // 1. Global notification types migration
+ val globalTypesStr = dao.getSetting(GLOBAL_NOTIFICATION_TYPES_KEY)
+ if (globalTypesStr != null) {
+ val globalTypes = globalTypesStr.deserializeSet()
+ val newGlobalTypes = globalTypes + "DOWNLOAD" + "MESSAGE"
+ dao.insert(AppSetting(GLOBAL_NOTIFICATION_TYPES_KEY, newGlobalTypes.serialize()))
+ }
+
+ // 2. Active apps migration
+ val allowedPackagesStr = dao.getSetting(SettingsKeys.ALLOWED_PACKAGES)
+ val allowedPackages = allowedPackagesStr.deserializeSet()
+
+ allowedPackages.forEach { packageName ->
+ val key = "config_$packageName"
+ val configStr = dao.getSetting(key)
+ if (configStr != null) {
+ val types = configStr.deserializeSet()
+ val newTypes = types + "DOWNLOAD" + "MESSAGE"
+ dao.insert(AppSetting(key, newTypes.serialize()))
+ }
+ }
+
+ dao.insert(AppSetting("download_message_migration_complete", "true"))
+ }
} catch (e: Exception) {
e.printStackTrace()
}
@@ -76,12 +160,14 @@ class AppPreferences(context: Context) {
val allowedPackagesFlow: Flow> = dao.getSettingFlow(SettingsKeys.ALLOWED_PACKAGES).map { it.deserializeSet() }
val isSetupComplete: Flow = dao.getSettingFlow(SettingsKeys.SETUP_COMPLETE).map { it.toBoolean(false) }
val lastSeenVersion: Flow = dao.getSettingFlow(SettingsKeys.LAST_VERSION).map { it.toInt(0) }
- val isPriorityEduShown: Flow = dao.getSettingFlow(SettingsKeys.PRIORITY_EDU).map { it.toBoolean(false) }
suspend fun setSetupComplete(isComplete: Boolean) = save(SettingsKeys.SETUP_COMPLETE, isComplete.toString())
suspend fun setLastSeenVersion(versionCode: Int) = save(SettingsKeys.LAST_VERSION, versionCode.toString())
suspend fun setPriorityEduShown(shown: Boolean) = save(SettingsKeys.PRIORITY_EDU, shown.toString())
+ val featuredPermissionWarningFlow: Flow = dao.getSettingFlow(SettingsKeys.FEATURED_PERMISSION_WARNING).map { it.toBoolean(false) }
+ suspend fun setFeaturedPermissionWarning(show: Boolean) = save(SettingsKeys.FEATURED_PERMISSION_WARNING, show.toString())
+
suspend fun toggleApp(packageName: String, isEnabled: Boolean) {
val currentString = dao.getSetting(SettingsKeys.ALLOWED_PACKAGES)
val currentSet = currentString.deserializeSet()
@@ -90,13 +176,9 @@ class AppPreferences(context: Context) {
}
// ========================================================================
- // THEME ENGINE (NEW)
+ // THEME ENGINE
// ========================================================================
- /**
- * Holds the ID (folder name) of the currently active theme.
- * Null means the system default (no theme).
- */
val activeThemeIdFlow: Flow = dao.getSettingFlow("active_theme_id")
suspend fun setActiveThemeId(id: String?) {
@@ -107,11 +189,9 @@ class AppPreferences(context: Context) {
}
}
- // ========================================================================
-
// --- LIMITS & PRIORITY ---
val limitModeFlow: Flow = dao.getSettingFlow("limit_mode").map {
- try { IslandLimitMode.valueOf(it ?: IslandLimitMode.MOST_RECENT.name) } catch(e: Exception) { IslandLimitMode.MOST_RECENT }
+ try { IslandLimitMode.valueOf(it ?: IslandLimitMode.MOST_RECENT.name) } catch(_: Exception) { IslandLimitMode.MOST_RECENT }
}
val appPriorityListFlow: Flow> = dao.getSettingFlow(SettingsKeys.PRIORITY_ORDER).map { it.deserializeList() }
@@ -126,14 +206,6 @@ class AppPreferences(context: Context) {
}
}
- suspend fun updateAppConfig(packageName: String, type: NotificationType, isEnabled: Boolean) {
- val key = "config_$packageName"
- val currentStr = dao.getSetting(key)
- val currentSet = currentStr?.deserializeSet() ?: NotificationType.entries.map { it.name }.toSet()
- val newSet = if (isEnabled) currentSet + type.name else currentSet - type.name
- save(key, newSet.serialize())
- }
-
// --- ISLAND CONFIG (Standard Notifications) ---
private fun sanitizeTimeout(raw: Long?): Long {
val value = raw ?: 5L
@@ -143,27 +215,51 @@ class AppPreferences(context: Context) {
val globalConfigFlow: Flow = combine(
dao.getSettingFlow(SettingsKeys.GLOBAL_FLOAT),
dao.getSettingFlow(SettingsKeys.GLOBAL_SHADE),
- dao.getSettingFlow(SettingsKeys.GLOBAL_TIMEOUT)
- ) { f, s, t ->
- IslandConfig(f.toBoolean(true), s.toBoolean(true), t?.toIntOrNull())
+ dao.getSettingFlow(SettingsKeys.GLOBAL_TIMEOUT),
+ dao.getSettingFlow(SettingsKeys.GLOBAL_FLOAT_TIMEOUT),
+ dao.getSettingFlow(SettingsKeys.GLOBAL_REMOVE_NOTIF),
+ dao.getSettingFlow(SettingsKeys.GLOBAL_DISMISS_WITH_ORIGINAL),
+ dao.getSettingFlow(SettingsKeys.GLOBAL_ENABLE_INLINE_REPLY)
+ ) { args: Array ->
+ IslandConfig(
+ args[0].toBoolean(true),
+ args[1].toBoolean(false),
+ args[2]?.toIntOrNull(),
+ args[3]?.toIntOrNull(),
+ args[4]?.toBooleanStrictOrNull(),
+ args[5]?.toBooleanStrictOrNull() ?: true,
+ args[6]?.toBooleanStrictOrNull()
+ )
}
suspend fun updateGlobalConfig(config: IslandConfig) {
config.isFloat?.let { save(SettingsKeys.GLOBAL_FLOAT, it.toString()) }
config.isShowShade?.let { save(SettingsKeys.GLOBAL_SHADE, it.toString()) }
config.timeout?.let { save(SettingsKeys.GLOBAL_TIMEOUT, it.toString()) }
+ config.floatTimeout?.let { save(SettingsKeys.GLOBAL_FLOAT_TIMEOUT, it.toString()) }
+ config.removeOriginalNotification?.let { save(SettingsKeys.GLOBAL_REMOVE_NOTIF, it.toString()) }
+ config.dismissWithOriginal?.let { save(SettingsKeys.GLOBAL_DISMISS_WITH_ORIGINAL, it.toString()) }
+ config.enableInlineReply?.let { save(SettingsKeys.GLOBAL_ENABLE_INLINE_REPLY, it.toString()) }
}
fun getAppIslandConfig(packageName: String): Flow {
return combine(
dao.getSettingFlow("config_${packageName}_float"),
dao.getSettingFlow("config_${packageName}_shade"),
- dao.getSettingFlow("config_${packageName}_timeout")
- ) { f, s, t ->
+ dao.getSettingFlow("config_${packageName}_timeout"),
+ dao.getSettingFlow("config_${packageName}_float_timeout"),
+ dao.getSettingFlow("config_${packageName}_remove_notif"),
+ dao.getSettingFlow("config_${packageName}_dismiss_with_original"),
+ dao.getSettingFlow("config_${packageName}_enable_inline_reply")
+ ) { args: Array ->
IslandConfig(
- f?.toBoolean(),
- s?.toBoolean(),
- t?.toIntOrNull()
+ args[0]?.toBooleanStrictOrNull(),
+ args[1]?.toBooleanStrictOrNull(),
+ args[2]?.toIntOrNull(),
+ args[3]?.toIntOrNull(),
+ args[4]?.toBooleanStrictOrNull(),
+ args[5]?.toBooleanStrictOrNull(),
+ args[6]?.toBooleanStrictOrNull()
)
}
}
@@ -172,10 +268,18 @@ class AppPreferences(context: Context) {
val fKey = "config_${packageName}_float"
val sKey = "config_${packageName}_shade"
val tKey = "config_${packageName}_timeout"
+ val ftKey = "config_${packageName}_float_timeout"
+ val rnKey = "config_${packageName}_remove_notif"
+ val dwoKey = "config_${packageName}_dismiss_with_original"
+ val eirKey = "config_${packageName}_enable_inline_reply"
if (config.isFloat != null) save(fKey, config.isFloat.toString()) else remove(fKey)
if (config.isShowShade != null) save(sKey, config.isShowShade.toString()) else remove(sKey)
if (config.timeout != null) save(tKey, config.timeout.toString()) else remove(tKey)
+ if (config.floatTimeout != null) save(ftKey, config.floatTimeout.toString()) else remove(ftKey)
+ if (config.removeOriginalNotification != null) save(rnKey, config.removeOriginalNotification.toString()) else remove(rnKey)
+ if (config.dismissWithOriginal != null) save(dwoKey, config.dismissWithOriginal.toString()) else remove(dwoKey)
+ if (config.enableInlineReply != null) save(eirKey, config.enableInlineReply.toString()) else remove(eirKey)
}
// --- NAVIGATION ---
@@ -193,8 +297,8 @@ class AppPreferences(context: Context) {
dao.getSettingFlow(SettingsKeys.NAV_LEFT),
dao.getSettingFlow(SettingsKeys.NAV_RIGHT)
) { l, r ->
- val left = try { NavContent.valueOf(l ?: NavContent.DISTANCE_ETA.name) } catch (e: Exception) { NavContent.DISTANCE_ETA }
- val right = try { NavContent.valueOf(r ?: NavContent.INSTRUCTION.name) } catch (e: Exception) { NavContent.INSTRUCTION }
+ val left = try { NavContent.valueOf(l ?: NavContent.DISTANCE_ETA.name) } catch (_: Exception) { NavContent.DISTANCE_ETA }
+ val right = try { NavContent.valueOf(r ?: NavContent.INSTRUCTION.name) } catch (_: Exception) { NavContent.INSTRUCTION }
left to right
}
@@ -208,8 +312,8 @@ class AppPreferences(context: Context) {
dao.getSettingFlow("config_${packageName}_nav_left"),
dao.getSettingFlow("config_${packageName}_nav_right")
) { l, r ->
- val left = l?.let { try { NavContent.valueOf(it) } catch(e: Exception){null} }
- val right = r?.let { try { NavContent.valueOf(it) } catch(e: Exception){null} }
+ val left = l?.let { try { NavContent.valueOf(it) } catch(_: Exception){null} }
+ val right = r?.let { try { NavContent.valueOf(it) } catch(_: Exception){null} }
left to right
}
}
@@ -220,8 +324,8 @@ class AppPreferences(context: Context) {
dao.getSettingFlow("config_${packageName}_nav_right"),
globalNavLayoutFlow
) { appL, appR, global ->
- val left = appL?.let { try { NavContent.valueOf(it) } catch(e: Exception){null} } ?: global.first
- val right = appR?.let { try { NavContent.valueOf(it) } catch(e: Exception){null} } ?: global.second
+ val left = appL?.let { try { NavContent.valueOf(it) } catch(_: Exception){null} } ?: global.first
+ val right = appR?.let { try { NavContent.valueOf(it) } catch(_: Exception){null} } ?: global.second
left to right
}
}
@@ -239,19 +343,11 @@ class AppPreferences(context: Context) {
private val WIDGET_IDS_DB_KEY = "saved_widget_ids_list"
- /**
- * Provides a Flow of all currently saved Widget IDs.
- */
val savedWidgetIdsFlow: Flow> = dao.getSettingFlow(WIDGET_IDS_DB_KEY).map { str ->
str?.split(",")?.mapNotNull { it.toIntOrNull() } ?: emptyList()
}
- /**
- * Gets configuration specific to a single Widget ID.
- * Returns the clean WidgetConfig class.
- */
fun getWidgetConfigFlow(id: Int): Flow {
- // [FIX] 'combine' only supports up to 5 args nicely. For 6+, it returns an Array.
return combine(
dao.getSettingFlow("widget_${id}_shown"),
dao.getSettingFlow("widget_${id}_timeout"),
@@ -260,7 +356,6 @@ class AppPreferences(context: Context) {
dao.getSettingFlow("widget_${id}_auto_update"),
dao.getSettingFlow("widget_${id}_update_interval")
) { args: Array ->
- // Manually unpack the array
val shown = args[0]
val timeout = args[1]
val sizeStr = args[2]
@@ -268,12 +363,12 @@ class AppPreferences(context: Context) {
val autoStr = args[4]
val intervalStr = args[5]
- val sizeEnum = try { WidgetSize.valueOf(sizeStr ?: WidgetSize.MEDIUM.name) } catch (e: Exception) { WidgetSize.MEDIUM }
- val modeEnum = try { WidgetRenderMode.valueOf(modeStr ?: WidgetRenderMode.INTERACTIVE.name) } catch (e: Exception) { WidgetRenderMode.INTERACTIVE }
+ val sizeEnum = try { WidgetSize.valueOf(sizeStr ?: WidgetSize.MEDIUM.name) } catch (_: Exception) { WidgetSize.MEDIUM }
+ val modeEnum = try { WidgetRenderMode.valueOf(modeStr ?: WidgetRenderMode.INTERACTIVE.name) } catch (_: Exception) { WidgetRenderMode.INTERACTIVE }
WidgetConfig(
isShowShade = shown.toBoolean(true),
- timeout = timeout.toInt(5),
+ timeout = timeout.toInt(10),
size = sizeEnum,
renderMode = modeEnum,
autoUpdate = autoStr.toBoolean(false),
@@ -282,9 +377,6 @@ class AppPreferences(context: Context) {
}
}
- /**
- * Saves configuration for a specific widget ID.
- */
suspend fun saveWidgetConfig(
id: Int,
config: WidgetConfig
@@ -328,4 +420,173 @@ class AppPreferences(context: Context) {
val newSet = if (isFavorite) currentSet + packageName else currentSet - packageName
save("favorite_widget_apps", newSet.serialize())
}
+
+ // ========================================================================
+ // Global Notification Types
+ // ========================================================================
+
+ val GLOBAL_NOTIFICATION_TYPES_KEY = "global_notification_types"
+
+ val globalNotificationTypesFlow: Flow> = dao.getSettingFlow(GLOBAL_NOTIFICATION_TYPES_KEY).map { str ->
+ str?.deserializeSet() ?: NotificationType.entries.map { it.name }.toSet()
+ }
+
+ suspend fun updateGlobalNotificationType(type: NotificationType, isEnabled: Boolean) {
+ val currentStr = dao.getSetting(GLOBAL_NOTIFICATION_TYPES_KEY)
+ val currentSet = currentStr?.deserializeSet() ?: NotificationType.entries.map { it.name }.toSet()
+ val newSet = if (isEnabled) currentSet + type.name else currentSet - type.name
+ save(GLOBAL_NOTIFICATION_TYPES_KEY, newSet.serialize())
+ }
+
+ // --- APP-SPECIFIC NOTIFICATION TYPES ---
+
+ fun getAppConfigFlow(packageName: String): Flow?> {
+ val legacyKey = "config_$packageName"
+ return dao.getSettingFlow(legacyKey).map { str ->
+ str?.deserializeSet()
+ }
+ }
+
+ suspend fun updateAppConfig(packageName: String, type: NotificationType, isEnabled: Boolean) {
+ val key = "config_$packageName"
+ val currentStr = dao.getSetting(key)
+ val currentSet = currentStr?.deserializeSet() ?: NotificationType.entries.map { it.name }.toSet()
+ val newSet = if (isEnabled) currentSet + type.name else currentSet - type.name
+ save(key, newSet.serialize())
+ }
+
+ // ========================================================================
+ // THEME ENGINE CONFIGURATION
+ // ========================================================================
+
+ private val USE_NATIVE_ENGINE = "use_native_live_updates"
+ private val IS_SHIZUKU_WORKAROUND_ENABLED = "is_shizuku_workaround_enabled"
+
+ val useNativeLiveUpdates: Flow = dao.getSettingFlow(USE_NATIVE_ENGINE)
+ .map { it?.toBoolean() ?: false }
+
+ val isShizukuWorkaroundEnabled: Flow = dao.getSettingFlow(IS_SHIZUKU_WORKAROUND_ENABLED)
+ .map { it?.toBoolean() ?: false }
+
+ suspend fun setUseNativeLiveUpdates(value: Boolean) {
+ save(USE_NATIVE_ENGINE, value.toString())
+ }
+
+ suspend fun setShizukuWorkaroundEnabled(value: Boolean) {
+ save(IS_SHIZUKU_WORKAROUND_ENABLED, value.toString())
+ }
+
+ // ========================================================================
+ // DND / GAME MODE CONFIGURATION
+ // ========================================================================
+
+ val isDndModeEnabledFlow: Flow = dao.getSettingFlow("dnd_mode_enabled").map { it.toBoolean(false) }
+ suspend fun setDndModeEnabled(isEnabled: Boolean) = save("dnd_mode_enabled", isEnabled.toString())
+
+ val autoDetectDndFlow: Flow = dao.getSettingFlow("auto_detect_dnd").map { it.toBoolean(false) }
+ suspend fun setAutoDetectDnd(autoDetect: Boolean) = save("auto_detect_dnd", autoDetect.toString())
+
+ // --- APP-SPECIFIC ENGINE OVERRIDES ---
+
+ fun getAppEnginePreferenceFlow(packageName: String): Flow {
+ val key = "config_${packageName}_use_native"
+ return dao.getSettingFlow(key).map { it?.toBooleanStrictOrNull() }
+ }
+
+ suspend fun updateAppEnginePreference(packageName: String, useNative: Boolean?) {
+ val key = "config_${packageName}_use_native"
+ if (useNative != null) {
+ save(key, useNative.toString())
+ } else {
+ remove(key)
+ }
+ }
+
+ // ========================================================================
+ // PERMANENT ISLAND CONFIGURATION
+ // ========================================================================
+
+ private val SHOW_PERMANENT_ISLAND = "show_permanent_island"
+ private val PERMANENT_ISLAND_WIDTH = "permanent_island_width"
+
+ val isPermanentIslandEnabledFlow: Flow = dao.getSettingFlow(SHOW_PERMANENT_ISLAND)
+ .map { it?.toBoolean() ?: false }
+
+ val permanentIslandWidthFlow: Flow = dao.getSettingFlow(PERMANENT_ISLAND_WIDTH)
+ .map { it?.toIntOrNull() ?: 0 }
+
+ suspend fun setPermanentIslandEnabled(value: Boolean) {
+ save(SHOW_PERMANENT_ISLAND, value.toString())
+ }
+
+ suspend fun setPermanentIslandWidth(value: Int) {
+ save(PERMANENT_ISLAND_WIDTH, value.toString())
+ }
+
+ // ========================================================================
+ // SYNCHRONOUS CACHE GETTERS
+ // ========================================================================
+
+ fun getAppBlockedTermsSync(packageName: String): Set {
+ return memoryCache["config_${packageName}_blocked"].deserializeSet()
+ }
+
+ fun getAppIslandConfigSync(packageName: String): IslandConfig {
+ return IslandConfig(
+ memoryCache["config_${packageName}_float"]?.toBooleanStrictOrNull(),
+ memoryCache["config_${packageName}_shade"]?.toBooleanStrictOrNull(),
+ memoryCache["config_${packageName}_timeout"]?.toIntOrNull(),
+ memoryCache["config_${packageName}_float_timeout"]?.toIntOrNull(),
+ memoryCache["config_${packageName}_remove_notif"]?.toBooleanStrictOrNull(),
+ memoryCache["config_${packageName}_dismiss_with_original"]?.toBooleanStrictOrNull(),
+ memoryCache["config_${packageName}_enable_inline_reply"]?.toBooleanStrictOrNull()
+ )
+ }
+
+ fun getGlobalConfigSync(): IslandConfig {
+ return IslandConfig(
+ memoryCache[SettingsKeys.GLOBAL_FLOAT].toBoolean(true),
+ memoryCache[SettingsKeys.GLOBAL_SHADE].toBoolean(false),
+ memoryCache[SettingsKeys.GLOBAL_TIMEOUT]?.toIntOrNull(),
+ memoryCache[SettingsKeys.GLOBAL_FLOAT_TIMEOUT]?.toIntOrNull(),
+ memoryCache[SettingsKeys.GLOBAL_REMOVE_NOTIF]?.toBooleanStrictOrNull(),
+ memoryCache[SettingsKeys.GLOBAL_DISMISS_WITH_ORIGINAL]?.toBooleanStrictOrNull() ?: true,
+ memoryCache[SettingsKeys.GLOBAL_ENABLE_INLINE_REPLY]?.toBooleanStrictOrNull()
+ )
+ }
+
+ fun getGlobalNavLayoutSync(): Pair {
+ val l = memoryCache[SettingsKeys.NAV_LEFT]
+ val r = memoryCache[SettingsKeys.NAV_RIGHT]
+ val left = try { NavContent.valueOf(l ?: NavContent.DISTANCE_ETA.name) } catch (_: Exception) { NavContent.DISTANCE_ETA }
+ val right = try { NavContent.valueOf(r ?: NavContent.INSTRUCTION.name) } catch (_: Exception) { NavContent.INSTRUCTION }
+ return left to right
+ }
+
+ fun getEffectiveNavLayoutSync(packageName: String): Pair {
+ val appL = memoryCache["config_${packageName}_nav_left"]
+ val appR = memoryCache["config_${packageName}_nav_right"]
+ val global = getGlobalNavLayoutSync()
+ val left = appL?.let { try { NavContent.valueOf(it) } catch(_: Exception){null} } ?: global.first
+ val right = appR?.let { try { NavContent.valueOf(it) } catch(_: Exception){null} } ?: global.second
+ return left to right
+ }
+
+ fun getGlobalNotificationTypesSync(): Set {
+ val str = memoryCache[GLOBAL_NOTIFICATION_TYPES_KEY]
+ return str?.deserializeSet() ?: NotificationType.entries.map { it.name }.toSet()
+ }
+
+ fun getAppConfigSync(packageName: String): Set? {
+ val str = memoryCache["config_$packageName"]
+ return str?.deserializeSet()
+ }
+
+ fun getAppEnginePreferenceSync(packageName: String): Boolean? {
+ return memoryCache["config_${packageName}_use_native"]?.toBooleanStrictOrNull()
+ }
+
+ fun useNativeLiveUpdatesSync(): Boolean {
+ return memoryCache[USE_NATIVE_ENGINE]?.toBoolean() ?: false
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/data/ConfigResolver.kt b/app/src/main/java/com/d4viddf/hyperbridge/data/ConfigResolver.kt
new file mode 100644
index 0000000..afce313
--- /dev/null
+++ b/app/src/main/java/com/d4viddf/hyperbridge/data/ConfigResolver.kt
@@ -0,0 +1,68 @@
+package com.d4viddf.hyperbridge.data
+
+import android.content.Context
+import com.d4viddf.hyperbridge.data.theme.ThemeRepository
+import com.d4viddf.hyperbridge.models.NavContent
+import com.d4viddf.hyperbridge.models.theme.HyperTheme
+import com.d4viddf.hyperbridge.models.theme.NavigationModule
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.stateIn
+
+/**
+ * A fast, in-memory resolver for Translators to look up effective
+ * configurations without blocking the main thread.
+ */
+class ConfigResolver(context: Context) {
+
+ private val preferences = AppPreferences(context)
+ private val themeRepo = ThemeRepository(context)
+
+ // Keep a hot flow of the active theme in memory
+ private val activeTheme: StateFlow = themeRepo.activeTheme
+
+ /**
+ * Data class holding the exact UI parameters a Translator needs to render the Island
+ */
+ data class ResolvedNavConfig(
+ val useNativeEngine: Boolean,
+ val leftContent: NavContent,
+ val rightContent: NavContent,
+ val themeVisuals: NavigationModule?
+ )
+
+ /**
+ * Call this from your NavTranslator when a notification arrives.
+ * It instantly calculates the result based on in-memory state.
+ */
+ suspend fun resolveNavConfig(packageName: String): ResolvedNavConfig {
+ val theme = activeTheme.value
+ val themeAppOverride = theme?.apps?.get(packageName)
+
+ // 1. Resolve Engine
+ val effectiveEngine = themeAppOverride?.useNativeLiveUpdates
+ ?: preferences.useNativeLiveUpdates.stateIn(CoroutineScope(Dispatchers.IO), SharingStarted.Eagerly, true).value
+
+ // 2. Resolve Visuals
+ val effectiveVisuals = themeAppOverride?.navigation
+
+ // 3. Resolve Content (A bit tricky as it requires reading DB, but we do it fast)
+ // Note: For absolute zero-latency, you might want to cache globalNavLayoutFlow in memory too!
+ var leftContent = NavContent.DISTANCE_ETA
+ var rightContent = NavContent.INSTRUCTION
+
+ preferences.getEffectiveNavLayout(packageName).collect { (l, r) ->
+ leftContent = l
+ rightContent = r
+ }
+
+ return ResolvedNavConfig(
+ useNativeEngine = effectiveEngine,
+ leftContent = leftContent,
+ rightContent = rightContent,
+ themeVisuals = effectiveVisuals
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/data/db/AppDatabase.kt b/app/src/main/java/com/d4viddf/hyperbridge/data/db/AppDatabase.kt
index a7e2c7b..f75920c 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/data/db/AppDatabase.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/data/db/AppDatabase.kt
@@ -14,18 +14,48 @@ abstract class AppDatabase : RoomDatabase() {
private var INSTANCE: AppDatabase? = null
fun getDatabase(context: Context): AppDatabase {
+ val storageContext = context.createDeviceProtectedStorageContext()
+ val dbName = "hyperbridge_db"
+
+ // Migration logic: Move DB from CE to DE storage if it exists in old location
+ if (!storageContext.getDatabasePath(dbName).exists()) {
+ val oldDb = context.getDatabasePath(dbName)
+ if (oldDb.exists()) {
+ storageContext.moveDatabaseFrom(context, dbName)
+ }
+ }
+
return INSTANCE ?: synchronized(this) {
- val instance = Room.databaseBuilder(
- context.applicationContext,
+ INSTANCE ?: Room.databaseBuilder(
+ storageContext,
AppDatabase::class.java,
- "hyperbridge_db"
+ dbName
)
- // Allow main thread queries for migration check (fast) if needed,
- // but we will use coroutines.
.fallbackToDestructiveMigration(false)
- .build()
- INSTANCE = instance
- instance
+ .build().also { INSTANCE = it }
+ }
+ }
+
+ fun performMigration(context: Context, onProgress: (Int) -> Unit) {
+ val storageContext = context.createDeviceProtectedStorageContext()
+ val dbName = "hyperbridge_db"
+
+ if (!storageContext.getDatabasePath(dbName).exists()) {
+ val oldDb = context.getDatabasePath(dbName)
+ if (oldDb.exists()) {
+ onProgress(10)
+ try {
+ storageContext.moveDatabaseFrom(context, dbName)
+ onProgress(100)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ onProgress(-1) // Error state
+ }
+ } else {
+ onProgress(100) // Already in DE or fresh install
+ }
+ } else {
+ onProgress(100) // Already in DE
}
}
}
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/data/db/AppSettings.kt b/app/src/main/java/com/d4viddf/hyperbridge/data/db/AppSettings.kt
index 1c43a10..9d7b9eb 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/data/db/AppSettings.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/data/db/AppSettings.kt
@@ -19,11 +19,16 @@ object SettingsKeys {
const val PRIORITY_EDU = "priority_edu_shown"
const val ALLOWED_PACKAGES = "allowed_packages"
const val PRIORITY_ORDER = "priority_app_order"
+ const val FEATURED_PERMISSION_WARNING = "featured_permission_warning"
// Global Configs
const val GLOBAL_FLOAT = "global_float"
const val GLOBAL_SHADE = "global_shade"
const val GLOBAL_TIMEOUT = "global_timeout"
+ const val GLOBAL_FLOAT_TIMEOUT = "global_float_timeout"
+ const val GLOBAL_REMOVE_NOTIF = "global_remove_original_notif"
+ const val GLOBAL_DISMISS_WITH_ORIGINAL = "global_dismiss_with_original"
+ const val GLOBAL_ENABLE_INLINE_REPLY = "global_enable_inline_reply"
const val GLOBAL_BLOCKED_TERMS = "global_blocked_terms"
// Nav
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/data/db/SettingsDAO.kt b/app/src/main/java/com/d4viddf/hyperbridge/data/db/SettingsDAO.kt
index a743c6e..595f8a4 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/data/db/SettingsDAO.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/data/db/SettingsDAO.kt
@@ -23,6 +23,9 @@ interface SettingsDao {
@Query("DELETE FROM settings WHERE `key` = :key")
suspend fun delete(key: String)
+ @Query("SELECT * FROM settings")
+ fun getAllFlow(): Flow>
+
@Query("SELECT * FROM settings")
suspend fun getAllSync(): List // Needed for Export
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/data/model/BackupModels.kt b/app/src/main/java/com/d4viddf/hyperbridge/data/model/BackupModels.kt
index 622d642..e9fe3c2 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/data/model/BackupModels.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/data/model/BackupModels.kt
@@ -1,15 +1,18 @@
package com.d4viddf.hyperbridge.data.model
import com.google.gson.annotations.SerializedName
+import kotlinx.serialization.Serializable
/**
* Root Container
*/
+@Serializable
data class HyperBridgeBackup(
@SerializedName("metadata") val metadata: BackupMetadata,
@SerializedName("settings") val settings: List
)
+@Serializable
data class BackupMetadata(
@SerializedName("package_name") val packageName: String,
@SerializedName("version_code") val versionCode: Int,
@@ -18,7 +21,8 @@ data class BackupMetadata(
@SerializedName("device_model") val deviceModel: String
)
+@Serializable
data class AppSettingBackup(
@SerializedName("key") val key: String,
@SerializedName("value") val value: String
-)
\ No newline at end of file
+)
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/data/theme/RulesEngine.kt b/app/src/main/java/com/d4viddf/hyperbridge/data/theme/RulesEngine.kt
index 318c9b7..391e133 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/data/theme/RulesEngine.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/data/theme/RulesEngine.kt
@@ -70,7 +70,7 @@ class RulesEngine {
// 4. External State (Plugins) - Placeholder for now
if (!cond.externalStateKey.isNullOrEmpty()) {
- // In future: Check against a StateManager.currentState map
+ // In the future: Check against a StateManager.currentState map
// For now, if a rule requires external state, we fail it safely
return false
}
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/data/theme/ThemeRepository.kt b/app/src/main/java/com/d4viddf/hyperbridge/data/theme/ThemeRepository.kt
index e8f52e5..c951ba8 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/data/theme/ThemeRepository.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/data/theme/ThemeRepository.kt
@@ -23,7 +23,7 @@ import java.util.zip.ZipInputStream
class ThemeRepository(private val context: Context) {
- private val TAG = "HyperBridgeTheme"
+ private val tag = "HyperBridgeTheme"
private val json = Json { ignoreUnknownKeys = true; prettyPrint = true }
private val _activeTheme = MutableStateFlow(null)
@@ -46,13 +46,13 @@ class ThemeRepository(private val context: Context) {
val content = themeFile.readText()
val theme = json.decodeFromString(content)
_activeTheme.value = theme
- Log.i(TAG, "Theme Activated: ${theme.meta.name}")
+ Log.i(tag, "Theme Activated: ${theme.meta.name}")
} else {
- Log.w(TAG, "Theme file not found: $themeId")
+ Log.w(tag, "Theme file not found: $themeId")
_activeTheme.value = null
}
} catch (e: Exception) {
- Log.e(TAG, "Failed to load theme", e)
+ Log.e(tag, "Failed to load theme", e)
_activeTheme.value = null
}
}
@@ -108,13 +108,13 @@ class ThemeRepository(private val context: Context) {
val content = configFile.readText()
val theme = try {
json.decodeFromString(content)
- } catch (e: Exception) {
+ } catch (_: Exception) {
throw IllegalArgumentException("Invalid JSON structure")
}
// 3. INSTALLATION
// We use the ID defined in JSON, or generate one if missing (shouldn't happen with strict spec)
- val finalId = if (theme.id.isNotEmpty()) theme.id else tempId
+ val finalId = theme.id.ifEmpty { tempId }
val targetDir = File(themesDir, finalId)
// If updating, clear old version
@@ -129,11 +129,11 @@ class ThemeRepository(private val context: Context) {
tempDir.deleteRecursively()
}
- Log.i(TAG, "Theme Installed Successfully: $finalId")
+ Log.i(tag, "Theme Installed Successfully: $finalId")
return@withContext finalId
} catch (e: Exception) {
- Log.e(TAG, "Installation failed", e)
+ Log.e(tag, "Installation failed", e)
tempDir.deleteRecursively() // Cleanup
throw e
}
@@ -195,7 +195,7 @@ class ThemeRepository(private val context: Context) {
ResourceType.PRESET_DRAWABLE -> null
}
} catch (e: Exception) {
- Log.w(TAG, "Error loading bitmap resource: ${resource.value}", e)
+ Log.w(tag, "Error loading bitmap resource: ${resource.value}", e)
null
}
}
@@ -219,8 +219,8 @@ class ThemeRepository(private val context: Context) {
val theme = json.decodeFromString(content)
list.add(theme)
}
- } catch (e: Exception) {
- Log.e(TAG, "Corrupt theme found in: ${themeFolder.name}")
+ } catch (_: Exception) {
+ Log.e(tag, "Corrupt theme found in: ${themeFolder.name}")
}
}
}
@@ -285,7 +285,7 @@ class ThemeRepository(private val context: Context) {
}
return@withContext zipFile
} catch (e: Exception) {
- Log.e(TAG, "Export failed", e)
+ Log.e(tag, "Export failed", e)
return@withContext null
}
}
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/data/widget/WidgetManager.kt b/app/src/main/java/com/d4viddf/hyperbridge/data/widget/WidgetManager.kt
index a372279..5160144 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/data/widget/WidgetManager.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/data/widget/WidgetManager.kt
@@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
+import androidx.core.graphics.createBitmap
@SuppressLint("StaticFieldLeak")
object WidgetManager {
@@ -28,13 +29,24 @@ object WidgetManager {
private val _widgetUpdates = MutableSharedFlow(replay = 0, extraBufferCapacity = 10)
val widgetUpdates: SharedFlow = _widgetUpdates.asSharedFlow()
+ private var isListening = false
+
fun init(ctx: Context) {
- if (context != null) return
- context = ctx.applicationContext
- appWidgetManager = AppWidgetManager.getInstance(context)
+ if (context == null) {
+ context = ctx.applicationContext
+ appWidgetManager = AppWidgetManager.getInstance(context)
+ appWidgetHost = HyperAppWidgetHost(context!!, HOST_ID)
+ }
- appWidgetHost = HyperAppWidgetHost(context!!, HOST_ID)
- appWidgetHost?.startListening()
+ if (!isListening) {
+ try {
+ appWidgetHost?.startListening()
+ isListening = true
+ } catch (e: IllegalStateException) {
+ // Thrown if the device is not unlocked yet
+ e.printStackTrace()
+ }
+ }
}
// --- ID MANAGEMENT ---
@@ -98,7 +110,7 @@ object WidgetManager {
hostView.layout(0, 0, hostView.measuredWidth, hostView.measuredHeight)
try {
- val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
+ val bitmap = createBitmap(width, height)
val canvas = Canvas(bitmap)
hostView.draw(canvas)
return bitmap
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/integration/shizuku/PrivilegedServiceImpl.kt b/app/src/main/java/com/d4viddf/hyperbridge/integration/shizuku/PrivilegedServiceImpl.kt
new file mode 100644
index 0000000..0987cd6
--- /dev/null
+++ b/app/src/main/java/com/d4viddf/hyperbridge/integration/shizuku/PrivilegedServiceImpl.kt
@@ -0,0 +1,178 @@
+package com.d4viddf.hyperbridge.integration.shizuku
+
+import androidx.annotation.Keep
+import com.d4viddf.hyperbridge.IPrivilegedService
+import com.d4viddf.hyperbridge.IPrivilegedLogCallback
+import android.util.Log
+import android.os.IBinder
+import android.os.IInterface
+import java.lang.reflect.InvocationTargetException
+import android.os.Handler
+import android.os.HandlerThread
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicReference
+
+@Keep
+class PrivilegedServiceImpl : IPrivilegedService.Stub() {
+
+ companion object {
+ private const val TAG = "PrivilegedServiceImpl"
+ private const val OP_TIMEOUT_MS = 3000L
+ private val workerThread: HandlerThread by lazy {
+ HandlerThread("PrivilegedServiceWorker").apply { start() }
+ }
+ private val workerHandler: Handler by lazy { Handler(workerThread.looper) }
+
+ init {
+ try {
+ Log.d(TAG, "⚡ PrivilegedServiceImpl class loaded")
+ } catch (ignored: Exception) {}
+ }
+ }
+
+ init {
+ try {
+ logD("⚡ PrivilegedServiceImpl instance created")
+ } catch (ignored: Exception) {}
+ }
+
+ @Volatile private var logCallback: IPrivilegedLogCallback? = null
+
+ override fun setLogCallback(callback: IPrivilegedLogCallback?) {
+ logCallback = callback
+ logD("Log callback set: ${callback != null}")
+ }
+
+ override fun setPackageNetworkingEnabled(uid: Int, enabled: Boolean): Boolean {
+ logD("🚀 ENTRY: setPackageNetworkingEnabled(uid=$uid, enabled=$enabled)")
+
+ try {
+ val resultRef = AtomicReference?>(null)
+ val latch = CountDownLatch(1)
+
+ workerHandler.post {
+ val result = runCatching {
+ logD("Step 1: Getting ConnectivityManager...")
+ val realCm = getConnectivityManagerInstance()
+ logD("Step 2: Got ConnectivityManager: ${realCm.javaClass.name}")
+
+ // Chain IDs: 9 = FILTER_CHAIN_NAME_STANDBY_ALLOWLIST or similar on some ROMs
+ // On some vendors, 2 or 1 might be used. 9 is most common for firewall.
+ val chain = 9
+
+ logD("Step 3: Calling setFirewallChainEnabled($chain, true)...")
+ // Pass Boolean instead of Integer to match expected 'boolean' type
+ callMethodResilient(realCm, "setFirewallChainEnabled", chain, true)
+ logD("Step 4: setFirewallChainEnabled succeeded")
+
+ val rule = if (enabled) 0 else 2 // 0 = ALLOW, 2 = DENY
+ logD("Step 5: Calling setUidFirewallRule($chain, $uid, $rule)...")
+ callMethodResilient(realCm, "setUidFirewallRule", chain, uid, rule)
+
+ logD("✅ SUCCESS: Firewall rules updated for $uid")
+ true
+ }
+
+ resultRef.set(result)
+ latch.countDown()
+ }
+
+ val completed = latch.await(OP_TIMEOUT_MS, TimeUnit.MILLISECONDS)
+ if (!completed) {
+ logE("❌ FAILURE in setPackageNetworkingEnabled: timeout after ${OP_TIMEOUT_MS}ms")
+ return false
+ }
+
+ val result = resultRef.get() ?: return false
+
+ if (result.isFailure) {
+ val e = result.exceptionOrNull()
+ logE("❌ FAILURE in setPackageNetworkingEnabled: ${e?.javaClass?.name}: ${e?.message}")
+ e?.cause?.let { cause ->
+ logE("❌ Root cause: ${cause.javaClass.name}: ${cause.message}")
+ }
+ e?.printStackTrace()
+ return false
+ }
+
+ return result.getOrDefault(false)
+
+ } catch (e: Throwable) {
+ // ABSOLUTE guard against any crash in the privileged process
+ logE("🔥 CRITICAL ERROR in PrivilegedServiceImpl: ${e.message}")
+ return false
+ } finally {
+ logD("🏁 EXIT: setPackageNetworkingEnabled")
+ }
+ }
+
+ private fun getConnectivityManagerInstance(): Any {
+ val smClass = Class.forName("android.os.ServiceManager")
+ val getService = smClass.getMethod("getService", String::class.java)
+ val binder = getService.invoke(null, "connectivity") as? IBinder
+ ?: throw RuntimeException("connectivity service not found")
+
+ val stubClass = Class.forName("android.net.IConnectivityManager\$Stub")
+ val asInterface = stubClass.getMethod("asInterface", IBinder::class.java)
+ return asInterface.invoke(null, binder) ?: throw RuntimeException("asInterface returned null")
+ }
+
+ /**
+ * More resilient method call that searches for the best matching method
+ */
+ private fun callMethodResilient(obj: Any, methodName: String, vararg args: Any) {
+ val clazz = obj.javaClass
+ val methods = clazz.methods
+
+ // Find a method that matches by name and parameter count
+ val targetMethod = methods.find { it.name == methodName && it.parameterCount == args.size }
+ ?: throw NoSuchMethodException("Could not find method $methodName with ${args.size} params on ${clazz.name}")
+
+ targetMethod.isAccessible = true
+
+ // Ensure arguments match primitive types if needed
+ val finalArgs = Array(args.size) { i ->
+ val paramType = targetMethod.parameterTypes[i]
+ val arg = args[i]
+
+ when {
+ paramType == Int::class.javaPrimitiveType && arg is Int -> arg
+ paramType == Boolean::class.javaPrimitiveType && arg is Boolean -> arg
+ // Force conversion if there's a mismatch (common in reflection)
+ paramType == Boolean::class.javaPrimitiveType && arg is Number -> arg.toInt() != 0
+ paramType == Int::class.javaPrimitiveType && arg is Boolean -> if (arg) 1 else 0
+ else -> arg
+ }
+ }
+
+ try {
+ targetMethod.invoke(obj, *finalArgs)
+ } catch (e: InvocationTargetException) {
+ val cause = e.targetException ?: e.cause
+ if (cause != null) {
+ logW("InvocationTargetException cause: ${cause.javaClass.name}: ${cause.message}")
+ } else {
+ logW("InvocationTargetException with null cause")
+ }
+ throw e
+ }
+ }
+
+ private fun logD(message: String) {
+ Log.d(TAG, message)
+ logCallback?.let { runCatching { it.log(0, TAG, message) } }
+ }
+
+ private fun logW(message: String) {
+ Log.w(TAG, message)
+ logCallback?.let { runCatching { it.log(2, TAG, message) } }
+ }
+
+ private fun logE(message: String) {
+ Log.e(TAG, message)
+ logCallback?.let { runCatching { it.log(3, TAG, message) } }
+ }
+}
+
+
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/integration/shizuku/ShizukuHook.kt b/app/src/main/java/com/d4viddf/hyperbridge/integration/shizuku/ShizukuHook.kt
new file mode 100644
index 0000000..3dcb729
--- /dev/null
+++ b/app/src/main/java/com/d4viddf/hyperbridge/integration/shizuku/ShizukuHook.kt
@@ -0,0 +1,241 @@
+package com.d4viddf.hyperbridge.integration.shizuku
+
+import android.content.Context
+import android.os.IBinder
+import android.util.Log
+import java.lang.reflect.InvocationTargetException
+import java.util.concurrent.ConcurrentHashMap
+import rikka.shizuku.ShizukuBinderWrapper
+import rikka.shizuku.SystemServiceHelper
+
+/**
+ * Minimal "hook mode" helper adapted from InstallerX Revived.
+ *
+ * Instead of hopping through our own Shizuku user service, we wrap the original
+ * system Connectivity binder with [ShizukuBinderWrapper] and invoke the hidden
+ * firewall APIs directly through that hooked binder.
+ */
+object ShizukuHook {
+ private const val TAG = "ShizukuHook"
+ private const val OEM_DENY_CHAIN = 9
+
+ private data class ServiceBackend(
+ val serviceName: String,
+ val stubClassName: String,
+ val label: String
+ )
+
+ private val serviceBackends = listOf(
+ ServiceBackend(
+ serviceName = Context.CONNECTIVITY_SERVICE,
+ stubClassName = "android.net.IConnectivityManager\$Stub",
+ label = "ConnectivityManager"
+ ),
+ ServiceBackend(
+ serviceName = "network_management",
+ stubClassName = "android.os.INetworkManagementService\$Stub",
+ label = "NetworkManagementService"
+ )
+ )
+
+ private val hookedServiceCache = ConcurrentHashMap()
+
+ fun setPackageNetworkingEnabled(uid: Int, enabled: Boolean) {
+ val rule = if (enabled) 0 else 2
+ val failures = mutableListOf()
+
+ try {
+ val cm = getHookedConnectivityManager()
+ if (!enabled) {
+ cm.javaClass.getMethod("setFirewallChainEnabled", Int::class.javaPrimitiveType, Boolean::class.javaPrimitiveType).invoke(cm, OEM_DENY_CHAIN, true)
+ Log.d(TAG, "Enabled firewall chain $OEM_DENY_CHAIN via typed IConnectivityManager before blocking uid=$uid")
+ }
+ cm.javaClass.getMethod("setUidFirewallRule", Int::class.javaPrimitiveType, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType).invoke(cm, OEM_DENY_CHAIN, uid, rule)
+ Log.d(TAG, "Network ${if (enabled) "restored" else "blocked"} for uid=$uid via typed IConnectivityManager")
+ return
+ } catch (t: Throwable) {
+ val detail = "Typed IConnectivityManager failed: ${t.message}"
+ failures += detail
+ Log.w(TAG, detail, t)
+ }
+
+ try {
+ val nm = getHookedNetworkManagementService()
+ if (!enabled) {
+ nm.javaClass.getMethod("setFirewallChainEnabled", Int::class.javaPrimitiveType, Boolean::class.javaPrimitiveType).invoke(nm, OEM_DENY_CHAIN, true)
+ Log.d(TAG, "Enabled firewall chain $OEM_DENY_CHAIN via typed INetworkManagementService before blocking uid=$uid")
+ }
+ try {
+ nm.javaClass.getMethod("setUidFirewallRule", Int::class.javaPrimitiveType, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType).invoke(nm, OEM_DENY_CHAIN, uid, rule)
+ Log.d(TAG, "Network ${if (enabled) "restored" else "blocked"} for uid=$uid via typed INetworkManagementService.setUidFirewallRule")
+ } catch (_: Throwable) {
+ nm.javaClass.getMethod("setFirewallUidRule", Int::class.javaPrimitiveType, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType).invoke(nm, OEM_DENY_CHAIN, uid, rule)
+ Log.d(TAG, "Network ${if (enabled) "restored" else "blocked"} for uid=$uid via typed INetworkManagementService.setFirewallUidRule")
+ }
+ return
+ } catch (t: Throwable) {
+ val detail = "Typed INetworkManagementService failed: ${t.message}"
+ failures += detail
+ Log.w(TAG, detail, t)
+ }
+
+ for (backend in serviceBackends) {
+ try {
+ val service = getHookedService(backend)
+ Log.d(TAG, "Trying ${backend.label} backend for uid=$uid, enabled=$enabled")
+
+ if (!enabled) {
+ callMethodResilient(
+ service,
+ listOf("setFirewallChainEnabled"),
+ OEM_DENY_CHAIN,
+ true
+ )
+ Log.d(TAG, "Enabled firewall chain $OEM_DENY_CHAIN via ${backend.label} before blocking uid=$uid")
+ }
+
+ val methodUsed = callMethodResilient(
+ service,
+ listOf("setUidFirewallRule", "setFirewallUidRule"),
+ OEM_DENY_CHAIN,
+ uid,
+ rule
+ )
+ Log.d(
+ TAG,
+ "Network ${if (enabled) "restored" else "blocked"} for uid=$uid via ${backend.label}.$methodUsed"
+ )
+ return
+ } catch (t: Throwable) {
+ val available = summarizeFirewallMethods(backend)
+ val detail = buildString {
+ append("${backend.label} failed: ${t.message}")
+ if (available.isNotBlank()) {
+ append(" | available=$available")
+ }
+ }
+ failures += detail
+ Log.w(TAG, detail, t)
+ }
+ }
+
+ throw IllegalStateException(
+ "No compatible firewall backend found for uid=$uid. ${failures.joinToString(" || ")}"
+ )
+ }
+
+ private fun getHookedConnectivityManager(): Any {
+ return getHookedService(
+ ServiceBackend(
+ serviceName = Context.CONNECTIVITY_SERVICE,
+ stubClassName = "android.net.IConnectivityManager\$Stub",
+ label = "ConnectivityManager"
+ )
+ )
+ }
+
+ private fun getHookedNetworkManagementService(): Any {
+ return getHookedService(
+ ServiceBackend(
+ serviceName = "network_management",
+ stubClassName = "android.os.INetworkManagementService\$Stub",
+ label = "NetworkManagementService"
+ )
+ )
+ }
+
+ private fun getHookedService(backend: ServiceBackend): Any {
+ hookedServiceCache[backend.stubClassName]?.let { return it }
+
+ return synchronized(this) {
+ hookedServiceCache[backend.stubClassName]?.let { return@synchronized it }
+
+ val originalBinder = SystemServiceHelper.getSystemService(backend.serviceName)
+ ?: throw IllegalStateException("${backend.label} binder is null")
+
+ val wrapper: IBinder = ShizukuBinderWrapper(originalBinder)
+ val stubClass = Class.forName(backend.stubClassName)
+ val asInterface = stubClass.getMethod("asInterface", IBinder::class.java)
+ val hooked = asInterface.invoke(null, wrapper) ?: throw IllegalStateException(
+ "${backend.label}.Stub.asInterface returned null"
+ )
+
+ hookedServiceCache[backend.stubClassName] = hooked
+ Log.i(TAG, "Created hooked ${backend.label} binder wrapper")
+ hooked
+ }
+ }
+
+ private fun callMethodResilient(target: Any, methodNames: List, vararg args: Any): String {
+ val methods = target.javaClass.methods.filter {
+ it.name in methodNames && it.parameterCount == args.size
+ }
+ if (methods.isEmpty()) {
+ throw NoSuchMethodException(
+ "Could not find any of ${methodNames.joinToString()} with ${args.size} args on ${target.javaClass.name}"
+ )
+ }
+
+ var lastError: Throwable? = null
+ for (method in methods) {
+ try {
+ invokeMethod(target, method, args)
+ return method.name
+ } catch (t: Throwable) {
+ lastError = t
+ }
+ }
+
+ throw lastError ?: NoSuchMethodException(
+ "Methods ${methodNames.joinToString()} exist on ${target.javaClass.name} but none accepted the arguments"
+ )
+ }
+
+ private fun invokeMethod(target: Any, method: java.lang.reflect.Method, args: Array) {
+ method.isAccessible = true
+ val adaptedArgs = Array(args.size) { index ->
+ val arg = args[index]
+ when (val expected = method.parameterTypes[index]) {
+ Int::class.javaPrimitiveType -> when (arg) {
+ is Int -> arg
+ is Boolean -> if (arg) 1 else 0
+ is Number -> arg.toInt()
+ else -> throw IllegalArgumentException("Unsupported arg $arg for int parameter")
+ }
+
+ Boolean::class.javaPrimitiveType -> when (arg) {
+ is Boolean -> arg
+ is Number -> arg.toInt() != 0
+ else -> throw IllegalArgumentException("Unsupported arg $arg for boolean parameter")
+ }
+
+ else -> if (expected.isInstance(arg)) arg
+ else throw IllegalArgumentException("Arg $arg does not match ${expected.name}")
+ }
+ }
+
+ try {
+ method.invoke(target, *adaptedArgs)
+ } catch (e: InvocationTargetException) {
+ throw e.targetException ?: e
+ }
+ }
+
+ private fun summarizeFirewallMethods(backend: ServiceBackend): String {
+ return runCatching {
+ val service = getHookedService(backend)
+ service.javaClass.methods
+ .filter { method ->
+ method.name.contains("Firewall", ignoreCase = true) ||
+ method.name.contains("firewall", ignoreCase = true)
+ }
+ .sortedWith(compareBy({ it.name }, { it.parameterCount }))
+ .joinToString("; ") { method ->
+ val params = method.parameterTypes.joinToString(",") { it.simpleName }
+ "${method.name}($params)"
+ }
+ }.getOrElse { "" }
+ }
+}
+
+
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/integration/shizuku/ShizukuUserServiceRecycler.kt b/app/src/main/java/com/d4viddf/hyperbridge/integration/shizuku/ShizukuUserServiceRecycler.kt
new file mode 100644
index 0000000..6ee652b
--- /dev/null
+++ b/app/src/main/java/com/d4viddf/hyperbridge/integration/shizuku/ShizukuUserServiceRecycler.kt
@@ -0,0 +1,181 @@
+package com.d4viddf.hyperbridge.integration.shizuku
+
+import android.content.ComponentName
+import com.d4viddf.hyperbridge.BuildConfig
+import android.content.Context
+import android.content.ServiceConnection
+import android.os.IBinder
+import com.d4viddf.hyperbridge.IPrivilegedService
+import com.d4viddf.hyperbridge.IPrivilegedLogCallback
+import android.util.Log
+import rikka.shizuku.Shizuku
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withTimeoutOrNull
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+
+object ShizukuUserServiceRecycler {
+
+ private val serviceMutex = Mutex()
+ private var cachedService: IPrivilegedService? = null
+ private var serviceConnection: ServiceConnection? = null
+ private var serviceArgs: Shizuku.UserServiceArgs? = null
+ private var lastPingAttempt = 0L
+ private const val TAG = "ShizukuRecycler"
+ private const val PING_INTERVAL_MS = 5000L // Only ping every 5 seconds to avoid overhead
+ private const val BIND_TIMEOUT_MS = 10000L // 10 second timeout for service binding
+
+
+ @Volatile private var logCallbackEnabled = false
+ private val logCallback = object : IPrivilegedLogCallback.Stub() {
+ override fun log(level: Int, tag: String?, message: String?) {
+ val safeTag = tag ?: "PrivilegedService"
+ val safeMsg = message ?: ""
+ when (level) {
+ 0 -> Log.d(safeTag, safeMsg)
+ 1 -> Log.i(safeTag, safeMsg)
+ 2 -> Log.w(safeTag, safeMsg)
+ 3 -> Log.e(safeTag, safeMsg)
+ else -> Log.d(safeTag, safeMsg)
+ }
+ }
+ }
+
+ /**
+ * Gets or creates a persistent connection to the privileged service.
+ * Uses caching to avoid repeated bind/unbind cycles.
+ * Only validates connection periodically to minimize Binder calls and avoid throttling.
+ */
+ suspend fun getPrivilegedService(): IPrivilegedService {
+ serviceMutex.withLock {
+ cachedService?.let { cached ->
+ val now = System.currentTimeMillis()
+ // Only ping periodically, not on every call, to avoid Binder overhead
+ if (now - lastPingAttempt > PING_INTERVAL_MS) {
+ try {
+ if (cached.asBinder().pingBinder()) {
+ lastPingAttempt = now
+ Log.d(TAG, "Service cache still valid (ping successful)")
+ return cached
+ }
+ } catch (e: Exception) {
+ // Binder is dead, will reinitialize below
+ Log.w(TAG, "Service ping failed, rebinding: ${e.message}")
+ }
+ lastPingAttempt = now
+ cachedService = null
+ } else {
+ // Recent ping succeeded or not yet time to check, use cached
+ Log.d(TAG, "Using cached service (${now - lastPingAttempt}ms since last ping)")
+ return cached
+ }
+ }
+
+ // Need to establish new connection
+ Log.d(TAG, "Establishing new service connection...")
+ return establishServiceConnection().also {
+ lastPingAttempt = System.currentTimeMillis()
+ Log.d(TAG, "Service connection established successfully")
+ }
+ }
+ }
+
+ fun setLogCallbackEnabled(enabled: Boolean) {
+ logCallbackEnabled = enabled
+ val service = cachedService ?: return
+ try {
+ if (enabled) {
+ service.setLogCallback(logCallback)
+ Log.d(TAG, "Log callback registered with privileged service (toggle)")
+ } else {
+ service.setLogCallback(null)
+ Log.d(TAG, "Log callback unregistered from privileged service (toggle)")
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to toggle log callback: ${e.message}")
+ }
+ }
+
+ private suspend fun establishServiceConnection(): IPrivilegedService {
+ return withTimeoutOrNull(BIND_TIMEOUT_MS) {
+ suspendCancellableCoroutine { continuation ->
+ val connection = object : ServiceConnection {
+ override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
+ Log.d(TAG, "onServiceConnected called with service=$service")
+ if (service != null) {
+ val privileged = IPrivilegedService.Stub.asInterface(service)
+ if (logCallbackEnabled) {
+ try {
+ privileged.setLogCallback(logCallback)
+ Log.d(TAG, "Log callback registered with privileged service")
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to register log callback: ${e.message}")
+ }
+ }
+ cachedService = privileged
+ serviceConnection = this
+ continuation.resume(privileged)
+ } else {
+ Log.e(TAG, "onServiceConnected but binder is null!")
+ continuation.resumeWithException(Exception("Shizuku UserService bound but returned null binder"))
+ }
+ }
+
+ override fun onServiceDisconnected(name: ComponentName?) {
+ Log.w(TAG, "Service disconnected unexpectedly")
+ cachedService = null
+ }
+ }
+
+ val args = Shizuku.UserServiceArgs(
+ ComponentName(BuildConfig.APPLICATION_ID, PrivilegedServiceImpl::class.java.name)
+ )
+ .daemon(true) // Keep as daemon to avoid repeated reconnections
+ .processNameSuffix("privileged")
+ .debuggable(BuildConfig.DEBUG)
+ .version(2)
+
+ serviceArgs = args
+
+ try {
+ Log.d(TAG, "Calling Shizuku.bindUserService()...")
+ Shizuku.bindUserService(args, connection)
+ } catch (e: Exception) {
+ Log.e(TAG, "bindUserService threw exception: ${e.message}", e)
+ continuation.resumeWithException(e)
+ }
+
+ continuation.invokeOnCancellation {
+ Log.d(TAG, "Service binding cancelled, unbinding...")
+ try {
+ Shizuku.unbindUserService(args, connection, true)
+ } catch (ignored: Exception) {
+ Log.w(TAG, "Error during unbind: ${ignored.message}")
+ }
+ }
+ }
+ } ?: throw Exception("Service binding timed out after ${BIND_TIMEOUT_MS}ms")
+ }
+
+ /**
+ * Executes an action with the privileged service, using cached connection.
+ */
+ suspend fun executeWithService(action: suspend (IPrivilegedService) -> T): T {
+ Log.d(TAG, "executeWithService called")
+ return try {
+ action(getPrivilegedService()).also {
+ Log.d(TAG, "executeWithService completed successfully")
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "executeWithService failed: ${e.message}", e)
+ throw e
+ }
+ }
+}
+
+
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/integration/shizuku/ShizukuUtil.kt b/app/src/main/java/com/d4viddf/hyperbridge/integration/shizuku/ShizukuUtil.kt
new file mode 100644
index 0000000..ea88750
--- /dev/null
+++ b/app/src/main/java/com/d4viddf/hyperbridge/integration/shizuku/ShizukuUtil.kt
@@ -0,0 +1,46 @@
+package com.d4viddf.hyperbridge.integration.shizuku
+
+import android.content.pm.PackageManager
+import com.d4viddf.hyperbridge.BuildConfig
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.first
+import rikka.shizuku.Shizuku
+
+
+class ShizukuNotWorkException(cause: Throwable? = null) : Exception("Shizuku is not working or permission denied.", cause)
+
+suspend fun requireShizukuPermissionGranted(action: suspend () -> T): T {
+ callbackFlow {
+ try {
+ rikka.sui.Sui.init(BuildConfig.APPLICATION_ID)
+ } catch (e: Exception) {}
+
+ if (Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED) {
+ if (Shizuku.pingBinder()) {
+ send(Unit)
+ } else {
+ close(ShizukuNotWorkException())
+ }
+ awaitClose()
+ } else {
+ val requestCode = (Int.MIN_VALUE..Int.MAX_VALUE).random()
+ val listener =
+ Shizuku.OnRequestPermissionResultListener { _requestCode, grantResult ->
+ if (_requestCode != requestCode) return@OnRequestPermissionResultListener
+ if (Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED)
+ trySend(Unit)
+ else close(Exception("sui/shizuku permission denied"))
+ }
+ Shizuku.addRequestPermissionResultListener(listener)
+ Shizuku.requestPermission(requestCode)
+ awaitClose { Shizuku.removeRequestPermissionResultListener(listener) }
+ }
+ }.catch {
+ throw ShizukuNotWorkException(it)
+ }.first()
+
+ return action()
+}
+
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/integration/shizuku/XmsfNetworkHelper.kt b/app/src/main/java/com/d4viddf/hyperbridge/integration/shizuku/XmsfNetworkHelper.kt
new file mode 100644
index 0000000..dbdb740
--- /dev/null
+++ b/app/src/main/java/com/d4viddf/hyperbridge/integration/shizuku/XmsfNetworkHelper.kt
@@ -0,0 +1,92 @@
+package com.d4viddf.hyperbridge.integration.shizuku
+
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.DeadObjectException
+import android.util.Log
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.delay
+
+object XmsfNetworkHelper {
+
+ private const val TAG = "XmsfNetworkHelper"
+ private const val XMSF_PACKAGE = "com.xiaomi.xmsf"
+ private const val MAX_RETRIES = 2
+ private const val RETRY_DELAY_MS = 500L
+
+ suspend fun setXmsfNetworkingEnabled(context: Context, enabled: Boolean): Boolean {
+
+ try {
+ val pm = context.packageManager
+ val uid = try {
+ pm.getPackageUid(XMSF_PACKAGE, 0)
+ } catch (e: Exception) {
+ Log.w(TAG, "XMSF package not found (UID lookup failed)")
+ return false
+ }
+
+ Log.d(TAG, "🚀 setXmsfNetworkingEnabled called: enabled=$enabled, uid=$uid")
+
+ // Use the utility to ensure permission and then perform the action
+ return try {
+ requireShizukuPermissionGranted {
+ Log.d(TAG, "✓ Shizuku permission granted, entering retry loop")
+ var lastError: Exception? = null
+
+ for (attempt in 0 until MAX_RETRIES) {
+ try {
+ Log.d(TAG, "📡 Attempt ${attempt + 1}/$MAX_RETRIES: Getting privileged service...")
+ val service = ShizukuUserServiceRecycler.getPrivilegedService()
+ Log.d(TAG, "✓ Got privileged service, calling setPackageNetworkingEnabled...")
+
+ val success = service.setPackageNetworkingEnabled(uid, enabled)
+ if (!success) {
+ throw IllegalStateException("Privileged service returned failure for uid=$uid")
+ }
+
+ Log.d(TAG, "✓ Successfully set XMSF networking to $enabled via privileged service")
+ return@requireShizukuPermissionGranted true
+ } catch (e: CancellationException) {
+ Log.w(TAG, "⚠️ Operation cancelled")
+ return@requireShizukuPermissionGranted false
+ } catch (e: DeadObjectException) {
+ lastError = e
+ Log.w(TAG, "⚠️ DeadObjectException on attempt ${attempt + 1}")
+ if (attempt + 1 < MAX_RETRIES) {
+ delay(RETRY_DELAY_MS)
+ }
+ } catch (e: Exception) {
+ lastError = e
+ Log.w(TAG, "⚠️ Privileged service path failed on attempt ${attempt + 1}: ${e.message}")
+ try {
+ Log.d(TAG, "🪝 Attempt ${attempt + 1}/$MAX_RETRIES: Falling back to hooked binder...")
+ ShizukuHook.setPackageNetworkingEnabled(uid, enabled)
+ Log.d(TAG, "✓ Successfully set XMSF networking to $enabled via hooked binder fallback")
+ return@requireShizukuPermissionGranted true
+ } catch (hookError: Exception) {
+ lastError = hookError
+ Log.e(
+ TAG,
+ "❌ Error on attempt ${attempt + 1}: service=${e.message}; hook=${hookError.message}"
+ )
+ }
+ if (attempt + 1 < MAX_RETRIES) {
+ delay(RETRY_DELAY_MS)
+ }
+ }
+ }
+ lastError?.let { Log.e(TAG, "❌ All XMSF networking paths failed after retries: ${it.message}") }
+ false
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "❌ Shizuku permission or logic failed: ${e.message}")
+ false
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "❌ Critical error in setXmsfNetworkingEnabled: ${e.message}")
+ return false
+ }
+ }
+}
+
+
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/models/ActiveIslands.kt b/app/src/main/java/com/d4viddf/hyperbridge/models/ActiveIslands.kt
index 53b62ec..b885c86 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/models/ActiveIslands.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/models/ActiveIslands.kt
@@ -5,10 +5,12 @@ data class ActiveIsland(
val type: NotificationType,
val postTime: Long,
val packageName: String,
+ val groupKey: String?,
// Content Diffing Fields
val title: String,
val text: String,
val subText: String,
// Used for Deduplication
- val lastContentHash: Int
+ val lastContentHash: Int,
+ val deleteIntent: android.app.PendingIntent? = null
)
\ No newline at end of file
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/models/IslandConfig.kt b/app/src/main/java/com/d4viddf/hyperbridge/models/IslandConfig.kt
index 3841912..38432b3 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/models/IslandConfig.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/models/IslandConfig.kt
@@ -4,15 +4,21 @@ data class IslandConfig(
val isFloat: Boolean? = null,
val isShowShade: Boolean? = null,
val timeout: Int? = null,
-
+ val floatTimeout: Int? = null,
+ val removeOriginalNotification: Boolean? = null,
+ val dismissWithOriginal: Boolean? = null,
+ val enableInlineReply: Boolean? = null,
) {
// Merges this config (App) with a default config (Global)
fun mergeWith(global: IslandConfig): IslandConfig {
return IslandConfig(
isFloat = this.isFloat ?: global.isFloat ?: false,
- isShowShade = this.isShowShade ?: global.isShowShade ?: true,
+ isShowShade = this.isShowShade ?: global.isShowShade ?: false,
timeout = this.timeout ?: global.timeout ?: 10,
-
+ floatTimeout = this.floatTimeout ?: global.floatTimeout ?: 5,
+ removeOriginalNotification = this.removeOriginalNotification ?: global.removeOriginalNotification ?: false,
+ dismissWithOriginal = this.dismissWithOriginal ?: global.dismissWithOriginal ?: true,
+ enableInlineReply = this.enableInlineReply ?: global.enableInlineReply ?: true,
)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/models/NotificationType.kt b/app/src/main/java/com/d4viddf/hyperbridge/models/NotificationType.kt
index 83be563..b583e4e 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/models/NotificationType.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/models/NotificationType.kt
@@ -5,9 +5,11 @@ import com.d4viddf.hyperbridge.R
enum class NotificationType(@StringRes val labelRes: Int) {
STANDARD(R.string.type_standard),
+ MESSAGE(R.string.type_message),
PROGRESS(R.string.type_progress),
+ DOWNLOAD(R.string.type_download),
MEDIA(R.string.type_media),
NAVIGATION(R.string.type_nav),
CALL(R.string.type_call),
TIMER(R.string.type_timer)
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/models/theme/ThemeModels.kt b/app/src/main/java/com/d4viddf/hyperbridge/models/theme/ThemeModels.kt
index 32e5c9f..d2d4656 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/models/theme/ThemeModels.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/models/theme/ThemeModels.kt
@@ -12,6 +12,7 @@ data class HyperTheme(
@SerialName("default_actions") val defaultActions: Map = emptyMap(),
@SerialName("default_progress") val defaultProgress: ProgressModule = ProgressModule(),
@SerialName("default_navigation") val defaultNavigation: NavigationModule = NavigationModule(),
+ @SerialName("default_reply") val defaultReply: ReplyModule = ReplyModule(),
val apps: Map = emptyMap(),
val rules: List = emptyList()
)
@@ -32,6 +33,7 @@ data class ThemeMetadata(
data class GlobalConfig(
@SerialName("highlight_color") val highlightColor: String? = null,
@SerialName("background_color") val backgroundColor: String? = null,
+ @SerialName("use_native_live_updates") val useNativeLiveUpdates: Boolean? = null,
@SerialName("text_color") val textColor: String? = "#FFFFFF",
@Deprecated("Use colorMode instead. Kept for backward compatibility with v0.4.x themes.")
@@ -77,12 +79,18 @@ data class AppThemeOverride(
@SerialName("call_config") val callConfig: CallModule? = null,
val actions: Map? = null,
val progress: ProgressModule? = null,
- val navigation: NavigationModule? = null
+ val navigation: NavigationModule? = null,
+ @SerialName("reply_config") val replyConfig: ReplyModule? = null,
+
+ // [NEW] App-specific behavior overrides
+ @SerialName("use_native_live_updates") val useNativeLiveUpdates: Boolean? = null,
+ @SerialName("active_notification_types") val activeNotificationTypes: Set? = null
) {
// Safe getter: reads new enum, falls back to legacy boolean if present, else returns null (uses global)
val activeColorMode: ColorMode?
get() = colorMode ?: useAppColors?.let { if (it) ColorMode.APP_ICON else ColorMode.CUSTOM }
}
+
@Serializable
data class ActionConfig(
val mode: ActionButtonMode = ActionButtonMode.ICON,
@@ -130,11 +138,25 @@ data class RuleConditions(
@Serializable
data class ThemeResource(val type: ResourceType, val value: String)
+@Serializable
+data class ReplyModule(
+ @SerialName("background_color") val backgroundColor: String? = null,
+ @SerialName("send_color") val sendColor: String? = null,
+ @SerialName("send_icon") val sendIcon: ThemeResource? = null,
+ @SerialName("textfield_background_color") val textfieldBackgroundColor: String? = null,
+ @SerialName("text_color") val textColor: String? = null,
+ @SerialName("color_mode") val colorMode: ColorMode? = null,
+ @SerialName("use_blur") val useBlur: Boolean = false,
+ @SerialName("textfield_corner_radius") val textfieldCornerRadius: Int = 24,
+ @SerialName("send_button_corner_radius") val sendButtonCornerRadius: Int = 24
+)
+
enum class ResourceType { PRESET_DRAWABLE, LOCAL_FILE, URI_CONTENT }
enum class ActionButtonMode { ICON, TEXT, BOTH }
enum class ColorMode {
+ DEFAULT, // Uses default system/app theme colors without overrides
CUSTOM, // Uses selectedColorHex
APP_ICON, // Extracts color from the notification's app icon
MATERIAL_YOU // Extracts color from the system wallpaper (Monet)
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/receiver/BootReceiver.kt b/app/src/main/java/com/d4viddf/hyperbridge/receiver/BootReceiver.kt
index 9963228..7cf0a81 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/receiver/BootReceiver.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/receiver/BootReceiver.kt
@@ -5,20 +5,87 @@ import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
+import android.service.notification.NotificationListenerService
import android.util.Log
import com.d4viddf.hyperbridge.service.NotificationReaderService
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import com.d4viddf.hyperbridge.data.db.AppDatabase
+
class BootReceiver : BroadcastReceiver() {
+
+ companion object {
+ private var lastToggleTime = 0L
+ }
+
override fun onReceive(context: Context, intent: Intent) {
- // Check for both standard boot and quick boot (some ROMs use quick)
- if (intent.action == Intent.ACTION_BOOT_COMPLETED ||
- intent.action == "android.intent.action.QUICKBOOT_POWERON") {
+ val action = intent.action ?: return
+
+ // 1. Determine Importance
+ val isMajor = action == Intent.ACTION_BOOT_COMPLETED ||
+ action == Intent.ACTION_LOCKED_BOOT_COMPLETED ||
+ action == Intent.ACTION_MY_PACKAGE_REPLACED ||
+ action == "android.intent.action.QUICKBOOT_POWERON"
+
+ val isMinor = action == Intent.ACTION_USER_UNLOCKED ||
+ action == Intent.ACTION_USER_PRESENT ||
+ action == "android.intent.action.USER_SWITCHED"
+
+ val isTest = action == "com.d4viddf.hyperbridge.ACTION_TEST_MIGRATION"
- Log.d("HyperBridge", "Boot completed detected.")
+ if (!isMajor && !isMinor && !isTest) return
+
+ Log.d("HyperBridge", "Trigger event detected: $action")
+
+ val pendingResult = goAsync()
+
+ CoroutineScope(Dispatchers.IO).launch {
+ try {
+ // 2. Migration Logic (Major or Test only)
+ if (isMajor || isTest) {
+ Log.d("HyperBridge", "Major trigger: Performing database migration.")
+ AppDatabase.performMigration(context) { progress ->
+ Log.d("HyperBridge", "Migration progress: $progress%")
+ }
+ // Trigger AppPreferences initialization to run SharedPreferences migrations
+ com.d4viddf.hyperbridge.data.AppPreferences(context)
+ }
+ } catch (e: Exception) {
+ Log.e("HyperBridge", "Error during migration in BootReceiver", e)
+ } finally {
+ // 3. Re-bind Logic
+ withContext(Dispatchers.Main) {
+ try {
+ if (isMajor) {
+ val now = System.currentTimeMillis()
+ if (now - lastToggleTime > 5000) {
+ lastToggleTime = now
+ Log.d("HyperBridge", "Major trigger: Toggling NLS component state.")
+ toggleNotificationListener(context)
+ } else {
+ Log.d("HyperBridge", "Major trigger: Cooldown active, skipping toggle.")
+ }
+ } else if (isMinor) {
+ Log.d("HyperBridge", "Minor trigger: Requesting official re-bind.")
+ requestRebind(context)
+ }
+ } finally {
+ pendingResult.finish()
+ }
+ }
+ }
+ }
+ }
- // Trick: We toggle the component state to force the Notification Manager
- // to re-evaluate and re-bind to our service.
- toggleNotificationListener(context)
+ private fun requestRebind(context: Context) {
+ try {
+ val component = ComponentName(context, NotificationReaderService::class.java)
+ NotificationListenerService.requestRebind(component)
+ } catch (e: Exception) {
+ Log.e("HyperBridge", "Failed to request official re-bind", e)
}
}
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/receiver/InlineReplyReceiver.kt b/app/src/main/java/com/d4viddf/hyperbridge/receiver/InlineReplyReceiver.kt
new file mode 100644
index 0000000..ce355c7
--- /dev/null
+++ b/app/src/main/java/com/d4viddf/hyperbridge/receiver/InlineReplyReceiver.kt
@@ -0,0 +1,33 @@
+package com.d4viddf.hyperbridge.receiver
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.provider.Settings
+import android.widget.Toast
+import com.d4viddf.hyperbridge.service.InlineReplyService
+
+class InlineReplyReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ if (!Settings.canDrawOverlays(context)) {
+ Toast.makeText(context, "Please grant 'Display over other apps' permission to use inline reply", Toast.LENGTH_LONG).show()
+
+ // Optionally launch settings
+ val settingsIntent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK
+ }
+ context.startActivity(settingsIntent)
+ return
+ }
+
+ val serviceIntent = Intent(context, InlineReplyService::class.java).apply {
+ putExtras(intent)
+ }
+
+ try {
+ context.startService(serviceIntent)
+ } catch (_: Exception) {
+ Toast.makeText(context, "Failed to start reply service from background", Toast.LENGTH_SHORT).show()
+ }
+ }
+}
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/service/InlineReplyService.kt b/app/src/main/java/com/d4viddf/hyperbridge/service/InlineReplyService.kt
new file mode 100644
index 0000000..3caff67
--- /dev/null
+++ b/app/src/main/java/com/d4viddf/hyperbridge/service/InlineReplyService.kt
@@ -0,0 +1,378 @@
+package com.d4viddf.hyperbridge.service
+
+import android.app.PendingIntent
+import android.app.RemoteInput
+import android.app.Service
+import android.content.Intent
+import android.graphics.Outline
+import android.graphics.PixelFormat
+import android.os.Build
+import android.os.Bundle
+import android.os.IBinder
+import android.util.Log
+import android.view.Gravity
+import android.view.KeyEvent
+import android.view.ViewOutlineProvider
+import android.view.WindowManager
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Send
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+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.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.boundsInWindow
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.platform.ViewCompositionStrategy
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleRegistry
+import androidx.lifecycle.ViewModelStore
+import androidx.lifecycle.ViewModelStoreOwner
+import androidx.lifecycle.setViewTreeLifecycleOwner
+import androidx.lifecycle.setViewTreeViewModelStoreOwner
+import androidx.savedstate.SavedStateRegistry
+import androidx.savedstate.SavedStateRegistryController
+import androidx.savedstate.SavedStateRegistryOwner
+import androidx.savedstate.setViewTreeSavedStateRegistryOwner
+import com.d4viddf.hyperbridge.ui.theme.HyperBridgeTheme
+import androidx.core.graphics.toColorInt
+
+fun safeParseColorFallback(hex: String?, fallback: Color): Color {
+ if (hex == null) return fallback
+ return try {
+ Color(hex.toColorInt())
+ } catch (_: Exception) {
+ fallback
+ }
+}
+
+class InlineReplyService : Service(), LifecycleOwner, ViewModelStoreOwner, SavedStateRegistryOwner {
+
+ private val lifecycleRegistry = LifecycleRegistry(this)
+ private val store = ViewModelStore()
+ private val savedStateRegistryController = SavedStateRegistryController.create(this)
+
+ override val lifecycle: Lifecycle get() = lifecycleRegistry
+ override val viewModelStore: ViewModelStore get() = store
+ override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController.savedStateRegistry
+
+ private var windowManager: WindowManager? = null
+ private var composeView: ComposeView? = null
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ savedStateRegistryController.performRestore(null)
+ lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
+ lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START)
+ lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
+ windowManager = getSystemService(WINDOW_SERVICE) as WindowManager
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ val pendingIntent = intent?.getParcelableExtra("pending_intent")
+ val resultKey = intent?.getStringExtra("result_key")
+ val packageName = intent?.getStringExtra("package_name") ?: ""
+
+ if (pendingIntent == null || resultKey == null) {
+ stopSelf()
+ return START_NOT_STICKY
+ }
+
+ showOverlay(pendingIntent, resultKey, packageName)
+ return START_NOT_STICKY
+ }
+
+ private fun showOverlay(pendingIntent: PendingIntent, resultKey: String, packageName: String) {
+ if (composeView != null) return
+
+ composeView = ComposeView(this).apply {
+ setViewTreeLifecycleOwner(this@InlineReplyService)
+ setViewTreeViewModelStoreOwner(this@InlineReplyService)
+ setViewTreeSavedStateRegistryOwner(this@InlineReplyService)
+ setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
+
+ setContent {
+ val preferences = remember { com.d4viddf.hyperbridge.data.AppPreferences(this@InlineReplyService) }
+ val themeRepository = remember { com.d4viddf.hyperbridge.data.theme.ThemeRepository(this@InlineReplyService) }
+ val activeThemeId by preferences.activeThemeIdFlow.collectAsState(initial = null)
+ val activeTheme by themeRepository.activeTheme.collectAsState()
+
+ LaunchedEffect(activeThemeId) {
+ activeThemeId?.let { themeRepository.activateTheme(it) }
+ }
+
+ val replyModule = activeTheme?.apps?.get(packageName)?.replyConfig ?: activeTheme?.defaultReply
+
+ val useBlur = replyModule?.useBlur ?: true
+ val tfCornerRadius = replyModule?.textfieldCornerRadius ?: 24
+ val btnCornerRadius = replyModule?.sendButtonCornerRadius ?: 24
+
+ val tfBgColorHex = replyModule?.textfieldBackgroundColor
+ val sendColorHex = replyModule?.sendColor
+ val txtColorHex = replyModule?.textColor
+ val colorMode = replyModule?.colorMode ?: com.d4viddf.hyperbridge.models.theme.ColorMode.DEFAULT
+
+ HyperBridgeTheme {
+ var message by remember { mutableStateOf("") }
+ val focusRequester = remember { FocusRequester() }
+
+ LaunchedEffect(Unit) {
+ try {
+ focusRequester.requestFocus()
+ } catch (e: Exception) {
+ Log.e("InlineReplyService", "Focus failed", e)
+ }
+ }
+
+ val isMaterialYou = colorMode == com.d4viddf.hyperbridge.models.theme.ColorMode.MATERIAL_YOU
+ val tfBgColor = if (isMaterialYou) MaterialTheme.colorScheme.surfaceVariant else (tfBgColorHex?.let { safeParseColorFallback(it, MaterialTheme.colorScheme.surfaceVariant) } ?: MaterialTheme.colorScheme.surfaceVariant)
+ val sendParsedColor = if (isMaterialYou) MaterialTheme.colorScheme.primary else (sendColorHex?.let { safeParseColorFallback(it, MaterialTheme.colorScheme.primary) } ?: MaterialTheme.colorScheme.primary)
+ val txtColor = if (isMaterialYou) MaterialTheme.colorScheme.onSurface else (txtColorHex?.let { safeParseColorFallback(it, MaterialTheme.colorScheme.onSurface) } ?: MaterialTheme.colorScheme.onSurface)
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.Transparent)
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null
+ ) {
+ removeOverlayAndStop()
+ },
+ contentAlignment = Alignment.BottomCenter
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .navigationBarsPadding()
+ .imePadding()
+ .padding(16.dp),
+ verticalAlignment = Alignment.Bottom
+ ) {
+ var tfRect by remember { mutableStateOf(android.graphics.Rect()) }
+ var btnRect by remember { mutableStateOf(android.graphics.Rect()) }
+
+ val currentView = LocalView.current
+ val displayMetrics = currentView.resources.displayMetrics
+
+ LaunchedEffect(tfRect, btnRect, tfCornerRadius, btnCornerRadius, useBlur) {
+ if (useBlur && !tfRect.isEmpty && !btnRect.isEmpty) {
+ currentView.outlineProvider = object : ViewOutlineProvider() {
+ override fun getOutline(view: android.view.View, outline: Outline) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ val path = android.graphics.Path()
+ path.addRoundRect(
+ android.graphics.RectF(tfRect),
+ tfCornerRadius.toFloat() * displayMetrics.density,
+ tfCornerRadius.toFloat() * displayMetrics.density,
+ android.graphics.Path.Direction.CW
+ )
+ path.addRoundRect(
+ android.graphics.RectF(btnRect),
+ btnCornerRadius.toFloat() * displayMetrics.density,
+ btnCornerRadius.toFloat() * displayMetrics.density,
+ android.graphics.Path.Direction.CW
+ )
+ outline.setPath(path)
+ }
+ }
+ }
+ val wParams = currentView.layoutParams as? WindowManager.LayoutParams
+ if (wParams != null) {
+ wParams.flags = wParams.flags or WindowManager.LayoutParams.FLAG_BLUR_BEHIND
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ wParams.blurBehindRadius = 50
+ }
+ try { windowManager?.updateViewLayout(currentView, wParams) } catch (_: Exception) {}
+ }
+ } else {
+ currentView.outlineProvider = null
+ val wParams = currentView.layoutParams as? WindowManager.LayoutParams
+ if (wParams != null) {
+ wParams.flags = wParams.flags and WindowManager.LayoutParams.FLAG_BLUR_BEHIND.inv()
+ try { windowManager?.updateViewLayout(currentView, wParams) } catch (_: Exception) {}
+ }
+ }
+ }
+
+
+ Box(modifier = Modifier
+ .weight(1f)
+ .heightIn(
+ min = 56.dp,
+ max = 200.dp
+ )
+ .onGloballyPositioned { coords ->
+ val b = coords.boundsInWindow()
+ tfRect = android.graphics.Rect(b.left.toInt(), b.top.toInt(), b.right.toInt(), b.bottom.toInt())
+ }
+ ) {
+ // Background Layer
+ Box(modifier = Modifier
+ .matchParentSize()
+ .background(if (useBlur) tfBgColor.copy(alpha = 0.5f) else tfBgColor, RoundedCornerShape(tfCornerRadius.dp))
+ )
+
+ // Content Layer (Sharp)
+ TextField(
+ value = message,
+ onValueChange = { message = it },
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(focusRequester),
+ placeholder = { Text(androidx.compose.ui.res.stringResource(com.d4viddf.hyperbridge.R.string.reply_hint)) },
+ textStyle = androidx.compose.ui.text.TextStyle(color = txtColor),
+ colors = TextFieldDefaults.colors(
+ focusedContainerColor = Color.Transparent,
+ unfocusedContainerColor = Color.Transparent,
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent,
+ disabledContainerColor = Color.Transparent,
+ disabledIndicatorColor = Color.Transparent
+ ),
+ maxLines = 4,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
+ keyboardActions = KeyboardActions(
+ onSend = {
+ sendReply(pendingIntent, resultKey, message)
+ }
+ )
+ )
+ }
+
+ Box(
+ modifier = Modifier
+ .padding(start = 8.dp)
+ .size(56.dp)
+ .onGloballyPositioned { coords ->
+ val b = coords.boundsInWindow()
+ btnRect = android.graphics.Rect(b.left.toInt(), b.top.toInt(), b.right.toInt(), b.bottom.toInt())
+ }
+ ) {
+ // Background Layer
+ Box(modifier = Modifier
+ .matchParentSize()
+ .background(if (useBlur) MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) else MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(btnCornerRadius.dp))
+ )
+
+ // Content Layer (Sharp)
+ IconButton(
+ onClick = { sendReply(pendingIntent, resultKey, message) },
+ modifier = Modifier.fillMaxSize()
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.Send,
+ contentDescription = "Send",
+ tint = if (message.isNotBlank()) sendParsedColor else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ setOnKeyListener { _, keyCode, event ->
+ if (keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) {
+ removeOverlayAndStop()
+ true
+ } else {
+ false
+ }
+ }
+ }
+
+ val params = WindowManager.LayoutParams(
+ WindowManager.LayoutParams.MATCH_PARENT,
+ WindowManager.LayoutParams.MATCH_PARENT,
+ WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
+ WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH or
+ WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or
+ WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
+ PixelFormat.TRANSLUCENT
+ ).apply {
+ gravity = Gravity.CENTER
+ // We do NOT use FLAG_NOT_FOCUSABLE because we need the keyboard to open!
+ // But we must be careful to consume back presses to allow the user to exit.
+ }
+
+ try {
+ windowManager?.addView(composeView, params)
+ } catch (e: Exception) {
+ Log.e("InlineReplyService", "Failed to add overlay. SYSTEM_ALERT_WINDOW permission missing?", e)
+ stopSelf()
+ }
+ }
+
+ private fun sendReply(pendingIntent: PendingIntent, resultKey: String, message: String) {
+ if (message.isBlank()) return
+
+ try {
+ val replyIntent = Intent()
+ val bundle = Bundle()
+ bundle.putCharSequence(resultKey, message)
+
+ val remoteInput = RemoteInput.Builder(resultKey).build()
+ RemoteInput.addResultsToIntent(arrayOf(remoteInput), replyIntent, bundle)
+
+ pendingIntent.send(this, 0, replyIntent)
+ } catch (e: PendingIntent.CanceledException) {
+ Log.e("InlineReplyService", "PendingIntent canceled", e)
+ }
+
+ removeOverlayAndStop()
+ }
+
+ private fun removeOverlayAndStop() {
+ try {
+ composeView?.let { windowManager?.removeView(it) }
+ } catch (e: Exception) {
+ Log.e("InlineReplyService", "Failed to remove view", e)
+ }
+ composeView = null
+ stopSelf()
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ removeOverlayAndStop()
+ lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE)
+ lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP)
+ lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
+ store.clear()
+ }
+}
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/service/NotificationReaderService.kt b/app/src/main/java/com/d4viddf/hyperbridge/service/NotificationReaderService.kt
index 0be670b..3b5cea9 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/service/NotificationReaderService.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/service/NotificationReaderService.kt
@@ -5,8 +5,10 @@ import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
+import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
+import android.content.IntentFilter
import android.os.Bundle
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
@@ -17,23 +19,30 @@ import androidx.core.app.NotificationManagerCompat
import com.d4viddf.hyperbridge.MainActivity
import com.d4viddf.hyperbridge.R
import com.d4viddf.hyperbridge.data.AppPreferences
+import com.d4viddf.hyperbridge.data.db.AppDatabase
import com.d4viddf.hyperbridge.data.theme.RulesEngine
import com.d4viddf.hyperbridge.data.theme.ThemeRepository
import com.d4viddf.hyperbridge.data.widget.WidgetManager
import com.d4viddf.hyperbridge.models.ActiveIsland
import com.d4viddf.hyperbridge.models.HyperIslandData
+import com.d4viddf.hyperbridge.models.IslandConfig
import com.d4viddf.hyperbridge.models.IslandLimitMode
+import com.d4viddf.hyperbridge.models.NavContent
import com.d4viddf.hyperbridge.models.NotificationType
import com.d4viddf.hyperbridge.models.WidgetConfig
import com.d4viddf.hyperbridge.models.WidgetRenderMode
import com.d4viddf.hyperbridge.service.translators.CallTranslator
import com.d4viddf.hyperbridge.service.translators.LiveUpdateTranslator
import com.d4viddf.hyperbridge.service.translators.MediaTranslator
+import com.d4viddf.hyperbridge.service.translators.MessageTranslator
import com.d4viddf.hyperbridge.service.translators.NavTranslator
import com.d4viddf.hyperbridge.service.translators.ProgressTranslator
+import com.d4viddf.hyperbridge.service.translators.DownloadTranslator
import com.d4viddf.hyperbridge.service.translators.StandardTranslator
import com.d4viddf.hyperbridge.service.translators.TimerTranslator
import com.d4viddf.hyperbridge.service.translators.WidgetTranslator
+import com.d4viddf.hyperbridge.util.ShizukuManager
+import io.github.d4viddf.hyperisland_kit.HyperIslandNotification
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -43,11 +52,13 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
+import kotlin.time.Duration.Companion.milliseconds
class NotificationReaderService : NotificationListenerService() {
companion object {
const val ACTION_RELOAD_THEME = "com.d4viddf.hyperbridge.ACTION_RELOAD_THEME"
+ const val ACTION_PERFORM_MIGRATION = "com.d4viddf.hyperbridge.ACTION_PERFORM_MIGRATION"
}
private val TAG = "HyperBridgeDebug"
@@ -56,7 +67,7 @@ class NotificationReaderService : NotificationListenerService() {
// --- CHANNELS ---
private val NOTIFICATION_CHANNEL_ID = "hyper_bridge_notification_channel"
private val WIDGET_CHANNEL_ID = "hyper_bridge_widget_channel"
- private val LIVE_UPDATE_CHANNEL_ID = "hyper_bridge_live_update_channel" // [NEW]
+ private val LIVE_UPDATE_CHANNEL_ID = "hyper_bridge_live_update_channel"
private val serviceScope = CoroutineScope(Dispatchers.Default + Job())
// --- STATE & CONFIG ---
@@ -64,14 +75,24 @@ class NotificationReaderService : NotificationListenerService() {
private var currentMode = IslandLimitMode.MOST_RECENT
private var appPriorityList = emptyList()
private var globalBlockedTerms: Set = emptySet()
+
+ private var isDndModeEnabled = false
+ private var autoDetectDnd = false
// --- CACHES ---
+ private val recentlyRemovedKeys = ConcurrentHashMap()
+ private val nativeIslands = ConcurrentHashMap.newKeySet()
private val activeIslands = ConcurrentHashMap()
private val activeTranslations = ConcurrentHashMap()
private val reverseTranslations = ConcurrentHashMap()
private val processingJobs = ConcurrentHashMap()
+ private val timeoutJobs = ConcurrentHashMap()
+ private val removalJobs = ConcurrentHashMap()
+ private lateinit var permanentIslandManager: PermanentIslandManager
+ private val intentionallyRemovedKeys = ConcurrentHashMap.newKeySet()
private val widgetUpdateDebouncer = ConcurrentHashMap()
private val dismissedWidgetIds = ConcurrentHashMap.newKeySet()
+ private val activeWidgets = ConcurrentHashMap.newKeySet()
private val appLabelCache = ConcurrentHashMap()
private val MAX_ISLANDS = 9
@@ -88,14 +109,66 @@ class NotificationReaderService : NotificationListenerService() {
private lateinit var navTranslator: NavTranslator
private lateinit var timerTranslator: TimerTranslator
private lateinit var progressTranslator: ProgressTranslator
+ private lateinit var downloadTranslator: DownloadTranslator
private lateinit var standardTranslator: StandardTranslator
+ private lateinit var messageTranslator: MessageTranslator
private lateinit var mediaTranslator: MediaTranslator
private lateinit var widgetTranslator: WidgetTranslator
- private lateinit var liveUpdateTranslator: LiveUpdateTranslator // [NEW]
+ private lateinit var liveUpdateTranslator: LiveUpdateTranslator
+
+ private val systemReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action == Intent.ACTION_USER_UNLOCKED) {
+ WidgetManager.init(this@NotificationReaderService)
+ } else if (intent.action == Intent.ACTION_SCREEN_ON) {
+ syncNotifications()
+ }
+ }
+ }
+
+ private val islandClickReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action == "com.d4viddf.hyperbridge.ISLAND_CLICKED") {
+ val sbnKey = intent.getStringExtra("sbn_key")
+ val bridgeId = intent.getIntExtra("bridge_id", -1)
+ @Suppress("DEPRECATION")
+ val originalIntent = intent.getParcelableExtra("original_intent")
+
+ if (originalIntent != null) {
+ try {
+ originalIntent.send()
+ } catch (e: PendingIntent.CanceledException) {
+ Log.e("HyperBridge", "PendingIntent canceled", e)
+ }
+ }
+
+ if (sbnKey != null) {
+ cancelNotification(sbnKey)
+ }
+
+ if (bridgeId != -1) {
+ ShizukuManager.cancel(context, bridgeId)
+ }
+ }
+ }
+ }
@RequiresPermission(Manifest.permission.POST_NOTIFICATIONS)
override fun onCreate() {
super.onCreate()
+
+ val filter = IntentFilter(Intent.ACTION_USER_UNLOCKED)
+ filter.addAction(Intent.ACTION_SCREEN_ON)
+ registerReceiver(systemReceiver, filter)
+
+ val clickFilter = IntentFilter("com.d4viddf.hyperbridge.ISLAND_CLICKED")
+ androidx.core.content.ContextCompat.registerReceiver(
+ this,
+ islandClickReceiver,
+ clickFilter,
+ androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED
+ )
+
preferences = AppPreferences(applicationContext)
createChannels()
@@ -108,28 +181,35 @@ class NotificationReaderService : NotificationListenerService() {
navTranslator = NavTranslator(this, themeRepository)
timerTranslator = TimerTranslator(this, themeRepository)
progressTranslator = ProgressTranslator(this, themeRepository)
+ downloadTranslator = DownloadTranslator(this, themeRepository)
standardTranslator = StandardTranslator(this, themeRepository)
- liveUpdateTranslator = LiveUpdateTranslator(this, themeRepository) // [NEW]
+ messageTranslator = MessageTranslator(this, themeRepository)
+ liveUpdateTranslator = LiveUpdateTranslator(this, themeRepository)
- // Media and Widget translators don't necessarily need the repo yet
mediaTranslator = MediaTranslator(this)
widgetTranslator = WidgetTranslator(this)
- WidgetManager.init(this)
+ val userManager = getSystemService(USER_SERVICE) as android.os.UserManager
+ if (userManager.isUserUnlocked) {
+ WidgetManager.init(this)
+ }
+
+ permanentIslandManager = PermanentIslandManager(this, serviceScope, preferences)
serviceScope.launch { preferences.allowedPackagesFlow.collectLatest { allowedPackageSet = it } }
serviceScope.launch { preferences.limitModeFlow.collectLatest { currentMode = it } }
serviceScope.launch { preferences.appPriorityListFlow.collectLatest { appPriorityList = it } }
serviceScope.launch { preferences.globalBlockedTermsFlow.collectLatest { globalBlockedTerms = it } }
+ serviceScope.launch { preferences.isDndModeEnabledFlow.collectLatest { isDndModeEnabled = it } }
+ serviceScope.launch { preferences.autoDetectDndFlow.collectLatest { autoDetectDnd = it } }
- // Listen for Theme Changes and update the Repository
+ // Listen for Theme Changes
serviceScope.launch {
preferences.activeThemeIdFlow.collectLatest { themeId ->
Log.d(TAG, "Service detected theme change: $themeId")
if (themeId != null) {
themeRepository.activateTheme(themeId)
} else {
- // Reset to defaults if theme is removed/disabled
themeRepository.activateTheme("")
}
}
@@ -164,7 +244,6 @@ class NotificationReaderService : NotificationListenerService() {
}
}
} else if (intent?.action == ACTION_RELOAD_THEME) {
- // Force reload current theme from disk
serviceScope.launch {
val themeId = preferences.activeThemeIdFlow.first()
if (themeId != null) {
@@ -172,27 +251,156 @@ class NotificationReaderService : NotificationListenerService() {
themeRepository.activateTheme(themeId)
}
}
+ } else if (intent?.action == ACTION_PERFORM_MIGRATION) {
+ serviceScope.launch(Dispatchers.IO) {
+ AppDatabase.performMigration(applicationContext) { progress ->
+ launch(Dispatchers.Main) {
+ showMigrationProgress(progress)
+ }
+ }
+ }
}
return START_STICKY
}
+ @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS)
+ private fun showMigrationProgress(progress: Int) {
+ val title = getString(R.string.migration_title)
+ val message = if (progress >= 100) getString(R.string.migration_complete) else getString(R.string.migration_message)
+ val bridgeId = "migration_update".hashCode()
+
+ serviceScope.launch {
+ val useNative = getEffectiveEngine(packageName)
+
+ if (useNative) {
+ val notificationBuilder = liveUpdateTranslator.translateToLiveUpdate(
+ sbn = null,
+ channelId = LIVE_UPDATE_CHANNEL_ID,
+ type = NotificationType.PROGRESS,
+ navRight = null,
+ config = null
+ )
+ notificationBuilder.setContentTitle(title)
+ notificationBuilder.setContentText(message)
+ notificationBuilder.setProgress(100, progress, progress < 0)
+ notificationBuilder.setOngoing(progress in 0..99)
+ notificationBuilder.setSmallIcon(R.drawable.ic_launcher_foreground)
+
+ val notification = notificationBuilder.build()
+ ShizukuManager.notify(this@NotificationReaderService, bridgeId, notification)
+ } else {
+ val builder = HyperIslandNotification.Builder(this@NotificationReaderService, "migration", title)
+ builder.setProgressBar(progress, "#007AFF")
+ builder.setChatInfo(title, message, "migration_icon", packageName)
+ builder.setShowNotification(true)
+ builder.setIslandFirstFloat(true)
+
+ val data = HyperIslandData(builder.buildResourceBundle(), builder.buildJsonParam())
+
+ val notificationBuilder = NotificationCompat.Builder(this@NotificationReaderService, NOTIFICATION_CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_launcher_foreground)
+ .setContentTitle(title)
+ .setContentText(message)
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+ .setOngoing(progress in 0..99)
+ .setProgress(100, progress, progress < 0)
+ .addExtras(data.resources)
+
+ val notification = notificationBuilder.build()
+ notification.extras.putString("miui.focus.param", data.jsonParam)
+
+ ShizukuManager.notify(this@NotificationReaderService, bridgeId, notification)
+ }
+
+ if (progress >= 100) {
+ delay(3000)
+ NotificationManagerCompat.from(this@NotificationReaderService).cancel(bridgeId)
+ }
+ }
+ }
+
+ // =========================================================================
+ // EFFECTIVE BEHAVIOR RESOLUTION (Theme > App > Global)
+ // =========================================================================
+
+ private fun getEffectiveTypes(pkg: String): Set {
+ val themeOverride = themeRepository.activeTheme.value?.apps?.get(pkg)
+ val rawTypes = if (themeOverride?.activeNotificationTypes != null) {
+ themeOverride.activeNotificationTypes
+ } else {
+ val localPref = preferences.getAppConfigSync(pkg)
+ localPref ?: preferences.getGlobalNotificationTypesSync()
+ }
+
+ // Fallback: if PROGRESS is enabled but DOWNLOAD is missing, implicitly enable DOWNLOAD
+ return if (rawTypes.contains("PROGRESS") && !rawTypes.contains("DOWNLOAD")) {
+ rawTypes + "DOWNLOAD"
+ } else {
+ rawTypes
+ }
+ }
+
+ private fun getEffectiveEngine(pkg: String): Boolean {
+ val activeTheme = themeRepository.activeTheme.value
+
+ // 1. Theme App Override (Creator explicitly configured this app)
+ val themeAppOverride = activeTheme?.apps?.get(pkg)?.useNativeLiveUpdates
+ if (themeAppOverride != null) return themeAppOverride
+
+ // 2. User App Override (User explicitly configured this app via Home Screen)
+ val userAppOverride = preferences.getAppEnginePreferenceSync(pkg)
+ if (userAppOverride != null) return userAppOverride
+
+ // 3. Theme Global Override (Creator explicitly forced an engine for the whole theme)
+ val themeGlobalOverride = activeTheme?.global?.useNativeLiveUpdates
+ if (themeGlobalOverride != null) return themeGlobalOverride
+
+ // 4. User Global Fallback (The main Engine Setting on the Home Screen!)
+ return preferences.useNativeLiveUpdatesSync()
+ }
+
+ private fun getEffectiveNav(pkg: String): Pair {
+ return preferences.getEffectiveNavLayoutSync(pkg)
+ }
+
// =========================================================================
// NOTIFICATION REMOVAL LOGIC
// =========================================================================
- override fun onNotificationRemoved(sbn: StatusBarNotification?) {
+ override fun onNotificationRemoved(sbn: StatusBarNotification?, rankingMap: RankingMap?, reason: Int) {
sbn?.let {
+ if (nativeIslands.remove(it.key)) {
+ updatePermanentIsland()
+ }
+
val isOurApp = it.packageName == packageName
val notifId = it.id
val notifKey = it.key
+ if (intentionallyRemovedKeys.remove(notifKey)) {
+ return
+ }
+
+ recentlyRemovedKeys[notifKey] = System.currentTimeMillis()
+
processingJobs[notifKey]?.cancel()
processingJobs.remove(notifKey)
+ timeoutJobs[notifKey]?.cancel()
+ timeoutJobs.remove(notifKey)
+
if (isOurApp) {
+ // Only process user-initiated dismissals for our notifications.
+ // Ignore programmatic cancels (e.g., during updates or Shizuku workarounds).
+ if (reason != REASON_CANCEL && reason != REASON_CANCEL_ALL) {
+ return
+ }
+
if (notifId >= WIDGET_ID_BASE) {
val widgetId = notifId - WIDGET_ID_BASE
dismissedWidgetIds.add(widgetId)
+ activeWidgets.remove(widgetId)
+ updatePermanentIsland()
return
}
@@ -202,9 +410,12 @@ class NotificationReaderService : NotificationListenerService() {
}
if (originalKey != null) {
- Log.d(TAG, "Dismissing source notification for ID $notifId -> Key: $originalKey")
- serviceScope.launch {
- cancelSourceNotification(originalKey)
+ Log.d(TAG, "Our notification $notifId removed. Cleaning up cache for $originalKey")
+ // [FIX] We no longer kill the source notification when our Island is dismissed or timed out
+ try {
+ activeIslands[originalKey]?.deleteIntent?.send()
+ } catch (e: Exception) {
+ Log.e(TAG, "Error sending delete intent for original notification", e)
}
cleanupCache(originalKey)
}
@@ -214,11 +425,29 @@ class NotificationReaderService : NotificationListenerService() {
if (activeTranslations.containsKey(notifKey)) {
val hyperId = activeTranslations[notifKey] ?: return
- try {
- NotificationManagerCompat.from(this).cancel(hyperId)
- } catch (e: Exception) {}
+ val job = serviceScope.launch(Dispatchers.IO) {
+ val appConfig = preferences.getAppIslandConfigSync(sbn.packageName)
+ val globalConfig = preferences.getGlobalConfigSync()
+ val finalConfig = appConfig.mergeWith(globalConfig)
+
+ val islandType = activeIslands[notifKey]?.type
+ val forceDismiss = islandType == NotificationType.CALL ||
+ islandType == NotificationType.MEDIA ||
+ islandType == NotificationType.NAVIGATION
- cleanupCache(notifKey)
+ if (finalConfig.dismissWithOriginal == true || forceDismiss) {
+ // Debounce updates if the app canceled it programmatically
+ if (reason == REASON_APP_CANCEL) {
+ kotlinx.coroutines.delay(300)
+ }
+ try {
+ NotificationManagerCompat.from(this@NotificationReaderService).cancel(hyperId)
+ } catch (_: Exception) {}
+ cleanupCache(notifKey)
+ }
+ removalJobs.remove(notifKey)
+ }
+ removalJobs[notifKey] = job
}
}
}
@@ -263,10 +492,40 @@ class NotificationReaderService : NotificationListenerService() {
val hyperId = activeTranslations[originalKey]
activeIslands.remove(originalKey)
activeTranslations.remove(originalKey)
+ timeoutJobs[originalKey]?.cancel()
+ timeoutJobs.remove(originalKey)
if (hyperId != null) {
reverseTranslations.remove(hyperId)
}
+ updatePermanentIsland()
+ }
+
+ private fun handlePostNotificationSideEffects(originalKey: String, bridgeId: Int, config: IslandConfig, type: NotificationType, isLiveUpdate: Boolean) {
+ // 1. Remove original if enabled (EXCEPT for Media)
+ if (config.removeOriginalNotification == true && type != NotificationType.MEDIA && type != NotificationType.CALL) {
+ intentionallyRemovedKeys.add(originalKey)
+ cancelNotification(originalKey)
+ }
+
+ // 2. Schedule timeout ONLY for Live Update notifications
+ if (isLiveUpdate) {
+ val timeoutSeconds = config.timeout ?: 0
+ timeoutJobs[originalKey]?.cancel()
+ if (timeoutSeconds > 0) {
+ timeoutJobs[originalKey] = serviceScope.launch {
+ delay((timeoutSeconds * 1000L).milliseconds)
+ Log.d(TAG, "Timeout reached for $originalKey, removing translated notification $bridgeId")
+ NotificationManagerCompat.from(this@NotificationReaderService).cancel(bridgeId)
+ cleanupCache(originalKey)
+ timeoutJobs.remove(originalKey)
+ }
+ }
+ }
+ }
+
+ private fun updatePermanentIsland() {
+ permanentIslandManager.onActiveNotificationsChanged(activeIslands.size + activeWidgets.size, nativeIslands.isNotEmpty())
}
// =========================================================================
@@ -276,15 +535,34 @@ class NotificationReaderService : NotificationListenerService() {
@RequiresPermission(Manifest.permission.POST_NOTIFICATIONS)
override fun onNotificationPosted(sbn: StatusBarNotification?) {
sbn?.let {
+ if (it.packageName != packageName) {
+ val extras = it.notification.extras
+ var isNative = false
+ if (extras != null) {
+ if (extras.containsKey("miui.focus.param") || extras.containsKey("miui.system.focus.param")) {
+ isNative = true
+ }
+ val template = extras.getString(Notification.EXTRA_TEMPLATE)
+ if (template == "androidx.media.app.NotificationCompat\$MediaStyle" ||
+ template == "android.app.Notification\$MediaStyle") {
+ isNative = true
+ }
+ }
+ if (isNative) {
+ if (nativeIslands.add(it.key)) updatePermanentIsland()
+ } else {
+ if (nativeIslands.remove(it.key)) updatePermanentIsland()
+ }
+ }
+
if (shouldIgnore(it.packageName)) return
+ if (!isAppAllowed(it.packageName)) return
processingJobs[it.key]?.cancel()
val job = serviceScope.launch {
- if (isAppAllowed(it.packageName)) {
- if (isJunkNotification(it)) return@launch
- processStandardNotification(it)
- }
+ if (isJunkNotification(it)) return@launch
+ processStandardNotification(it)
}
processingJobs[it.key] = job
job.invokeOnCompletion { processingJobs.remove(sbn.key) }
@@ -293,193 +571,323 @@ class NotificationReaderService : NotificationListenerService() {
@RequiresPermission(Manifest.permission.POST_NOTIFICATIONS)
private suspend fun processStandardNotification(rawSbn: StatusBarNotification) {
+ val manager = getSystemService(NotificationManager::class.java)
+ val isSystemDndActive = manager.currentInterruptionFilter != NotificationManager.INTERRUPTION_FILTER_ALL
+ val dndActive = isDndModeEnabled || (autoDetectDnd && isSystemDndActive)
+
+ if (dndActive) {
+ Log.d(TAG, "DND active. Skipping notification ${rawSbn.packageName}")
+ return
+ }
+
val sbn = ensureValidSbn(rawSbn)
try {
val extras = sbn.notification.extras
- val rawTitle = extras.getCharSequence(Notification.EXTRA_TITLE)?.toString()
- val rawText = extras.getCharSequence(Notification.EXTRA_TEXT)?.toString()
- val rawBigTitle = extras.getCharSequence(Notification.EXTRA_TITLE_BIG)?.toString()
- val rawBigText = extras.getCharSequence(Notification.EXTRA_BIG_TEXT)?.toString()
- val rawProgress = extras.getInt(Notification.EXTRA_PROGRESS, -1)
-
- Log.d(TAG, "--------------------------------------------------")
- Log.d(TAG, "START PROCESSING: ${sbn.packageName}")
- Log.d(TAG, " RAW Title : '$rawTitle'")
- Log.d(TAG, " RAW Text : '$rawText'")
- Log.d(TAG, " RAW BigTitle : '$rawBigTitle'")
- Log.d(TAG, " RAW BigText : '$rawBigText'")
- Log.d(TAG, " RAW Progress : $rawProgress")
-
// [LOGIC] 1. Resolve Info intelligently
var effectiveTitle = resolveTitle(sbn)
val effectiveText = resolveText(sbn.notification.extras)
- Log.d(TAG, " RESOLVED Initial Title: '$effectiveTitle'")
-
// [LOGIC] 2. State Preservation
val key = sbn.key
val previous = activeIslands[key]
if (effectiveTitle.isEmpty()) {
- Log.w(TAG, " Title invalid. Attempting fallback...")
if (previous != null && previous.title.isNotEmpty() && previous.title != sbn.packageName) {
effectiveTitle = previous.title
- Log.d(TAG, " >>> Restored from Cache: '$effectiveTitle'")
} else {
effectiveTitle = getCachedAppLabel(sbn.packageName)
- Log.d(TAG, " >>> Used App Label Fallback: '$effectiveTitle'")
}
}
// [LOGIC] 3. Hard Stop
- val hasProgress = extras.getInt(Notification.EXTRA_PROGRESS_MAX, 0) > 0 ||
- extras.getBoolean(Notification.EXTRA_PROGRESS_INDETERMINATE)
+ val hasProgress = hasProgressNotification(sbn, effectiveTitle, effectiveText)
- if (effectiveTitle.isEmpty() && !hasProgress) {
- Log.w(TAG, " ABORTING: Title still empty and no progress bar.")
- return
- }
+ if (effectiveTitle.isEmpty() && !hasProgress) return
- val appBlockedTerms = preferences.getAppBlockedTerms(sbn.packageName).first()
+ val appBlockedTerms = preferences.getAppBlockedTermsSync(sbn.packageName)
if (appBlockedTerms.isNotEmpty()) {
val content = "$effectiveTitle $effectiveText"
- if (appBlockedTerms.any { term -> content.contains(term, ignoreCase = true) }) {
- Log.d(TAG, " ABORTING: Blocked term found.")
- return
- }
+ if (appBlockedTerms.any { term -> content.contains(term, ignoreCase = true) }) return
}
- // [UPDATED] 4. Theme & Rules Interception
+ // [LOGIC] 4. Theme & Rules Interception
val activeTheme = themeRepository.activeTheme.value
val ruleMatch = rulesEngine.match(sbn, effectiveTitle, effectiveText, activeTheme)
val type = if (ruleMatch?.targetLayout != null) {
- try {
- NotificationType.valueOf(ruleMatch.targetLayout)
- } catch (_: Exception) {
- Log.e(TAG, "Invalid rule layout: ${ruleMatch.targetLayout}")
- detectNotificationType(sbn)
- }
+ try { NotificationType.valueOf(ruleMatch.targetLayout) }
+ catch (_: Exception) { detectNotificationType(sbn) }
} else {
detectNotificationType(sbn)
}
- val config = preferences.getAppConfig(sbn.packageName).first()
- if (!config.contains(type.name)) return
+ // --- LAYERED TRIGGERS LOGIC ---
+ val effectiveTypes = getEffectiveTypes(sbn.packageName)
+ if (!effectiveTypes.contains(type.name)) {
+ Log.d(TAG, " ABORTING: Type $type disabled by user/theme for ${sbn.packageName}")
+ return
+ }
+
+ var effectiveKey = key
+ removalJobs[effectiveKey]?.cancel()
+ removalJobs.remove(effectiveKey)
+ var isUpdate = activeIslands.containsKey(effectiveKey)
+ var bridgeId = sbn.key.hashCode()
+
+ if (!isUpdate && type == NotificationType.MESSAGE && sbn.groupKey != null) {
+ val existingEntry = activeIslands.entries.find {
+ it.value.type == NotificationType.MESSAGE &&
+ it.value.packageName == sbn.packageName &&
+ it.value.groupKey == sbn.groupKey
+ }
+
+ if (existingEntry != null) {
+ val oldKey = existingEntry.key
+ bridgeId = existingEntry.value.id
+ effectiveKey = oldKey
+ isUpdate = true
+
+ activeIslands.remove(oldKey)
+ activeTranslations.remove(oldKey)
+ timeoutJobs[oldKey]?.cancel()
+ timeoutJobs.remove(oldKey)
+
+ effectiveKey = key
+ activeTranslations[effectiveKey] = bridgeId
+ reverseTranslations[bridgeId] = effectiveKey
+ }
+ }
+
+ if (!isUpdate && (type == NotificationType.DOWNLOAD || type == NotificationType.PROGRESS)) {
+ val existingEntries = activeIslands.entries.filter {
+ it.value.packageName == sbn.packageName &&
+ (it.value.type == NotificationType.DOWNLOAD || it.value.type == NotificationType.PROGRESS)
+ }
+
+ val existingEntry = if (existingEntries.size == 1) {
+ existingEntries.first()
+ } else {
+ existingEntries.find { it.value.title == effectiveTitle }
+ }
- val isUpdate = activeIslands.containsKey(key)
+ if (existingEntry != null) {
+ val oldKey = existingEntry.key
+ bridgeId = existingEntry.value.id
+ effectiveKey = oldKey
+ isUpdate = true
+
+ activeIslands.remove(oldKey)
+ activeTranslations.remove(oldKey)
+ timeoutJobs[oldKey]?.cancel()
+ timeoutJobs.remove(oldKey)
+ removalJobs[oldKey]?.cancel()
+ removalJobs.remove(oldKey)
+
+ effectiveKey = key
+ activeTranslations[effectiveKey] = bridgeId
+ reverseTranslations[bridgeId] = effectiveKey
+ }
+ }
if (!isUpdate && activeIslands.size >= MAX_ISLANDS) {
handleLimitReached(type, sbn.packageName)
if (activeIslands.size >= MAX_ISLANDS) return
}
- val appIslandConfig = preferences.getAppIslandConfig(sbn.packageName).first()
- val globalConfig = preferences.globalConfigFlow.first()
+ val appIslandConfig = preferences.getAppIslandConfigSync(sbn.packageName)
+ val globalConfig = preferences.getGlobalConfigSync()
val finalConfig = appIslandConfig.mergeWith(globalConfig)
-
- val bridgeId = sbn.key.hashCode()
val picKey = "pic_${bridgeId}"
- // ===================================================================
- // [NEW LOGIC] NATIVE LIVE UPDATES FALLBACK
- // ===================================================================
- val useLiveUpdates = getSharedPreferences("hyperbridge_settings", Context.MODE_PRIVATE)
- .getBoolean("use_native_live_updates", false)
+ // --- LAYERED ENGINE LOGIC ---
+ val useLiveUpdates = getEffectiveEngine(sbn.packageName)
if (useLiveUpdates) {
Log.i(TAG, " POSTING Native Live Update -> ID: $bridgeId, Type: $type")
- // [FIXED] Pass the dedicated Live Update Channel ID
- val builder = liveUpdateTranslator.translateToLiveUpdate(sbn, finalConfig, LIVE_UPDATE_CHANNEL_ID)
+ // [FIX] Fetch the user's custom layout so the Live Update can use it!
+ val navLayout = if (type == NotificationType.NAVIGATION) getEffectiveNav(sbn.packageName) else null
+
+ // [FIX] Pass the type and the right layout to the translator
+ val builder = liveUpdateTranslator.translateToLiveUpdate(
+ sbn = sbn,
+ channelId = LIVE_UPDATE_CHANNEL_ID,
+ type = type,
+ navRight = navLayout?.second,
+ config = finalConfig
+ )
- // Track original notification key for dismissal synchronization
builder.extras.putString(EXTRA_ORIGINAL_KEY, sbn.key)
+ val shouldAlertOnce = isUpdate && (type == NotificationType.PROGRESS || type == NotificationType.DOWNLOAD || type == NotificationType.MEDIA)
+ builder.setOnlyAlertOnce(shouldAlertOnce)
+
+ val hasPermission = com.d4viddf.hyperbridge.util.XiaomiNotificationHelper.hasFocusPermission(this)
+ if (!hasPermission && com.d4viddf.hyperbridge.util.XiaomiNotificationHelper.isSupportIsland()) {
+ serviceScope.launch {
+ preferences.setFeaturedPermissionWarning(true)
+ }
+ val intent = Intent(this, MainActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+ putExtra("open_troubleshoot", true)
+ }
+ val pendingIntent = PendingIntent.getActivity(
+ this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ builder.addAction(
+ android.R.drawable.ic_dialog_info,
+ getString(R.string.troubleshoot_featured_notification),
+ pendingIntent
+ )
+ }
+
val notification = builder.build()
- // [FIXED] Dynamic Hash that accounts for Text, Progress, AND Button Actions (Play/Pause)
val actualProgress = extras.getInt(Notification.EXTRA_PROGRESS, 0)
val actualMax = extras.getInt(Notification.EXTRA_PROGRESS_MAX, 0)
val isIndeterminate = extras.getBoolean(Notification.EXTRA_PROGRESS_INDETERMINATE, false)
-
- // Track the button titles so we know when Play becomes Pause
val actionState = sbn.notification.actions?.joinToString { it.title?.toString() ?: "" } ?: ""
val newContentHash = effectiveTitle.hashCode() * 31 +
- effectiveText.hashCode() +
- actualProgress +
- actualMax +
- isIndeterminate.hashCode() +
- actionState.hashCode()
-
- if (isUpdate && previous != null && previous.lastContentHash == newContentHash) {
- Log.d(TAG, " ABORTING: Content hash duplicate (Live Update).")
- return
- }
+ effectiveText.hashCode() + actualProgress + actualMax +
+ isIndeterminate.hashCode() + actionState.hashCode()
- NotificationManagerCompat.from(this).notify(bridgeId, notification)
+ if (isUpdate && previous != null && previous.lastContentHash == newContentHash) return
- activeTranslations[sbn.key] = bridgeId
- reverseTranslations[bridgeId] = sbn.key
+ if (!shouldAlertOnce) {
+ ShizukuManager.notify(this, bridgeId, notification)
+ } else {
+ NotificationManagerCompat.from(this).notify(bridgeId, notification)
+ }
- activeIslands[key] = ActiveIsland(
+ activeTranslations[effectiveKey] = bridgeId
+ reverseTranslations[bridgeId] = effectiveKey
+ activeIslands[effectiveKey] = ActiveIsland(
id = bridgeId, type = type, postTime = System.currentTimeMillis(),
- packageName = sbn.packageName,
- title = effectiveTitle,
- text = effectiveText,
- subText = "LiveUpdate",
- lastContentHash = newContentHash
+ packageName = sbn.packageName, groupKey = sbn.groupKey, title = effectiveTitle, text = effectiveText,
+ subText = "LiveUpdate", lastContentHash = newContentHash, deleteIntent = sbn.notification.deleteIntent
)
+ updatePermanentIsland()
+
+ handlePostNotificationSideEffects(effectiveKey, bridgeId, finalConfig, type, true)
return
}
- // ===================================================================
- // [ORIGINAL LOGIC] Xiaomi Custom Island Injection
+ // --- LAYERED CUSTOM ISLAND LOGIC ---
val data: HyperIslandData = when (type) {
NotificationType.CALL -> callTranslator.translate(sbn, picKey, finalConfig, activeTheme)
NotificationType.NAVIGATION -> {
- val navLayout = preferences.getEffectiveNavLayout(sbn.packageName).first()
+ // --- LAYERED NAVIGATION LOGIC ---
+ val navLayout = getEffectiveNav(sbn.packageName)
navTranslator.translate(sbn, picKey, finalConfig, navLayout.first, navLayout.second, activeTheme)
}
NotificationType.TIMER -> timerTranslator.translate(sbn, picKey, finalConfig, activeTheme)
- NotificationType.PROGRESS -> progressTranslator.translate(sbn, effectiveTitle, picKey, finalConfig, activeTheme)
+ NotificationType.PROGRESS -> progressTranslator.translate(sbn, effectiveTitle, picKey, finalConfig, activeTheme, isUpdate)
+ NotificationType.DOWNLOAD -> downloadTranslator.translate(sbn, effectiveTitle, picKey, finalConfig, activeTheme, isUpdate)
NotificationType.MEDIA -> mediaTranslator.translate(sbn, picKey, finalConfig)
+ NotificationType.MESSAGE -> messageTranslator.translate(sbn, effectiveTitle, effectiveText, picKey, finalConfig, activeTheme)
else -> standardTranslator.translate(sbn, effectiveTitle, effectiveText, picKey, finalConfig, activeTheme)
}
val newContentHash = data.jsonParam.hashCode()
+ if (isUpdate && previous != null && previous.lastContentHash == newContentHash) return
+
+ kotlinx.coroutines.yield()
- if (isUpdate && previous != null && previous.lastContentHash == newContentHash) {
- Log.d(TAG, " ABORTING: Content hash duplicate.")
+ val removedTime = recentlyRemovedKeys[rawSbn.key]
+ if (removedTime != null && System.currentTimeMillis() - removedTime < 2000) {
+ Log.d(TAG, "Skipping post because notification was recently removed: ${rawSbn.key}")
return
}
- try {
- val currentNotifs = activeNotifications
- val exists = currentNotifs.any { it.key == key }
- if (!exists) return
- } catch (e: Exception) { }
+ val shouldAlertOnce = isUpdate && (type == NotificationType.PROGRESS || type == NotificationType.DOWNLOAD || type == NotificationType.MEDIA)
Log.i(TAG, " POSTING Island -> ID: $bridgeId, Type: $type, FinalTitle: '$effectiveTitle', FinalText: '$effectiveText'")
- postStandardNotification(sbn, bridgeId, data)
+ postStandardNotification(sbn, bridgeId, data, shouldAlertOnce)
- activeIslands[key] = ActiveIsland(
+ activeIslands[effectiveKey] = ActiveIsland(
id = bridgeId, type = type, postTime = System.currentTimeMillis(),
- packageName = sbn.packageName,
- title = effectiveTitle,
- text = effectiveText,
- subText = "",
- lastContentHash = newContentHash
+ packageName = sbn.packageName, groupKey = sbn.groupKey, title = effectiveTitle, text = effectiveText,
+ subText = "", lastContentHash = newContentHash, deleteIntent = sbn.notification.deleteIntent
)
+ updatePermanentIsland()
+
+ handlePostNotificationSideEffects(effectiveKey, bridgeId, finalConfig, type, false)
} catch (e: Exception) {
Log.e(TAG, "💥 Error processing standard notification", e)
}
}
+ private fun isDownloadNotification(sbn: StatusBarNotification, title: String, text: String): Boolean {
+ val pkg = sbn.packageName.lowercase()
+ val titleLower = title.lowercase()
+ val textLower = text.lowercase()
+ val channelId = sbn.notification.channelId?.lowercase() ?: ""
+
+ val isMatch = if (pkg.contains("download") || pkg.contains("downloader") || pkg.contains("chrome") ||
+ pkg.contains("browser") || pkg.contains("firefox") || pkg.contains("market") ||
+ pkg.contains("vending") || pkg.contains("play.store") || pkg.contains("playstore") ||
+ pkg.contains("store") || pkg.contains("fdroid") || pkg.contains("samsungapps") ||
+ pkg.contains("mipicks") || pkg.contains("venezia") || pkg.contains("packageinstaller") ||
+ pkg.contains("installer") || pkg.contains("gms") || channelId.contains("download") ||
+ channelId.contains("install")) {
+ true
+ } else {
+ val extras = sbn.notification.extras
+ val subText = extras.getCharSequence(Notification.EXTRA_SUB_TEXT)?.toString()?.lowercase() ?: ""
+ val infoText = extras.getCharSequence(Notification.EXTRA_INFO_TEXT)?.toString()?.lowercase() ?: ""
+
+ val downloadKeywords = listOf(
+ // English
+ "download", "install", "update", "updat", "upload", "transfer",
+ // Spanish / Portuguese / Italian / French
+ "descarg", "baix", "telecharg", "instal", "actuali", "carg", "subi", "transf",
+ // German
+ "laden", "gelad", "aktualis",
+ // Polish
+ "pobier", "pobran", "aktual",
+ // Russian / Ukrainian
+ "скач", "загруз", "устан", "обнов"
+ )
+ downloadKeywords.any {
+ titleLower.contains(it) ||
+ textLower.contains(it) ||
+ subText.contains(it) ||
+ infoText.contains(it)
+ }
+ }
+
+ Log.d(TAG, "🔍 isDownloadNotification check: pkg=$pkg, channelId='$channelId', title='$title', text='$text', resolved=$isMatch")
+ return isMatch
+ }
+
+ private fun hasProgressNotification(sbn: StatusBarNotification, title: String, text: String): Boolean {
+ val extras = sbn.notification.extras
+ val isDownload = isDownloadNotification(sbn, title, text)
+ val isOngoing = (sbn.notification.flags and Notification.FLAG_ONGOING_EVENT) != 0
+ return extras.getInt(Notification.EXTRA_PROGRESS_MAX, 0) > 0 ||
+ extras.getBoolean(Notification.EXTRA_PROGRESS_INDETERMINATE) ||
+ (isDownload && extractTextPercentage(title, text) != null) ||
+ (isDownload && isOngoing)
+ }
+
+ private fun extractTextPercentage(title: String?, text: String?): Int? {
+ val pattern = Regex("""\b(\d{1,3})\s*%""")
+ val textMatch = text?.let { pattern.find(it) }
+ val titleMatch = title?.let { pattern.find(it) }
+ val match = textMatch ?: titleMatch
+ if (match != null) {
+ val value = match.groupValues[1].toIntOrNull()
+ if (value != null && value in 0..100) {
+ return value
+ }
+ }
+ return null
+ }
+
private fun resolveTitle(sbn: StatusBarNotification): String {
val extras = sbn.notification.extras
val title = extras.getCharSequence(Notification.EXTRA_TITLE)?.toString()?.trim() ?: ""
@@ -489,9 +897,7 @@ class NotificationReaderService : NotificationListenerService() {
if ((title.isEmpty() || title.equals(pkg, ignoreCase = true)) && !bigTitle.isNullOrEmpty()) {
return bigTitle
}
-
if (title.equals(pkg, ignoreCase = true)) return ""
-
return title
}
@@ -505,25 +911,22 @@ class NotificationReaderService : NotificationListenerService() {
private suspend fun ensureValidSbn(sbn: StatusBarNotification): StatusBarNotification {
val extras = sbn.notification.extras
- val hasProgress = extras.getInt(Notification.EXTRA_PROGRESS_MAX, 0) > 0 ||
- extras.getBoolean(Notification.EXTRA_PROGRESS_INDETERMINATE)
- if (hasProgress) return sbn
-
val title = resolveTitle(sbn)
val text = resolveText(extras)
+ val hasProgress = hasProgressNotification(sbn, title, text)
+ if (hasProgress) return sbn
+
val pkg = sbn.packageName
val isSuspicious = title.isEmpty() || text.equals(pkg, ignoreCase = true)
if (isSuspicious) {
- delay(150)
+ delay(150.milliseconds)
try {
val activeList = activeNotifications
val updatedSbn = activeList?.firstOrNull { it.key == sbn.key }
- if (updatedSbn != null) {
- return updatedSbn
- }
- } catch (e: Exception) { }
+ if (updatedSbn != null) return updatedSbn
+ } catch (_: Exception) { }
}
return sbn
}
@@ -536,39 +939,90 @@ class NotificationReaderService : NotificationListenerService() {
val isNav = n.category == Notification.CATEGORY_NAVIGATION || sbn.packageName.let { it.contains("maps") || it.contains("waze") }
val isTimer = (extras.getBoolean(Notification.EXTRA_SHOW_CHRONOMETER) || n.category == Notification.CATEGORY_ALARM) && n.`when` > 0
val isMedia = template.contains("MediaStyle") || n.category == Notification.CATEGORY_TRANSPORT
- val hasProgress = extras.getInt(Notification.EXTRA_PROGRESS_MAX, 0) > 0
+ val isMessage = n.category == Notification.CATEGORY_MESSAGE || template == "android.app.Notification.MessagingStyle"
+
+ val title = resolveTitle(sbn)
+ val text = resolveText(extras)
+ val isDownload = isDownloadNotification(sbn, title, text)
+ val hasProgress = hasProgressNotification(sbn, title, text)
return when {
isCall -> NotificationType.CALL
isNav -> NotificationType.NAVIGATION
isTimer -> NotificationType.TIMER
isMedia -> NotificationType.MEDIA
- hasProgress -> NotificationType.PROGRESS
+ isMessage -> NotificationType.MESSAGE
+ hasProgress -> {
+ if (isDownload) {
+ NotificationType.DOWNLOAD
+ } else {
+ NotificationType.PROGRESS
+ }
+ }
else -> NotificationType.STANDARD
}
}
@RequiresPermission(Manifest.permission.POST_NOTIFICATIONS)
- private fun postStandardNotification(sbn: StatusBarNotification, bridgeId: Int, data: HyperIslandData) {
+ private fun postStandardNotification(sbn: StatusBarNotification, bridgeId: Int, data: HyperIslandData, shouldAlertOnce: Boolean) {
val builder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle(getString(R.string.app_name))
- .setContentText("Active Island")
+ .setContentText(getString(R.string.notification_went_wrong))
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setOngoing(true)
- .setOnlyAlertOnce(true)
+ .setOnlyAlertOnce(shouldAlertOnce)
val extras = Bundle()
extras.putString(EXTRA_ORIGINAL_KEY, sbn.key)
builder.addExtras(extras)
builder.addExtras(data.resources)
- sbn.notification.contentIntent?.let { builder.setContentIntent(it) }
+ val hasPermission = com.d4viddf.hyperbridge.util.XiaomiNotificationHelper.hasFocusPermission(this)
+ if (!hasPermission) {
+ val intent = Intent(this, MainActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+ putExtra("open_troubleshoot", true)
+ }
+ val pendingIntent = PendingIntent.getActivity(
+ this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ builder.setContentIntent(pendingIntent)
+ builder.addAction(
+ android.R.drawable.ic_dialog_info,
+ getString(R.string.troubleshoot_featured_notification),
+ pendingIntent
+ )
+ } else {
+ sbn.notification.contentIntent?.let { originalIntent ->
+ if (detectNotificationType(sbn) == NotificationType.MESSAGE) {
+ val clickIntent = Intent("com.d4viddf.hyperbridge.ISLAND_CLICKED").apply {
+ setPackage(packageName)
+ putExtra("sbn_key", sbn.key)
+ putExtra("bridge_id", bridgeId)
+ putExtra("original_intent", originalIntent)
+ }
+ val clickPendingIntent = PendingIntent.getBroadcast(
+ this,
+ bridgeId,
+ clickIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ builder.setContentIntent(clickPendingIntent)
+ } else {
+ builder.setContentIntent(originalIntent)
+ }
+ }
+ }
val notification = builder.build()
notification.extras.putString("miui.focus.param", data.jsonParam)
- NotificationManagerCompat.from(this).notify(bridgeId, notification)
+ if (!shouldAlertOnce) {
+ ShizukuManager.notifyWithCancel(this, bridgeId, notification)
+ } else {
+ NotificationManagerCompat.from(this).notify(bridgeId, notification)
+ }
activeTranslations[sbn.key] = bridgeId
reverseTranslations[bridgeId] = sbn.key
@@ -590,11 +1044,8 @@ class NotificationReaderService : NotificationListenerService() {
}
manager.createNotificationChannel(widgetChannel)
- // [NEW] Channel specifically for Native Android Live Updates
val liveUpdateChannel = NotificationChannel(LIVE_UPDATE_CHANNEL_ID, getString(R.string.channel_live_updates), NotificationManager.IMPORTANCE_DEFAULT).apply {
- setSound(null, null)
- enableVibration(false)
- setShowBadge(false)
+ setSound(null, null); enableVibration(false); setShowBadge(false)
}
manager.createNotificationChannel(liveUpdateChannel)
}
@@ -613,6 +1064,8 @@ class NotificationReaderService : NotificationListenerService() {
try {
val data = widgetTranslator.translate(widgetId)
postWidgetNotification(WIDGET_ID_BASE + widgetId, data)
+ activeWidgets.add(widgetId)
+ updatePermanentIsland()
} catch (e: Exception) { Log.e(TAG, "Failed widget $widgetId", e) }
}
@@ -620,7 +1073,7 @@ class NotificationReaderService : NotificationListenerService() {
private fun postWidgetNotification(notificationId: Int, data: HyperIslandData) {
val builder = NotificationCompat.Builder(this, WIDGET_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher_foreground)
- .setContentTitle("Widget Overlay").setContentText("Active")
+ .setContentTitle("Widget Overlay").setContentText(getString(R.string.widget_went_wrong))
.setPriority(NotificationCompat.PRIORITY_LOW).setOngoing(true)
.setOnlyAlertOnce(true).addExtras(data.resources)
@@ -630,15 +1083,50 @@ class NotificationReaderService : NotificationListenerService() {
val notification = builder.build()
notification.extras.putString("miui.focus.param", data.jsonParam)
- NotificationManagerCompat.from(this).notify(notificationId, notification)
+ ShizukuManager.notify(this, notificationId, notification)
}
private fun handleLimitReached(newType: NotificationType, newPkg: String) {
- if (currentMode == IslandLimitMode.MOST_RECENT) {
- val oldest = activeIslands.minByOrNull { it.value.postTime }
- oldest?.let {
- NotificationManagerCompat.from(this).cancel(it.value.id)
- cleanupCache(it.key)
+ val oldest = activeIslands.minByOrNull { it.value.postTime } ?: return
+
+ when (currentMode) {
+ IslandLimitMode.FIRST_COME -> {
+ // Ignore the new notification by removing it immediately (or simply returning, but returning here means the caller won't add it)
+ // The logic in the caller says:
+ // if (!isUpdate && activeIslands.size >= MAX_ISLANDS) {
+ // handleLimitReached(type, sbn.packageName)
+ // if (activeIslands.size >= MAX_ISLANDS) return
+ // }
+ // So if we do nothing here, the size remains >= MAX_ISLANDS, and the caller will return.
+ return
+ }
+ IslandLimitMode.MOST_RECENT -> {
+ NotificationManagerCompat.from(this).cancel(oldest.value.id)
+ cleanupCache(oldest.key)
+ }
+ IslandLimitMode.PRIORITY -> {
+ // Check if newPkg has higher priority than existing ones.
+ // Priority is determined by its index in appPriorityList (lower index = higher priority).
+ // If it's not in the list, it has the lowest priority (Int.MAX_VALUE).
+ val newPriority = appPriorityList.indexOf(newPkg).let { if (it == -1) Int.MAX_VALUE else it }
+
+ // Find the existing active island with the lowest priority (highest index value)
+ val lowestPriorityIsland = activeIslands.maxByOrNull {
+ appPriorityList.indexOf(it.value.packageName).let { idx -> if (idx == -1) Int.MAX_VALUE else idx }
+ }
+
+ if (lowestPriorityIsland != null) {
+ val lowestPriority = appPriorityList.indexOf(lowestPriorityIsland.value.packageName).let { if (it == -1) Int.MAX_VALUE else it }
+ if (newPriority <= lowestPriority) {
+ // The new notification has equal or higher priority than the lowest existing one.
+ // Remove the lowest priority existing notification.
+ NotificationManagerCompat.from(this).cancel(lowestPriorityIsland.value.id)
+ cleanupCache(lowestPriorityIsland.key)
+ } else {
+ // The new notification has lower priority than all existing ones. Do nothing, which will ignore it.
+ return
+ }
+ }
}
}
}
@@ -651,28 +1139,127 @@ class NotificationReaderService : NotificationListenerService() {
val title = extras.getCharSequence(Notification.EXTRA_TITLE)?.toString()?.trim() ?: ""
val text = extras.getCharSequence(Notification.EXTRA_TEXT)?.toString()?.trim() ?: ""
- val hasProgress = extras.getInt(Notification.EXTRA_PROGRESS_MAX, 0) > 0 || extras.getBoolean(Notification.EXTRA_PROGRESS_INDETERMINATE)
+ val hasProgress = hasProgressNotification(sbn, title, text)
val isSpecial = notification.category == Notification.CATEGORY_TRANSPORT || notification.category == Notification.CATEGORY_CALL ||
notification.category == Notification.CATEGORY_NAVIGATION || extras.getString(Notification.EXTRA_TEMPLATE)?.contains("MediaStyle") == true
if (hasProgress || isSpecial) return false
-
if (title.isEmpty() && text.isEmpty()) return true
-
if (title.equals(pkg, ignoreCase = true) || text.equals(pkg, ignoreCase = true)) return true
-
if (globalBlockedTerms.any { "$title $text".contains(it, true) }) return true
- if ((notification.flags and Notification.FLAG_GROUP_SUMMARY) != 0) return true
+
+ if ((notification.flags and Notification.FLAG_GROUP_SUMMARY) != 0) {
+ val type = detectNotificationType(sbn)
+ if (type != NotificationType.MESSAGE) return true
+ if (text.isEmpty() || title.isEmpty()) return true
+ }
return false
}
private fun getCachedAppLabel(pkg: String): String = appLabelCache.getOrPut(pkg) {
- try { packageManager.getApplicationLabel(packageManager.getApplicationInfo(pkg, 0)).toString() } catch (e: Exception) { "" }
+ try { packageManager.getApplicationLabel(packageManager.getApplicationInfo(pkg, 0)).toString() } catch (_: Exception) { "" }
}
private fun shouldIgnore(packageName: String): Boolean = packageName == this.packageName || packageName == "android" || packageName.contains("miui.notification")
private fun isAppAllowed(packageName: String): Boolean = allowedPackageSet.contains(packageName)
- override fun onListenerConnected() { Log.i(TAG, "HyperBridge Service Connected") }
- override fun onDestroy() { super.onDestroy(); serviceScope.cancel() }
+ private var syncJob: Job? = null
+
+ override fun onListenerConnected() {
+ Log.i(TAG, "HyperBridge Service Connected")
+ syncJob?.cancel()
+ syncJob = serviceScope.launch {
+ while (true) {
+ delay(60_000) // 1 minute periodic sync
+ syncNotifications()
+ }
+ }
+ }
+
+ private fun syncNotifications() {
+ val now = System.currentTimeMillis()
+ recentlyRemovedKeys.entries.removeIf { now - it.value > 10000 }
+
+ serviceScope.launch(Dispatchers.IO) {
+ try {
+ val currentNotifications = activeNotifications ?: return@launch
+ val systemNotificationKeys = currentNotifications.map { it.key }.toSet()
+
+ var nativeChanged = false
+ for (sbn in currentNotifications) {
+ if (sbn.packageName != packageName) {
+ val extras = sbn.notification.extras
+ var isNative = false
+ if (extras != null) {
+ if (extras.containsKey("miui.focus.param") || extras.containsKey("miui.system.focus.param")) {
+ isNative = true
+ }
+ val template = extras.getString(Notification.EXTRA_TEMPLATE)
+ if (template == "androidx.media.app.NotificationCompat\$MediaStyle" ||
+ template == "android.app.Notification\$MediaStyle") {
+ isNative = true
+ }
+ }
+ if (isNative) {
+ if (nativeIslands.add(sbn.key)) nativeChanged = true
+ } else {
+ if (nativeIslands.remove(sbn.key)) nativeChanged = true
+ }
+ }
+ }
+ val currentNatives = nativeIslands.toList()
+ for (key in currentNatives) {
+ if (!systemNotificationKeys.contains(key)) {
+ if (nativeIslands.remove(key)) nativeChanged = true
+ }
+ }
+ if (nativeChanged) updatePermanentIsland()
+
+ val currentKeys = currentNotifications.map { it.key }.toSet()
+
+ val keysToRemove = mutableListOf()
+ for ((originalKey, activeIsland) in activeIslands) {
+ if (!currentKeys.contains(originalKey)) {
+ val appConfig = preferences.getAppIslandConfigSync(activeIsland.packageName)
+ val globalConfig = preferences.getGlobalConfigSync()
+ val finalConfig = appConfig.mergeWith(globalConfig)
+
+ val forceDismiss = activeIsland.type == NotificationType.CALL ||
+ activeIsland.type == NotificationType.MEDIA ||
+ activeIsland.type == NotificationType.NAVIGATION
+
+ // If the app intentionally removes the original notification, it's expected to be missing from currentKeys.
+ if (!forceDismiss && finalConfig.removeOriginalNotification == true) {
+ continue
+ }
+
+ if (finalConfig.dismissWithOriginal == true || forceDismiss) {
+ keysToRemove.add(originalKey)
+ }
+ }
+ }
+
+ for (key in keysToRemove) {
+ Log.d(TAG, "Sync: Found stuck notification $key, removing.")
+ val hyperId = activeTranslations[key]
+ if (hyperId != null) {
+ try {
+ NotificationManagerCompat.from(this@NotificationReaderService).cancel(hyperId)
+ } catch (_: Exception) {}
+ }
+ cleanupCache(key)
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error syncing notifications", e)
+ }
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ unregisterReceiver(systemReceiver)
+ unregisterReceiver(islandClickReceiver)
+ syncJob?.cancel()
+ serviceScope.cancel()
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/service/PermanentIslandManager.kt b/app/src/main/java/com/d4viddf/hyperbridge/service/PermanentIslandManager.kt
new file mode 100644
index 0000000..4c22445
--- /dev/null
+++ b/app/src/main/java/com/d4viddf/hyperbridge/service/PermanentIslandManager.kt
@@ -0,0 +1,126 @@
+package com.d4viddf.hyperbridge.service
+
+import android.Manifest
+import android.content.Context
+import android.util.Log
+import androidx.annotation.RequiresPermission
+import androidx.core.app.NotificationCompat
+import com.d4viddf.hyperbridge.R
+import com.d4viddf.hyperbridge.data.AppPreferences
+import com.d4viddf.hyperbridge.models.HyperIslandData
+import com.d4viddf.hyperbridge.util.ShizukuManager
+import io.github.d4viddf.hyperisland_kit.HyperIslandNotification
+import io.github.d4viddf.hyperisland_kit.models.ImageTextInfoLeft
+import io.github.d4viddf.hyperisland_kit.models.TextInfo
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+
+
+class PermanentIslandManager(
+ private val context: Context,
+ private val scope: CoroutineScope,
+ private val preferences: AppPreferences
+) {
+ private val TAG = "HyperBridgeDebug"
+ private val PERMANENT_BRIDGE_ID = 9999
+
+ private var isPermanentIslandEnabled = false
+ private var isIslandActive = false
+ private var currentRealNotifications = 0
+ private var hasNativeIsland = false
+ private var currentWidth = 0
+
+ init {
+ scope.launch {
+ preferences.isPermanentIslandEnabledFlow.collectLatest { enabled ->
+ if (isPermanentIslandEnabled != enabled) {
+ isPermanentIslandEnabled = enabled
+ updateState()
+ }
+ }
+ }
+ scope.launch {
+ preferences.permanentIslandWidthFlow.collectLatest { width ->
+ if (currentWidth != width) {
+ currentWidth = width
+ if (isIslandActive) {
+ dispatchPermanentIsland()
+ }
+ }
+ }
+ }
+ }
+
+ fun onActiveNotificationsChanged(count: Int, hasNative: Boolean = false) {
+ currentRealNotifications = count
+ hasNativeIsland = hasNative
+ updateState()
+ }
+
+ private fun updateState() {
+ if (isPermanentIslandEnabled && currentRealNotifications == 0 && !hasNativeIsland) {
+ if (!isIslandActive) {
+ dispatchPermanentIsland()
+ isIslandActive = true
+ }
+ } else {
+ if (isIslandActive) {
+ removePermanentIsland()
+ isIslandActive = false
+ }
+ }
+ }
+
+ @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS)
+ private fun dispatchPermanentIsland() {
+ try {
+ Log.d(TAG, "Dispatching permanent island")
+
+ val builder = HyperIslandNotification.Builder(context, "permanent_island", "Permanent Island")
+
+ // Should not be dismissible and shouldn't show in shade
+ builder.setEnableFloat(false)
+ builder.setIslandConfig(timeout = 86400000, dismissible = false, highlightColor = "#FFFFFF", expandedTimeMs = 0)
+ builder.setShowNotification(false)
+ builder.setReopen(true)
+ builder.setIslandFirstFloat(false)
+
+ // Only big paramislands with empty values for textonleft and picKey = null
+ // Use width spaces to change width
+ val emptyString = "\u00A0".repeat(currentWidth)
+ builder.setBigIslandInfo(
+ left = ImageTextInfoLeft(1, null, TextInfo(emptyString, emptyString)),
+ right = null
+ )
+ builder.setSmallIsland("")
+
+ val data = HyperIslandData(builder.buildResourceBundle(), builder.buildJsonParam())
+
+ val notifBuilder = NotificationCompat.Builder(context, "hyper_bridge_notification_channel")
+ .setSmallIcon(R.drawable.ic_launcher_foreground)
+ .setContentTitle("Permanent Island")
+ .setContentText("Empty Island")
+ .setPriority(NotificationCompat.PRIORITY_MIN)
+ .setOngoing(true)
+
+ notifBuilder.addExtras(data.resources)
+
+ val notification = notifBuilder.build()
+ notification.extras.putString("miui.focus.param", data.jsonParam)
+
+ ShizukuManager.notify(context, PERMANENT_BRIDGE_ID, notification)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error dispatching permanent island", e)
+ }
+ }
+
+ private fun removePermanentIsland() {
+ try {
+ Log.d(TAG, "Removing permanent island")
+ ShizukuManager.cancel(context, PERMANENT_BRIDGE_ID)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error removing permanent island", e)
+ }
+ }
+}
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/service/WidgetOverlayService.kt b/app/src/main/java/com/d4viddf/hyperbridge/service/WidgetOverlayService.kt
index c4e4d63..483fb19 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/service/WidgetOverlayService.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/service/WidgetOverlayService.kt
@@ -173,7 +173,7 @@ class WidgetOverlayService : Service() {
// Pass JSON param for the Island UI
notification.extras.putString("miui.focus.param", data.jsonParam)
- NotificationManagerCompat.from(this).notify(notificationId, notification)
+ com.d4viddf.hyperbridge.util.ShizukuManager.notify(this, notificationId, notification)
}
private fun createWidgetChannel() {
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/service/translators/BaseTranslator.kt b/app/src/main/java/com/d4viddf/hyperbridge/service/translators/BaseTranslator.kt
index a63c9b8..316d2eb 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/service/translators/BaseTranslator.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/service/translators/BaseTranslator.kt
@@ -1,6 +1,7 @@
package com.d4viddf.hyperbridge.service.translators
import android.app.Notification
+import android.app.PendingIntent
import android.app.Person
import android.content.Context
import android.graphics.Bitmap
@@ -16,7 +17,6 @@ import android.graphics.RectF
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.graphics.drawable.Icon
-import android.os.Build
import android.os.Bundle
import android.os.Parcelable
import android.service.notification.StatusBarNotification
@@ -39,6 +39,7 @@ import io.github.d4viddf.hyperisland_kit.HyperAction
import io.github.d4viddf.hyperisland_kit.HyperPicture
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.abs
+import androidx.core.graphics.get
abstract class BaseTranslator(
protected val context: Context,
@@ -50,21 +51,11 @@ abstract class BaseTranslator(
private val appColorCache = ConcurrentHashMap()
protected inline fun Bundle.getParcelableCompat(key: String): T? {
- return if (Build.VERSION.SDK_INT >= 33) {
- getParcelable(key, T::class.java)
- } else {
- @Suppress("DEPRECATION")
- getParcelable(key)
- }
+ return getParcelable(key, T::class.java)
}
protected inline fun Bundle.getParcelableArrayListCompat(key: String): ArrayList? {
- return if (Build.VERSION.SDK_INT >= 33) {
- getParcelableArrayList(key, T::class.java)
- } else {
- @Suppress("DEPRECATION")
- getParcelableArrayList(key)
- }
+ return getParcelableArrayList(key, T::class.java)
}
// --- THEME HELPERS ---
@@ -178,10 +169,52 @@ abstract class BaseTranslator(
}
protected fun resolveIcon(sbn: StatusBarNotification, picKey: String): HyperPicture {
- val originalBitmap = getNotificationBitmap(sbn) ?: createFallbackBitmap()
+ var originalBitmap = getNotificationBitmap(sbn) ?: createFallbackBitmap()
+ if (isBitmapDarkAndMonochrome(originalBitmap)) {
+ originalBitmap = tintBitmap(originalBitmap, Color.WHITE)
+ }
return HyperPicture(picKey, originalBitmap)
}
+ protected fun isBitmapDarkAndMonochrome(bitmap: Bitmap): Boolean {
+ val width = bitmap.width
+ val height = bitmap.height
+ if (width == 0 || height == 0) return false
+
+ var darkPixels = 0
+ var totalPixels = 0
+ var isMonochrome = true
+
+ val stepX = maxOf(1, width / 20)
+ val stepY = maxOf(1, height / 20)
+
+ for (x in 0 until width step stepX) {
+ for (y in 0 until height step stepY) {
+ val pixel = bitmap[x, y]
+ val alpha = Color.alpha(pixel)
+ if (alpha > 50) {
+ totalPixels++
+ val r = Color.red(pixel)
+ val g = Color.green(pixel)
+ val b = Color.blue(pixel)
+
+ val diff = abs(r - g) + abs(g - b) + abs(b - r)
+ if (diff > 45) {
+ isMonochrome = false
+ }
+
+ val luminance = (0.299 * r + 0.587 * g + 0.114 * b)
+ if (luminance < 80) {
+ darkPixels++
+ }
+ }
+ }
+ }
+
+ if (totalPixels == 0 || !isMonochrome) return false
+ return (darkPixels.toFloat() / totalPixels) > 0.7f
+ }
+
// --- THEME APPLICATION LOGIC ---
protected fun applyThemeToActionIcon(source: Bitmap, shapeId: String, paddingPercent: Int, bgColor: Int): Bitmap {
@@ -233,10 +266,9 @@ abstract class BaseTranslator(
protected fun extractBridgeActions(
sbn: StatusBarNotification,
+ config: com.d4viddf.hyperbridge.models.IslandConfig,
theme: HyperTheme? = null,
- mode: ActionDisplayMode = ActionDisplayMode.BOTH,
- hideReplies: Boolean = true,
- useAppOpenForReplies: Boolean = false
+ mode: ActionDisplayMode = ActionDisplayMode.BOTH
): List {
val bridgeActions = mutableListOf()
val actions = sbn.notification.actions ?: return emptyList()
@@ -253,25 +285,29 @@ abstract class BaseTranslator(
actions.forEachIndexed { index, androidAction ->
val hasRemoteInput = androidAction.remoteInputs != null && androidAction.remoteInputs!!.isNotEmpty()
- if (hasRemoteInput && hideReplies) return@forEachIndexed
-
val rawTitle = androidAction.title?.toString() ?: ""
+ val isMarkAsRead = androidAction.semanticAction == Notification.Action.SEMANTIC_ACTION_MARK_AS_READ || rawTitle.equals("mark as read", ignoreCase = true)
+
+ if (config.removeOriginalNotification == true && hasRemoteInput) {
+ return@forEachIndexed
+ }
+
val uniqueKey = "act_${sbn.key.hashCode()}_$index"
- val config = resolveActionConfig(theme, sbn.packageName, rawTitle)
+ val actionConfig = resolveActionConfig(theme, sbn.packageName, rawTitle)
- val finalBgColorInt = if (config?.backgroundColor != null) {
+ val finalBgColorInt = if (actionConfig?.backgroundColor != null) {
try {
- config.backgroundColor.toColorInt()
+ actionConfig.backgroundColor.toColorInt()
} catch(e: Exception) { defaultActionBg }
} else {
defaultActionBg
}
val finalBgColorHex = String.format("#%08X", (0xFFFFFFFF and finalBgColorInt.toLong()))
- val finalTintColorHex = config?.tintColor ?: "#FFFFFF"
+ val finalTintColorHex = actionConfig?.tintColor ?: "#FFFFFF"
- val effectiveMode = when (config?.mode) {
+ val effectiveMode = when (actionConfig?.mode) {
ActionButtonMode.ICON -> ActionDisplayMode.ICON
ActionButtonMode.TEXT -> ActionDisplayMode.TEXT
ActionButtonMode.BOTH -> ActionDisplayMode.BOTH
@@ -285,7 +321,7 @@ abstract class BaseTranslator(
val shouldLoadIcon = (effectiveMode != ActionDisplayMode.TEXT)
var bitmapToUse: Bitmap? = null
- val configIconRes = config?.icon
+ val configIconRes = actionConfig?.icon
if (configIconRes != null && configIconRes.type == ResourceType.LOCAL_FILE && repository != null) {
bitmapToUse = repository.getResourceBitmap(configIconRes)
}
@@ -309,8 +345,22 @@ abstract class BaseTranslator(
hyperPic = HyperPicture("${uniqueKey}_icon", processedBitmap)
}
- val finalIntent = if (hasRemoteInput && useAppOpenForReplies) {
- sbn.notification.contentIntent ?: androidAction.actionIntent
+ val finalIntent = if (hasRemoteInput) {
+ if (config.enableInlineReply != false) {
+ val replyIntent = android.content.Intent(context, com.d4viddf.hyperbridge.receiver.InlineReplyReceiver::class.java).apply {
+ putExtra("pending_intent", androidAction.actionIntent)
+ putExtra("result_key", androidAction.remoteInputs!![0].resultKey)
+ putExtra("package_name", sbn.packageName)
+ }
+ PendingIntent.getBroadcast(
+ context,
+ uniqueKey.hashCode(),
+ replyIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
+ )
+ } else {
+ sbn.notification.contentIntent ?: androidAction.actionIntent
+ }
} else {
androidAction.actionIntent
}
@@ -356,6 +406,21 @@ abstract class BaseTranslator(
val picture = extras.getParcelableCompat(Notification.EXTRA_PICTURE)
if (picture != null) return picture
+ val template = extras.getString(Notification.EXTRA_TEMPLATE)
+ if (template == "android.app.Notification\$MessagingStyle") {
+ val messages = extras.getParcelableArray(Notification.EXTRA_MESSAGES)
+ if (messages != null && messages.isNotEmpty()) {
+ val lastMessage = messages.last() as? Bundle
+ if (lastMessage != null) {
+ val senderPerson = lastMessage.getParcelableCompat("sender_person")
+ if (senderPerson?.icon != null) {
+ val bitmap = loadIconBitmap(senderPerson.icon!!, pkg)
+ if (bitmap != null) return bitmap
+ }
+ }
+ }
+ }
+
if (sbn.notification.category == Notification.CATEGORY_CALL) {
val person = extras.getParcelableCompat(Notification.EXTRA_MESSAGING_PERSON)
?: extras.getParcelableArrayListCompat(Notification.EXTRA_PEOPLE_LIST)?.firstOrNull()
@@ -453,6 +518,20 @@ abstract class BaseTranslator(
}
}
+ protected fun extractTextPercentage(title: String?, text: String?): Int? {
+ val pattern = Regex("""\b(\d{1,3})\s*%""")
+ val textMatch = text?.let { pattern.find(it) }
+ val titleMatch = title?.let { pattern.find(it) }
+ val match = textMatch ?: titleMatch
+ if (match != null) {
+ val value = match.groupValues[1].toIntOrNull()
+ if (value != null && value in 0..100) {
+ return value
+ }
+ }
+ return null
+ }
+
protected fun createFallbackBitmap(): Bitmap = createBitmap(1, 1)
protected fun Drawable.toBitmap(width: Int? = null, height: Int? = null): Bitmap {
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/service/translators/CallTranslator.kt b/app/src/main/java/com/d4viddf/hyperbridge/service/translators/CallTranslator.kt
index 5c78c8a..ccce3dc 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/service/translators/CallTranslator.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/service/translators/CallTranslator.kt
@@ -36,7 +36,7 @@ class CallTranslator(
theme: HyperTheme?
): HyperIslandData {
val extras = sbn.notification.extras
- val title = extras.getString(Notification.EXTRA_TITLE) ?: "Call"
+ val title = extras.getCharSequence(Notification.EXTRA_TITLE)?.toString() ?: "Call"
val isChronometerShown = extras.getBoolean(Notification.EXTRA_SHOW_CHRONOMETER)
val baseTime = sbn.notification.`when`
@@ -92,7 +92,7 @@ class CallTranslator(
builder.setSmallIsland(picKey)
val highlight = resolveColor(theme, sbn.packageName, "#FFFFFF")
- builder.setIslandConfig(highlightColor = highlight)
+ builder.setIslandConfig(highlightColor = highlight, expandedTimeMs = config.floatTimeout)
if (isIncoming) {
builder.setBigIslandInfo(
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/service/translators/DownloadTranslator.kt b/app/src/main/java/com/d4viddf/hyperbridge/service/translators/DownloadTranslator.kt
new file mode 100644
index 0000000..13935a9
--- /dev/null
+++ b/app/src/main/java/com/d4viddf/hyperbridge/service/translators/DownloadTranslator.kt
@@ -0,0 +1,123 @@
+package com.d4viddf.hyperbridge.service.translators
+
+import android.app.Notification
+import android.content.Context
+import android.service.notification.StatusBarNotification
+import com.d4viddf.hyperbridge.R
+import com.d4viddf.hyperbridge.data.theme.ThemeRepository
+import com.d4viddf.hyperbridge.models.HyperIslandData
+import com.d4viddf.hyperbridge.models.IslandConfig
+import com.d4viddf.hyperbridge.models.theme.HyperTheme
+import io.github.d4viddf.hyperisland_kit.HyperIslandNotification
+import io.github.d4viddf.hyperisland_kit.HyperPicture
+import io.github.d4viddf.hyperisland_kit.models.ImageTextInfoLeft
+import io.github.d4viddf.hyperisland_kit.models.ImageTextInfoRight
+import io.github.d4viddf.hyperisland_kit.models.PicInfo
+import io.github.d4viddf.hyperisland_kit.models.TextInfo
+
+class DownloadTranslator(context: Context, repo: ThemeRepository) : BaseTranslator(context, repo) {
+
+ private val finishKeywords by lazy {
+ context.resources.getStringArray(R.array.progress_finish_keywords).toList()
+ }
+
+ fun translate(
+ sbn: StatusBarNotification,
+ title: String,
+ picKey: String,
+ config: IslandConfig,
+ theme: HyperTheme?,
+ isUpdate: Boolean
+ ): HyperIslandData {
+
+ val themeProgressColor = theme?.defaultProgress?.activeColor
+ ?: resolveColor(theme, sbn.packageName, "#007AFF")
+
+ val themeFinishColor = theme?.defaultProgress?.finishedColor
+ ?: resolveColor(theme, sbn.packageName, "#34C759")
+
+ val customTick = getThemeBitmap(theme, "tick_icon")
+
+ val builder = HyperIslandNotification.Builder(context, "bridge_${sbn.packageName}", title)
+
+ builder.setShowNotification(config.isShowShade ?: true)
+
+ val isFloatEnabled = config.isFloat ?: false
+ builder.setEnableFloat(isFloatEnabled && !isUpdate)
+ builder.setIslandFirstFloat(config.isFloat ?: false)
+
+ val extras = sbn.notification.extras
+ val max = extras.getInt(Notification.EXTRA_PROGRESS_MAX, 0)
+ val current = extras.getInt(Notification.EXTRA_PROGRESS, 0)
+ val indeterminate = extras.getBoolean(Notification.EXTRA_PROGRESS_INDETERMINATE)
+ val textContent = (extras.getCharSequence(Notification.EXTRA_TEXT)?.toString() ?: "")
+
+ val textPercent = extractTextPercentage(title, textContent)
+ val percent = (if (max > 0) {
+ ((current.toFloat() / max.toFloat()) * 100).toInt()
+ } else {
+ textPercent ?: 0
+ }).coerceIn(0, 100)
+ val isIndeterminate = indeterminate && textPercent == null
+ val isTextFinished = finishKeywords.any { textContent.contains(it, ignoreCase = true) }
+ val isFinished = percent >= 100 || isTextFinished
+
+ val tickKey = "${picKey}_tick"
+ val hiddenKey = "hidden_pixel"
+
+ builder.addPicture(resolveIcon(sbn, picKey))
+ builder.addPicture(getTransparentPicture(hiddenKey))
+
+ if (isFinished) {
+ if (customTick != null) {
+ builder.addPicture(HyperPicture(tickKey, customTick))
+ } else {
+ builder.addPicture(getColoredPicture(tickKey, R.drawable.rounded_check_circle_24, themeFinishColor))
+ }
+ }
+
+ val actions = extractBridgeActions(sbn, config, theme)
+
+ builder.setChatInfo(
+ title = title,
+ content = if (isFinished) context.getString(R.string.download_complete) else textContent,
+ pictureKey = picKey,
+ appPkg = sbn.packageName
+ )
+
+ if (!isFinished && !isIndeterminate) {
+ builder.setProgressBar(percent, themeProgressColor)
+ }
+
+ if (isFinished) {
+ builder.setBigIslandInfo(
+ left = ImageTextInfoLeft(1, PicInfo(1, hiddenKey)),
+ right = ImageTextInfoRight(2, PicInfo(1, tickKey))
+ )
+ builder.setSmallIsland(tickKey)
+ builder.setIslandConfig(timeout = config.timeout , dismissible = true, expandedTimeMs = if (isFloatEnabled) config.floatTimeout else null)
+ } else {
+ if (isIndeterminate) {
+ builder.setBigIslandInfo(
+ left = ImageTextInfoLeft(1, PicInfo(1, picKey), TextInfo("", "")),
+ right = ImageTextInfoRight(1, PicInfo(1, hiddenKey), TextInfo(title, context.getString(R.string.downloading)))
+ )
+ builder.setSmallIsland(picKey)
+ } else {
+ builder.setBigIslandProgressCircle(picKey, "", percent, themeProgressColor, true)
+ builder.setSmallIslandCircularProgress(picKey, percent, themeProgressColor, isCCW = true)
+ }
+ }
+
+ val highlight = resolveColor(theme, sbn.packageName, themeProgressColor)
+ builder.setIslandConfig(timeout = config.timeout, highlightColor = highlight, expandedTimeMs = config.floatTimeout)
+ actions.forEach { it.actionImage?.let { pic -> builder.addPicture(pic) } }
+ val hyperActions = actions.map { it.action }.toTypedArray()
+ hyperActions.forEach {
+ builder.addAction(it)
+ }
+ hyperActions.forEach { builder.addHiddenAction(it) }
+
+ return HyperIslandData(builder.buildResourceBundle(), builder.buildJsonParam())
+ }
+}
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/service/translators/LiveUpdateTranslator.kt b/app/src/main/java/com/d4viddf/hyperbridge/service/translators/LiveUpdateTranslator.kt
index e83e514..c6593a1 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/service/translators/LiveUpdateTranslator.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/service/translators/LiveUpdateTranslator.kt
@@ -2,13 +2,14 @@ package com.d4viddf.hyperbridge.service.translators
import android.app.Notification
import android.content.Context
-import android.graphics.Bitmap
import android.service.notification.StatusBarNotification
import androidx.core.app.NotificationCompat
import androidx.core.graphics.drawable.IconCompat
+import androidx.core.graphics.toColorInt
import com.d4viddf.hyperbridge.R
import com.d4viddf.hyperbridge.data.theme.ThemeRepository
-import com.d4viddf.hyperbridge.models.IslandConfig
+import com.d4viddf.hyperbridge.models.NavContent
+import com.d4viddf.hyperbridge.models.NotificationType
class LiveUpdateTranslator(
context: Context,
@@ -16,98 +17,170 @@ class LiveUpdateTranslator(
) : BaseTranslator(context, repo) {
fun translateToLiveUpdate(
- sbn: StatusBarNotification,
- config: IslandConfig,
- channelId: String
+ sbn: StatusBarNotification?,
+ channelId: String,
+ type: NotificationType,
+ navRight: NavContent? = null,
+ config: com.d4viddf.hyperbridge.models.IslandConfig? = null
): NotificationCompat.Builder {
- val original = sbn.notification
- val extras = original.extras
-
- val title = extras.getCharSequence(Notification.EXTRA_TITLE)?.toString() ?: ""
- val text = extras.getCharSequence(Notification.EXTRA_TEXT)?.toString() ?: ""
-
- var progressMax = extras.getInt(Notification.EXTRA_PROGRESS_MAX, 0)
- var progress = extras.getInt(Notification.EXTRA_PROGRESS, 0)
- var indeterminate = extras.getBoolean(Notification.EXTRA_PROGRESS_INDETERMINATE, false)
-
- // Identify if it's media so we can use the title for the Island chip
- val isMedia = extras.containsKey(Notification.EXTRA_MEDIA_SESSION) ||
- extras.getString(Notification.EXTRA_TEMPLATE)?.contains("MediaStyle") == true
-
- // [FIXED] Media apps often leave random progress flags in the background.
- // Force them off so it doesn't show an indeterminate loading bar!
- if (isMedia) {
- progressMax = 0
- progress = 0
- indeterminate = false
- }
- // Force category for Android 16 promotion limits
- val validCategory = if (original.category.isNullOrEmpty() || original.category == NotificationCompat.CATEGORY_SERVICE) {
- if (progressMax > 0 || indeterminate) NotificationCompat.CATEGORY_PROGRESS else NotificationCompat.CATEGORY_TRANSPORT
- } else {
- original.category
- }
+ val original = sbn?.notification
+ val extras = original?.extras
+
+ val title = extras?.getCharSequence(Notification.EXTRA_TITLE)?.toString() ?: ""
+ val text = extras?.getCharSequence(Notification.EXTRA_TEXT)?.toString() ?: ""
+
+ val progressMax = extras?.getInt(Notification.EXTRA_PROGRESS_MAX, 0) ?: 0
+ val progress = extras?.getInt(Notification.EXTRA_PROGRESS, 0) ?: 0
+ val indeterminate = extras?.getBoolean(Notification.EXTRA_PROGRESS_INDETERMINATE, false) ?: false
val builder = NotificationCompat.Builder(context, channelId)
- .setSmallIcon(original.smallIcon?.let { IconCompat.createFromIcon(context, it) } ?: IconCompat.createWithResource(context, R.drawable.ic_launcher_foreground))
.setContentTitle(title)
.setContentText(text)
.setOngoing(true)
- .setOnlyAlertOnce(true)
- .setCategory(validCategory)
- .setContentIntent(original.contentIntent)
+ .setCategory(original?.category)
- // --- ADD PICTURE / LARGE ICON ---
- if (android.os.Build.VERSION.SDK_INT >= 23 && original.getLargeIcon() != null) {
- builder.setLargeIcon(original.getLargeIcon())
- } else {
- @Suppress("DEPRECATION")
- val picture = extras.getParcelable(Notification.EXTRA_PICTURE)
- if (picture != null) builder.setLargeIcon(picture)
+ original?.contentIntent?.let { builder.setContentIntent(it) }
+
+ // --- THEME COLOR & ICON INJECTION ---
+ val theme = repository?.activeTheme?.value
+
+ val originalBitmap = sbn?.let { getNotificationBitmap(it) }
+ if (originalBitmap != null) {
+ builder.setLargeIcon(originalBitmap)
}
- // Carry over the original timestamp (Standard behavior)
- if (original.`when` > 0) {
- builder.setWhen(original.`when`)
- builder.setShowWhen(true)
+ if (type == NotificationType.NAVIGATION) {
+ // 1. Inject Theme Nav Color
+ val themeColorStr = theme?.defaultNavigation?.progressBarColor
+ ?: resolveColor(theme, sbn?.packageName, "#34C759") // Green fallback
+
+ val themeColorInt = try {
+ themeColorStr.toColorInt()
+ } catch (_: Exception) {
+ original?.color ?: 0
+ }
+ builder.setColor(themeColorInt)
+
+ // 2. Inject Theme Nav Icon
+ val navStartBitmap = getThemeBitmap(theme, "nav_start")
+ if (navStartBitmap != null) {
+ builder.setSmallIcon(IconCompat.createWithBitmap(navStartBitmap))
+ } else {
+ builder.setSmallIcon(original?.smallIcon?.let { IconCompat.createFromIcon(context, it) } ?: IconCompat.createWithResource(context, R.drawable.ic_launcher_foreground))
+ }
+ } else {
+ // Standard fallback for non-navigation
+ builder.setColor(original?.color ?: 0)
+ builder.setSmallIcon(original?.smallIcon?.let { IconCompat.createFromIcon(context, it) } ?: IconCompat.createWithResource(context, R.drawable.ic_launcher_foreground))
}
- // --- COPY BUTTONS ---
- original.actions?.forEach { action ->
- val iconCompat = if ( action.getIcon() != null) {
+ // --- ACTIONS ---
+ val rawActions = original?.actions ?: emptyArray()
+ rawActions.forEachIndexed { index, action ->
+ val iconCompat = if (action.getIcon() != null) {
IconCompat.createFromIcon(context, action.getIcon()!!)
} else {
IconCompat.createWithResource(context, action.icon)
}
- builder.addAction(NotificationCompat.Action.Builder(iconCompat, action.title, action.actionIntent).build())
+
+ val hasRemoteInput = action.remoteInputs != null && action.remoteInputs!!.isNotEmpty()
+ val finalIntent = if (hasRemoteInput) {
+ if (config?.enableInlineReply != false) {
+ val uniqueKey = "act_${sbn?.key.hashCode()}_$index"
+ val replyIntent = android.content.Intent(context, com.d4viddf.hyperbridge.receiver.InlineReplyReceiver::class.java).apply {
+ putExtra("pending_intent", action.actionIntent)
+ putExtra("result_key", action.remoteInputs!![0].resultKey)
+ putExtra("package_name", sbn?.packageName)
+ }
+ android.app.PendingIntent.getBroadcast(
+ context,
+ uniqueKey.hashCode(),
+ replyIntent,
+ android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_MUTABLE
+ )
+ } else {
+ original?.contentIntent ?: action.actionIntent
+ }
+ } else {
+ action.actionIntent
+ }
+
+ builder.addAction(NotificationCompat.Action.Builder(iconCompat, action.title, finalIntent).build())
}
// --- APPLY STYLES ---
+ // BigTextStyle ensures text isn't completely hidden by the progress bar
+ builder.setStyle(NotificationCompat.BigTextStyle().bigText(text).setBigContentTitle(title))
+
+ // Add back the progress bar to show the user where they are
if (progressMax > 0 || indeterminate) {
builder.setProgress(progressMax, progress, indeterminate)
- } else {
- // Standard notification: Allow expanding text
- builder.setStyle(NotificationCompat.BigTextStyle().bigText(text).setBigContentTitle(title))
}
// --- ANDROID 16 LIVE UPDATE INJECTION ---
- val shortAlertText = generateCriticalShortText(title, text, progress, progressMax, isMedia)
+ val shortAlertText = generateCriticalShortText(title, text, progress, progressMax, type, navRight, sbn)
+
builder.setRequestPromotedOngoing(true)
builder.setShortCriticalText(shortAlertText)
-
return builder
}
- private fun generateCriticalShortText(title: String, text: String, progress: Int, max: Int, isMedia: Boolean): String {
- if (isMedia) return title.ifBlank { "Media" }
+ private fun generateCriticalShortText(
+ title: String,
+ text: String,
+ progress: Int,
+ max: Int,
+ type: NotificationType,
+ navRight: NavContent?,
+ sbn: StatusBarNotification?
+ ): String {
+
+ if (type == NotificationType.MEDIA) return title.ifBlank { "Media" }
+
+ // Advanced Extraction for Navigation Layouts
+ if (type == NotificationType.NAVIGATION && sbn != null) {
+ val extras = sbn.notification.extras
+ val bigText = extras.getCharSequence(Notification.EXTRA_BIG_TEXT)?.toString()?.replace("\n", " ")?.trim() ?: ""
+ val subText = extras.getCharSequence(Notification.EXTRA_SUB_TEXT)?.toString()?.replace("\n", " ")?.trim() ?: ""
+
+ val timeRegex = Regex("(\\d{1,2}:\\d{2})|(\\d+h\\s*\\d+m)", RegexOption.IGNORE_CASE)
+ val distanceRegex = Regex("^\\d+([,.]\\d+)?\\s*(m|km|ft|mi|yd|yards|miles|meters)", RegexOption.IGNORE_CASE)
+
+ var distance = ""
+ var eta = ""
+
+ // Extract ETA
+ if (timeRegex.containsMatchIn(subText)) eta = subText
+ else if (timeRegex.containsMatchIn(text) && !distanceRegex.containsMatchIn(text)) eta = text
+
+ // Extract Distance
+ val candidates = listOf(bigText, title, text).filter { it.isNotEmpty() }
+ val contentSource = candidates.firstOrNull { str -> distanceRegex.containsMatchIn(str) } ?: title.ifEmpty { text }
+
+ if (distanceRegex.containsMatchIn(contentSource)) {
+ distanceRegex.find(contentSource)?.let { distance = it.value }
+ }
+
+ // Return the value based on the user's customized Right Side layout!
+ return when (navRight) {
+ NavContent.ETA -> eta.ifEmpty { distance }
+ NavContent.DISTANCE -> distance.ifEmpty { eta }
+ NavContent.DISTANCE_ETA -> listOf(distance, eta).filter { it.isNotEmpty() }.joinToString(" • ")
+ NavContent.INSTRUCTION -> title
+ else -> eta.ifEmpty { distance }.ifEmpty { title } // Fallback
+ }
+ }
+ // Standard Progress Fallback (Only applied if NOT Navigation)
+ val textPercent = extractTextPercentage(title, text)
if (max > 0) return "${(progress * 100) / max}%"
+ if (textPercent != null) return "$textPercent%"
+ // Timer Fallback
val timeRegex = Regex("(\\d+\\s*(min|m))", RegexOption.IGNORE_CASE)
timeRegex.find(text)?.let { return it.groupValues[1] }
- timeRegex.find(title)?.let { return it.groupValues[1] }
- return title.ifBlank { text }.ifBlank { "Active" }
+ return title
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/service/translators/MediaTranslator.kt b/app/src/main/java/com/d4viddf/hyperbridge/service/translators/MediaTranslator.kt
index a5777dc..f405197 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/service/translators/MediaTranslator.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/service/translators/MediaTranslator.kt
@@ -152,7 +152,7 @@ class MediaTranslator(context: Context) : BaseTranslator(context) {
textInfo = TextInfo(title = "", content = "")
)
)
- builder.setIslandConfig(highlightColor = containerColorHex)
+ builder.setIslandConfig(highlightColor = containerColorHex, expandedTimeMs = config.floatTimeout)
builder.setHideDeco(true).setReopen(true).setShowSmallIcon(true)
return HyperIslandData(builder.buildResourceBundle(), builder.buildJsonParam())
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/service/translators/MessageTranslator.kt b/app/src/main/java/com/d4viddf/hyperbridge/service/translators/MessageTranslator.kt
new file mode 100644
index 0000000..bdd622b
--- /dev/null
+++ b/app/src/main/java/com/d4viddf/hyperbridge/service/translators/MessageTranslator.kt
@@ -0,0 +1,124 @@
+package com.d4viddf.hyperbridge.service.translators
+
+import android.app.Notification
+import android.content.Context
+import android.service.notification.StatusBarNotification
+import com.d4viddf.hyperbridge.R
+import com.d4viddf.hyperbridge.data.theme.ThemeRepository
+import com.d4viddf.hyperbridge.models.HyperIslandData
+import com.d4viddf.hyperbridge.models.IslandConfig
+import com.d4viddf.hyperbridge.models.theme.HyperTheme
+import io.github.d4viddf.hyperisland_kit.HyperAction
+import io.github.d4viddf.hyperisland_kit.HyperIslandNotification
+import io.github.d4viddf.hyperisland_kit.models.ImageTextInfoLeft
+import io.github.d4viddf.hyperisland_kit.models.ImageTextInfoRight
+import io.github.d4viddf.hyperisland_kit.models.PicInfo
+import io.github.d4viddf.hyperisland_kit.models.TextInfo
+
+class MessageTranslator(
+ context: Context,
+ repo: ThemeRepository
+) : BaseTranslator(context, repo) {
+
+ fun translate(
+ sbn: StatusBarNotification,
+ title: String,
+ text: String,
+ picKey: String,
+ config: IslandConfig,
+ theme: HyperTheme?
+ ): HyperIslandData {
+ val extras = sbn.notification.extras
+ val template = extras.getString(Notification.EXTRA_TEMPLATE) ?: ""
+ val subText = extras.getString(Notification.EXTRA_SUB_TEXT) ?: ""
+
+ val isMedia = template.contains("MediaStyle")
+ val isCall = sbn.notification.category == Notification.CATEGORY_CALL
+
+ val displayContent = when {
+ isMedia -> context.getString(R.string.status_now_playing)
+ isCall && subText.isNotEmpty() -> "$text • $subText"
+ subText.isNotEmpty() -> if (text.isNotEmpty()) "$text • $subText" else subText
+ else -> text
+ }
+
+ val highlightColor = resolveColor(theme, sbn.packageName, "#FFFFFF")
+
+ val builder = HyperIslandNotification.Builder(context, "bridge_${sbn.packageName}", title)
+
+ // --- CONFIGURATION ---
+ builder.setEnableFloat(config.isFloat ?: false)
+ builder.setIslandConfig(timeout = config.timeout , dismissible = true, highlightColor = highlightColor, expandedTimeMs = config.floatTimeout)
+ builder.setShowNotification(config.isShowShade ?: false)
+ builder.setReopen(true)
+ builder.setIslandFirstFloat(config.isFloat ?: false)
+
+ val hiddenKey = "hidden_pixel"
+ builder.addPicture(resolveIcon(sbn, picKey))
+ builder.addPicture(getTransparentPicture(hiddenKey))
+
+ val bridgeActions = extractBridgeActions(
+ sbn = sbn,
+ config = config,
+ theme = theme
+ )
+
+ // Base Info (Shade)
+ builder.setBaseInfo(
+ type = 2,
+ title = title,
+ content = displayContent
+ )
+ builder.setIconTextInfo(
+ picKey= picKey,
+ title = title,
+ content = displayContent
+ )
+
+ // Island Layout
+ if (isMedia) {
+ builder.setBigIslandInfo(left = ImageTextInfoLeft(1, PicInfo(1, picKey), TextInfo("", "")))
+ } else {
+ builder.setBigIslandInfo(
+ left = ImageTextInfoLeft(1, PicInfo(1, picKey), TextInfo("", "")),
+ right = ImageTextInfoRight(1, PicInfo(1, hiddenKey), TextInfo(title, displayContent))
+ )
+ }
+
+ builder.setSmallIsland(picKey)
+
+ // Add Actions
+ if (bridgeActions.isNotEmpty()) {
+ // [FIX] Specific configuration for Shade Text Buttons:
+ // 1. actionBgColor = null -> Transparent Background
+ // 2. titleColor = "#FFFFFF" -> White Text (Neutral/No Color)
+ val textActions = bridgeActions.map { it.action }.map { original ->
+ HyperAction(
+ key = original.key,
+ title = original.title,
+ icon = original.icon,
+ pendingIntent = original.pendingIntent,
+ actionIntentType = original.actionIntentType,
+ actionBgColor = null,
+ titleColor = "#FFFFFF"
+ )
+ }.toTypedArray()
+
+ // Set actions visible in shade
+ builder.setTextButtons(*textActions)
+
+ // Register them internally
+ textActions.forEach {
+ builder.addHiddenAction(it)
+ }
+
+ // Register any custom icons if available
+ bridgeActions.forEach {
+ it.actionImage?.let { pic -> builder.addPicture(pic) }
+ }
+ }
+
+
+ return HyperIslandData(builder.buildResourceBundle(), builder.buildJsonParam())
+ }
+}
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/service/translators/NavTranslator.kt b/app/src/main/java/com/d4viddf/hyperbridge/service/translators/NavTranslator.kt
index e4c691e..3f44506 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/service/translators/NavTranslator.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/service/translators/NavTranslator.kt
@@ -64,7 +64,7 @@ class NavTranslator(context: Context, repo: ThemeRepository) : BaseTranslator(co
else if (isTimeInfo(text) && !isDistanceInfo(text)) eta = text
val candidates = listOf(bigText, title, text).filter { it.isNotEmpty() }
- val contentSource = candidates.firstOrNull { str -> distanceRegex.containsMatchIn(str) } ?: if (title.isNotEmpty()) title else text
+ val contentSource = candidates.firstOrNull { str -> distanceRegex.containsMatchIn(str) } ?: title.ifEmpty { text }
if (isDistanceInfo(contentSource)) {
val match = distanceRegex.find(contentSource)
@@ -81,7 +81,6 @@ class NavTranslator(context: Context, repo: ThemeRepository) : BaseTranslator(co
// 4. Build Notification
val builder = HyperIslandNotification.Builder(context, "bridge_${sbn.packageName}", instruction)
builder.setEnableFloat(config.isFloat ?: false)
- builder.setIslandConfig(timeout = config.timeout)
builder.setShowNotification(config.isShowShade ?: true)
builder.setIslandFirstFloat(config.isFloat ?: false)
@@ -106,17 +105,12 @@ class NavTranslator(context: Context, repo: ThemeRepository) : BaseTranslator(co
}
// 5. Actions (Important: Nav needs Text Buttons like "Exit")
- // We use extractBridgeActions from BaseTranslator but customize the mode slightly if needed
val rawActions = sbn.notification.actions ?: emptyArray()
val actionKeys = mutableListOf()
rawActions.forEachIndexed { index, action ->
val uniqueKey = "act_${sbn.key.hashCode()}_$index"
- // For Navigation, we usually prefer Text buttons (e.g. "Exit Navigation")
- // So we don't necessarily need the fancy icon shape logic here unless user explicitly styles it.
- // We pass null for background to keep it standard pill style or text only.
-
val hyperAction = HyperAction(
key = uniqueKey,
title = action.title?.toString() ?: "",
@@ -131,8 +125,7 @@ class NavTranslator(context: Context, repo: ThemeRepository) : BaseTranslator(co
actionKeys.add(uniqueKey)
}
- // 6. Shade Layout (The Fix)
- // Revert to setBaseInfo (Type 1) which supports standard notifications with actions.
+ // 6. Shade Layout
val shadeContent = listOf(distance, eta).filter { it.isNotEmpty() }.joinToString(" • ")
builder.setBaseInfo(
@@ -153,7 +146,7 @@ class NavTranslator(context: Context, repo: ThemeRepository) : BaseTranslator(co
)
}
- // 8. Island Layout (Dynamic)
+ // 8. Island Layout (Dynamic from App Preference / Global)
fun getTextInfo(type: NavContent): TextInfo {
return when (type) {
NavContent.INSTRUCTION -> TextInfo(instruction, null)
@@ -170,7 +163,7 @@ class NavTranslator(context: Context, repo: ThemeRepository) : BaseTranslator(co
)
builder.setSmallIsland(picKey)
- builder.setIslandConfig(highlightColor = theme?.global?.highlightColor)
+ builder.setIslandConfig(highlightColor = theme?.global?.highlightColor, expandedTimeMs = config.floatTimeout)
return HyperIslandData(builder.buildResourceBundle(), builder.buildJsonParam())
}
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/service/translators/ProgressTranslator.kt b/app/src/main/java/com/d4viddf/hyperbridge/service/translators/ProgressTranslator.kt
index 517719b..2eff700 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/service/translators/ProgressTranslator.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/service/translators/ProgressTranslator.kt
@@ -26,7 +26,8 @@ class ProgressTranslator(context: Context, repo: ThemeRepository) : BaseTranslat
title: String,
picKey: String,
config: IslandConfig,
- theme: HyperTheme?
+ theme: HyperTheme?,
+ isUpdate: Boolean
): HyperIslandData {
// [FIX] Prioritize Progress Colors -> Global Highlight -> Default
@@ -39,18 +40,27 @@ class ProgressTranslator(context: Context, repo: ThemeRepository) : BaseTranslat
val customTick = getThemeBitmap(theme, "tick_icon")
val builder = HyperIslandNotification.Builder(context, "bridge_${sbn.packageName}", title)
- builder.setEnableFloat(config.isFloat ?: false)
- builder.setIslandConfig(timeout = config.timeout)
+
builder.setShowNotification(config.isShowShade ?: true)
+
+ // Always enable float if the user wants it, but only "First Float" (expand) on the initial appearance
+ val isFloatEnabled = config.isFloat ?: false
+ builder.setEnableFloat(isFloatEnabled && !isUpdate)
builder.setIslandFirstFloat(config.isFloat ?: false)
val extras = sbn.notification.extras
val max = extras.getInt(Notification.EXTRA_PROGRESS_MAX, 0)
val current = extras.getInt(Notification.EXTRA_PROGRESS, 0)
val indeterminate = extras.getBoolean(Notification.EXTRA_PROGRESS_INDETERMINATE)
- val textContent = (extras.getString(Notification.EXTRA_TEXT) ?: "")
+ val textContent = (extras.getCharSequence(Notification.EXTRA_TEXT)?.toString() ?: "")
- val percent = if (max > 0) ((current.toFloat() / max.toFloat()) * 100).toInt() else 0
+ val textPercent = extractTextPercentage(title, textContent)
+ val percent = (if (max > 0) {
+ ((current.toFloat() / max.toFloat()) * 100).toInt()
+ } else {
+ textPercent ?: 0
+ }).coerceIn(0, 100)
+ val isIndeterminate = indeterminate && textPercent == null
val isTextFinished = finishKeywords.any { textContent.contains(it, ignoreCase = true) }
val isFinished = percent >= 100 || isTextFinished
@@ -69,7 +79,7 @@ class ProgressTranslator(context: Context, repo: ThemeRepository) : BaseTranslat
}
}
- val actions = extractBridgeActions(sbn, theme)
+ val actions = extractBridgeActions(sbn, config, theme)
builder.setChatInfo(
title = title,
@@ -78,32 +88,32 @@ class ProgressTranslator(context: Context, repo: ThemeRepository) : BaseTranslat
appPkg = sbn.packageName
)
- if (!isFinished && !indeterminate) {
+ if (!isFinished && !isIndeterminate) {
builder.setProgressBar(percent, themeProgressColor)
}
if (isFinished) {
builder.setBigIslandInfo(
- left = ImageTextInfoLeft(1, PicInfo(1, hiddenKey), TextInfo("", "")),
- right = ImageTextInfoRight(1, PicInfo(1, tickKey), TextInfo("Finished", title))
+ left = ImageTextInfoLeft(1, PicInfo(1, hiddenKey)),
+ right = ImageTextInfoRight(2, PicInfo(1, tickKey))
)
builder.setSmallIsland(tickKey)
+ builder.setIslandConfig(timeout = config.timeout , dismissible = true, expandedTimeMs = if (isFloatEnabled) config.floatTimeout else null)
} else {
- if (indeterminate) {
+ if (isIndeterminate) {
builder.setBigIslandInfo(
left = ImageTextInfoLeft(1, PicInfo(1, picKey), TextInfo("", "")),
right = ImageTextInfoRight(1, PicInfo(1, hiddenKey), TextInfo(title, "Processing..."))
)
builder.setSmallIsland(picKey)
} else {
- builder.setBigIslandProgressCircle(picKey, "$percent%", percent, themeProgressColor, true)
+ builder.setBigIslandProgressCircle(picKey, "", percent, themeProgressColor, true)
builder.setSmallIslandCircularProgress(picKey, percent, themeProgressColor, isCCW = true)
}
}
val highlight = resolveColor(theme, sbn.packageName, themeProgressColor)
- builder.setIslandConfig(highlightColor = highlight)
-
+ builder.setIslandConfig(timeout = config.timeout, highlightColor = highlight, expandedTimeMs = config.floatTimeout)
actions.forEach { it.actionImage?.let { pic -> builder.addPicture(pic) } }
val hyperActions = actions.map { it.action }.toTypedArray()
hyperActions.forEach {
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/service/translators/StandardTranslator.kt b/app/src/main/java/com/d4viddf/hyperbridge/service/translators/StandardTranslator.kt
index d225bbf..0f85740 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/service/translators/StandardTranslator.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/service/translators/StandardTranslator.kt
@@ -35,7 +35,6 @@ class StandardTranslator(
val isMedia = template.contains("MediaStyle")
val isCall = sbn.notification.category == Notification.CATEGORY_CALL
- val displayTitle = title
val displayContent = when {
isMedia -> context.getString(R.string.status_now_playing)
isCall && subText.isNotEmpty() -> "$text • $subText"
@@ -45,12 +44,12 @@ class StandardTranslator(
val highlightColor = resolveColor(theme, sbn.packageName, "#FFFFFF")
- val builder = HyperIslandNotification.Builder(context, "bridge_${sbn.packageName}", displayTitle)
+ val builder = HyperIslandNotification.Builder(context, "bridge_${sbn.packageName}", title)
// --- CONFIGURATION ---
builder.setEnableFloat(config.isFloat ?: false)
- builder.setIslandConfig(timeout = config.timeout)
- builder.setShowNotification(config.isShowShade ?: true)
+ builder.setIslandConfig(timeout = config.timeout , dismissible = true, highlightColor = highlightColor, expandedTimeMs = config.floatTimeout)
+ builder.setShowNotification(config.isShowShade ?: false)
builder.setReopen(true)
builder.setIslandFirstFloat(config.isFloat ?: false)
@@ -60,20 +59,19 @@ class StandardTranslator(
val bridgeActions = extractBridgeActions(
sbn = sbn,
- theme = theme,
- hideReplies = false,
- useAppOpenForReplies = true
+ config = config,
+ theme = theme
)
// Base Info (Shade)
builder.setBaseInfo(
type = 2,
- title = displayTitle,
+ title = title,
content = displayContent
)
builder.setIconTextInfo(
picKey= picKey,
- title = displayTitle,
+ title = title,
content = displayContent
)
@@ -83,7 +81,7 @@ class StandardTranslator(
} else {
builder.setBigIslandInfo(
left = ImageTextInfoLeft(1, PicInfo(1, picKey), TextInfo("", "")),
- right = ImageTextInfoRight(1, PicInfo(1, hiddenKey), TextInfo(displayTitle, displayContent))
+ right = ImageTextInfoRight(1, PicInfo(1, hiddenKey), TextInfo(title, displayContent))
)
}
@@ -120,7 +118,6 @@ class StandardTranslator(
}
}
- builder.setIslandConfig(highlightColor = highlightColor)
return HyperIslandData(builder.buildResourceBundle(), builder.buildJsonParam())
}
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/service/translators/TimerTranslator.kt b/app/src/main/java/com/d4viddf/hyperbridge/service/translators/TimerTranslator.kt
index 6baeb06..fc9a86b 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/service/translators/TimerTranslator.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/service/translators/TimerTranslator.kt
@@ -40,14 +40,12 @@ class TimerTranslator(context: Context, repo: ThemeRepository) : BaseTranslator(
builder.setIslandConfig(timeout = config.timeout)
builder.setShowNotification(config.isShowShade ?: true)
builder.setIslandFirstFloat(config.isFloat ?: false)
- // Apply Theme Color to Island text
- builder.setIslandConfig(highlightColor = themeHighlight)
val hiddenKey = "hidden_pixel"
builder.addPicture(resolveIcon(sbn, picKey))
builder.addPicture(getTransparentPicture(hiddenKey))
- val actions = extractBridgeActions(sbn)
+ val actions = extractBridgeActions(sbn, config, theme)
builder.setChatInfo(
title = title,
@@ -71,7 +69,7 @@ class TimerTranslator(context: Context, repo: ThemeRepository) : BaseTranslator(
builder.addAction(it.action)
it.actionImage?.let { pic -> builder.addPicture(pic) }
}
- builder.setIslandConfig(highlightColor = theme?.global?.highlightColor)
+ builder.setIslandConfig(highlightColor = theme?.global?.highlightColor, expandedTimeMs = config.floatTimeout)
return HyperIslandData(builder.buildResourceBundle(), builder.buildJsonParam())
}
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/AppListViewModel.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/AppListViewModel.kt
index 05275b3..2d65347 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/ui/AppListViewModel.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/AppListViewModel.kt
@@ -12,14 +12,20 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.d4viddf.hyperbridge.data.AppCacheManager
import com.d4viddf.hyperbridge.data.AppPreferences
+import com.d4viddf.hyperbridge.data.theme.ThemeRepository
import com.d4viddf.hyperbridge.models.IslandConfig
+import com.d4viddf.hyperbridge.models.NavContent
import com.d4viddf.hyperbridge.models.NotificationType
+import com.d4viddf.hyperbridge.models.theme.HyperTheme
+import com.d4viddf.hyperbridge.models.theme.NavigationModule
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -46,6 +52,10 @@ class AppListViewModel(application: Application) : AndroidViewModel(application)
private val preferences = AppPreferences(application)
private val cacheManager = AppCacheManager(application)
+ // [NEW] Theme Repository to resolve behavior overrides
+ private val themeRepo = ThemeRepository(application)
+ val activeTheme: StateFlow = themeRepo.activeTheme
+
private val _installedApps = MutableStateFlow?>(null)
private val _isLoading = MutableStateFlow(true)
@@ -140,6 +150,59 @@ class AppListViewModel(application: Application) : AndroidViewModel(application)
}
}
+// ========================================================================
+ // EFFECTIVE BEHAVIOR LOGIC (THEME vs PREFS)
+ // ========================================================================
+
+ data class EffectiveAppConfig(
+ val isManagedByTheme: Boolean,
+ val activeTypes: Set,
+ val useNativeEngine: Boolean,
+ val navigationOverride: NavigationModule?,
+ val localNavContent: Pair // Added for the bottom sheet
+ )
+
+ /**
+ * Resolves the "true" settings for an app by layering:
+ * Active Theme > Local App Preferences > Global Fallbacks
+ */
+ fun getEffectiveAppConfigFlow(packageName: String): Flow {
+ return combine(
+ preferences.getAppConfigFlow(packageName),
+ preferences.globalNotificationTypesFlow,
+ preferences.getEffectiveNavLayout(packageName), // Gets the fallback-resolved NavContent
+ activeTheme
+ ) { appPrefTypes, globalTypes, effectiveNavContent, theme ->
+
+ val themeOverride = theme?.apps?.get(packageName)
+ val isManaged = themeOverride != null
+
+ // 1. Resolve Types (Theme -> AppPref -> Global)
+ val effectiveTypes = when {
+ themeOverride?.activeNotificationTypes != null -> themeOverride.activeNotificationTypes
+ appPrefTypes != null -> appPrefTypes
+ else -> globalTypes
+ }
+
+ // 2. Resolve Engine
+ val effectiveEngine = when {
+ themeOverride?.useNativeLiveUpdates != null -> themeOverride.useNativeLiveUpdates
+ else -> true // Global Default
+ }
+
+ // 3. Resolve Navigation Visuals (Theme completely overrides local nav preferences)
+ val effectiveNavVisuals = themeOverride?.navigation
+
+ EffectiveAppConfig(
+ isManagedByTheme = isManaged,
+ activeTypes = effectiveTypes,
+ useNativeEngine = effectiveEngine,
+ navigationOverride = effectiveNavVisuals,
+ localNavContent = effectiveNavContent
+ )
+ }
+ }
+
// --- PREFERENCE ACTIONS ---
fun toggleApp(packageName: String, isEnabled: Boolean) {
@@ -148,10 +211,44 @@ class AppListViewModel(application: Application) : AndroidViewModel(application)
}
}
- fun getAppConfig(packageName: String) = preferences.getAppConfig(packageName)
+ // Standard non-flow getter if needed by other parts of the app
+ suspend fun getAppConfig(packageName: String) = preferences.getAppConfigFlow(packageName).first()
+ /**
+ * Updates notification types. Intelligently routes the save to the
+ * Active Theme JSON if the theme is managing the app, otherwise saves locally.
+ */
fun updateAppConfig(pkg: String, type: NotificationType, enabled: Boolean) {
- viewModelScope.launch { preferences.updateAppConfig(pkg, type, enabled) }
+ viewModelScope.launch {
+ val currentTheme = activeTheme.value
+ val themeOverride = currentTheme?.apps?.get(pkg)
+
+ if (currentTheme != null && themeOverride != null) {
+ // The Theme is managing this app! Update the Theme JSON directly.
+ val currentTypes = themeOverride.activeNotificationTypes ?: emptySet()
+ val newTypes = if (enabled) currentTypes + type.name else currentTypes - type.name
+
+ val updatedOverride = themeOverride.copy(activeNotificationTypes = newTypes)
+ val updatedAppsMap = currentTheme.apps.toMutableMap().apply { put(pkg, updatedOverride) }
+ val updatedTheme = currentTheme.copy(apps = updatedAppsMap)
+
+ // Save to disk and reload the active theme state
+ themeRepo.saveTheme(updatedTheme)
+ themeRepo.activateTheme(updatedTheme.id)
+ } else {
+ // Not managed by theme. Save normally to local AppPreferences.
+ preferences.updateAppConfig(pkg, type, enabled)
+ }
+ }
+ }
+
+ /**
+ * Updates the per-app navigation content layout (Distance vs ETA, etc.)
+ */
+ fun updateAppNavLayout(pkg: String, left: NavContent?, right: NavContent?) {
+ viewModelScope.launch {
+ preferences.updateAppNavLayout(pkg, left, right)
+ }
}
// --- ISLAND CONFIG ---
@@ -224,4 +321,27 @@ class AppListViewModel(application: Application) : AndroidViewModel(application)
draw(canvas)
return bitmap
}
+
+ fun updateAppEngine(pkg: String, useNative: Boolean) {
+ viewModelScope.launch {
+ val currentTheme = activeTheme.value
+ val isCustomTheme = currentTheme != null && currentTheme.id.isNotEmpty()
+
+ if (isCustomTheme) {
+ // If a Custom Theme is active, we MUST patch the Theme JSON,
+ // even if it didn't have an override before!
+ val existingOverride = currentTheme.apps[pkg] ?: com.d4viddf.hyperbridge.models.theme.AppThemeOverride()
+ val updatedOverride = existingOverride.copy(useNativeLiveUpdates = useNative)
+
+ val updatedAppsMap = currentTheme.apps.toMutableMap().apply { put(pkg, updatedOverride) }
+ val updatedTheme = currentTheme.copy(apps = updatedAppsMap)
+
+ themeRepo.saveTheme(updatedTheme)
+ themeRepo.activateTheme(updatedTheme.id)
+ } else {
+ // If NO custom theme is active, save to normal AppPreferences
+ preferences.updateAppEnginePreference(pkg, useNative)
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/components/ChangelogDialog.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/components/ChangelogDialog.kt
deleted file mode 100644
index 221b1b6..0000000
--- a/app/src/main/java/com/d4viddf/hyperbridge/ui/components/ChangelogDialog.kt
+++ /dev/null
@@ -1,63 +0,0 @@
-package com.d4viddf.hyperbridge.ui.components
-
-import androidx.compose.foundation.layout.*
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.AutoAwesome
-import androidx.compose.material3.*
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import com.d4viddf.hyperbridge.R
-import com.d4viddf.hyperbridge.util.parseBold // Import the extension
-
-@Composable
-fun ChangelogDialog(
- currentVersionName: String,
- changelogText: String,
- onDismiss: () -> Unit
-) {
- AlertDialog(
- onDismissRequest = onDismiss,
- icon = {
- Icon(
- Icons.Default.AutoAwesome,
- contentDescription = null,
- modifier = Modifier.size(32.dp),
- tint = MaterialTheme.colorScheme.primary
- )
- },
- title = {
- Text(
- text = stringResource(R.string.whats_new_title, currentVersionName),
- fontWeight = FontWeight.Bold,
- style = MaterialTheme.typography.headlineSmall
- )
- },
- text = {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .heightIn(max = 300.dp)
- .verticalScroll(rememberScrollState())
- ) {
- // FIX: Use parseBold() instead of .replace()
- Text(
- text = changelogText.parseBold(),
- style = MaterialTheme.typography.bodyLarge,
- lineHeight = 24.sp,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- },
- confirmButton = {
- Button(onClick = onDismiss) {
- Text(stringResource(R.string.awesome))
- }
- }
- )
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/components/ChangelogSheet.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/components/ChangelogSheet.kt
index cbb1e7a..a3acc9f 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/ui/components/ChangelogSheet.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/components/ChangelogSheet.kt
@@ -32,6 +32,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.d4viddf.hyperbridge.R
import com.d4viddf.hyperbridge.util.parseBold
+import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -41,6 +42,7 @@ fun ChangelogSheet(
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
+ val coroutineScope = androidx.compose.runtime.rememberCoroutineScope()
ModalBottomSheet(
onDismissRequest = onDismiss,
@@ -93,7 +95,12 @@ fun ChangelogSheet(
// --- CLOSE BUTTON ---
Button(
- onClick = onDismiss,
+ onClick = {
+ coroutineScope.launch {
+ sheetState.hide()
+ onDismiss()
+ }
+ },
modifier = Modifier
.fillMaxWidth()
.height(50.dp),
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/components/CommonCards.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/components/CommonCards.kt
index e473868..ec9c960 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/ui/components/CommonCards.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/components/CommonCards.kt
@@ -1,23 +1,33 @@
package com.d4viddf.hyperbridge.ui.components
-import android.content.Context
import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.automirrored.rounded.ArrowForwardIos
import androidx.compose.material.icons.filled.Warning
-import androidx.compose.material3.*
-import androidx.compose.runtime.*
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
-import com.d4viddf.hyperbridge.util.openAutoStartSettings // Correct Import
-import com.d4viddf.hyperbridge.util.openBatterySettings // Correct Import
-// ... (Keep WarningCard and ExpandableOptimizationCard code from previous step) ...
@Composable
fun WarningCard(
title: String,
@@ -47,54 +57,18 @@ fun WarningCard(
}
}
-@Composable
-fun ExpandableOptimizationCard(context: Context) {
- var expanded by remember { mutableStateOf(false) }
- val cardColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f)
- val contentColor = MaterialTheme.colorScheme.onSecondaryContainer
-
- Card(
- colors = CardDefaults.cardColors(containerColor = cardColor),
- shape = RoundedCornerShape(16.dp),
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 8.dp)
- .clickable { expanded = !expanded }
- ) {
- Column(modifier = Modifier.padding(16.dp)) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- Icon(Icons.Default.Settings, null, tint = contentColor)
- Spacer(modifier = Modifier.width(16.dp))
- Text("Xiaomi System Setup", fontWeight = FontWeight.Bold, color = contentColor)
- Spacer(modifier = Modifier.weight(1f))
- Text(if (expanded) "Hide" else "Show", color = contentColor)
- }
- if (expanded) {
- Spacer(modifier = Modifier.height(16.dp))
- Text(
- "HyperOS kills background apps. Apply these settings:",
- style = MaterialTheme.typography.bodyMedium,
- color = contentColor
- )
- Spacer(modifier = Modifier.height(12.dp))
-
- Button(
- onClick = { openAutoStartSettings(context) },
- modifier = Modifier.fillMaxWidth(),
- colors = ButtonDefaults.buttonColors(containerColor = contentColor)
- ) {
- Text("1. Enable Autostart", color = MaterialTheme.colorScheme.surface)
- }
-
- OutlinedButton(
- onClick = { openBatterySettings(context) },
- modifier = Modifier.fillMaxWidth(),
- colors = ButtonDefaults.outlinedButtonColors(contentColor = contentColor)
- ) {
- Text("2. Set Battery 'No Restrictions'")
- }
+@Composable
+fun ListOptionCard(title: String, subtitle: String, icon: ImageVector, shape: Shape, onClick: () -> Unit, trailingContent: (@Composable () -> Unit)? = null) {
+ Card(onClick = onClick, colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), shape = shape, modifier = Modifier.fillMaxWidth().defaultMinSize(minHeight = 88.dp)) {
+ Row(modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 16.dp), verticalAlignment = Alignment.CenterVertically) {
+ Icon(icon, null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
+ Spacer(Modifier.width(20.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Medium)
+ Text(subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
+ if (trailingContent != null) trailingContent() else Icon(Icons.AutoMirrored.Rounded.ArrowForwardIos, null, modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f))
}
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/components/Dialogs.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/components/Dialogs.kt
index 3cf7bdd..18db4de 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/ui/components/Dialogs.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/components/Dialogs.kt
@@ -26,6 +26,7 @@ import androidx.compose.material.icons.filled.Block
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Palette
+import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@@ -72,10 +73,17 @@ fun AppConfigBottomSheet(
onDismiss: () -> Unit,
onNavConfigClick: () -> Unit
) {
- // Data Loading
- val typeConfig by viewModel.getAppConfig(app.packageName).collectAsState(initial = emptySet())
+ // --- UPDATED DATA LOADING ---
+ val effectiveConfig by viewModel.getEffectiveAppConfigFlow(app.packageName).collectAsState(initial = null)
+ val activeTypes = effectiveConfig?.activeTypes ?: emptySet()
+ val isManagedByTheme = effectiveConfig?.isManagedByTheme == true
+
val appIslandConfig by viewModel.getAppIslandConfig(app.packageName).collectAsState(initial = IslandConfig())
- val globalConfig by viewModel.globalConfigFlow.collectAsState(initial = IslandConfig(true, true, 5))
+ val globalConfig by viewModel.globalConfigFlow.collectAsState(initial = IslandConfig(
+ isFloat = true,
+ isShowShade = true,
+ timeout = 5
+ ))
val blockedTerms by viewModel.getAppBlockedTerms(app.packageName).collectAsState(initial = emptySet())
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
@@ -131,9 +139,36 @@ fun AppConfigBottomSheet(
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f))
Spacer(modifier = Modifier.height(24.dp))
+ // --- NEW: THEME OVERRIDE WARNING ---
+ if (isManagedByTheme) {
+ Surface(
+ color = MaterialTheme.colorScheme.primaryContainer,
+ shape = RoundedCornerShape(8.dp),
+ modifier = Modifier.padding(bottom = 16.dp).fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Outlined.Info,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onPrimaryContainer,
+ modifier = Modifier.size(20.dp)
+ )
+ Spacer(Modifier.width(12.dp))
+ Text(
+ text = "Behaviors are currently managed by your active Theme.",
+ color = MaterialTheme.colorScheme.onPrimaryContainer,
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ }
+ }
+
// --- CARD 1: NOTIFICATION TYPES (New Dropdown) ---
- // Count how many are active to show in subtitle
- val activeCount = NotificationType.entries.toTypedArray().count { typeConfig.contains(it.name) }
+ // Count how many are active from the effective config
+ val activeCount = NotificationType.entries.toTypedArray().count { activeTypes.contains(it.name) }
val activeSubtitle = stringResource(R.string.active_notifications_subtitle, activeCount)
ExpandableSettingCard(
@@ -143,7 +178,7 @@ fun AppConfigBottomSheet(
) {
NotificationTypesContent(
app = app,
- typeConfig = typeConfig,
+ activeTypes = activeTypes,
viewModel = viewModel,
onNavConfigClick = { onDismiss(); onNavConfigClick() },
navEditDesc = navEditDesc
@@ -210,14 +245,14 @@ fun AppConfigBottomSheet(
@Composable
fun NotificationTypesContent(
app: AppInfo,
- typeConfig: Set,
+ activeTypes: Set,
viewModel: AppListViewModel,
onNavConfigClick: () -> Unit,
navEditDesc: String
) {
Column {
NotificationType.entries.forEach { type ->
- val isChecked = typeConfig.contains(type.name)
+ val isChecked = activeTypes.contains(type.name)
val typeLabel = stringResource(type.labelRes)
val switchDesc = if (isChecked) stringResource(R.string.cd_disable_type, typeLabel)
else stringResource(R.string.cd_enable_type, typeLabel)
@@ -225,6 +260,7 @@ fun NotificationTypesContent(
Row(
modifier = Modifier
.fillMaxWidth()
+ // viewModel.updateAppConfig perfectly routes to Theme or Database depending on state!
.clickable { viewModel.updateAppConfig(app.packageName, type, !isChecked) }
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/components/EmptyState.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/components/EmptyState.kt
index 553c550..d7b4d99 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/ui/components/EmptyState.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/components/EmptyState.kt
@@ -28,10 +28,10 @@ import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun EmptyState(
+ modifier: Modifier = Modifier,
title: String,
description: String,
- icon: ImageVector = Icons.Outlined.NotificationsOff,
- modifier: Modifier = Modifier
+ icon: ImageVector = Icons.Outlined.NotificationsOff
) {
Column(
modifier = modifier
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/components/EnginePreview.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/components/EnginePreview.kt
new file mode 100644
index 0000000..519f543
--- /dev/null
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/components/EnginePreview.kt
@@ -0,0 +1,292 @@
+package com.d4viddf.hyperbridge.ui.components
+
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.EaseInOutQuart
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+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.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.BatteryFull
+import androidx.compose.material.icons.filled.Call
+import androidx.compose.material.icons.filled.CallEnd
+import androidx.compose.material.icons.filled.Group
+import androidx.compose.material.icons.filled.SignalCellular4Bar
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+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.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import kotlinx.coroutines.delay
+
+@Composable
+fun EnginePreview(isNative: Boolean) {
+ var isExpanded by remember { mutableStateOf(false) }
+
+ LaunchedEffect(isNative) {
+ while(true) {
+ isExpanded = false
+ delay(2500)
+ isExpanded = true
+ delay(4500)
+ }
+ }
+
+ val topPadding by animateDpAsState(
+ targetValue = if (isExpanded) 42.dp else 10.dp,
+ animationSpec = tween(500, easing = EaseInOutQuart),
+ label = "yOffset"
+ )
+
+ val height by animateDpAsState(
+ targetValue = if (isExpanded && isNative ) 130.dp else if (isExpanded && !isNative) 80.dp else 26.dp,
+ animationSpec = spring(0.8f, Spring.StiffnessLow),
+ label = "height"
+ )
+
+ val width by animateDpAsState(
+ targetValue = if (isExpanded) 340.dp else if (isNative) 120.dp else 200.dp,
+ animationSpec = spring(0.8f, Spring.StiffnessLow),
+ label = "width"
+ )
+
+ val containerColor by animateColorAsState(
+ if (isNative && isExpanded) Color(0xFF222222) else Color.Black,
+ label = "color"
+ )
+
+ Card(
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
+ shape = RoundedCornerShape(28.dp),
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(200.dp)
+ ) {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
+
+ // Mock Status Bar
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp, vertical = 14.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text("12:00", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold)
+ Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
+ Icon(Icons.Default.SignalCellular4Bar, null, modifier = Modifier.size(14.dp))
+ Icon(Icons.Default.BatteryFull, null, modifier = Modifier.size(14.dp))
+ }
+ }
+
+ // The Pill
+ Box(
+ modifier = Modifier
+ .padding(top = topPadding)
+ .width(width)
+ .height(height)
+ .clip(RoundedCornerShape(if (isExpanded) 24.dp else 50.dp))
+ .background(containerColor),
+ contentAlignment = Alignment.Center
+ ) {
+ if (!isExpanded) {
+ // COLLAPSED MOCKUP
+ Row(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(Icons.Default.Group, null, tint = Color.White, modifier = Modifier.size(14.dp))
+ Spacer(Modifier.width(8.dp))
+ Text(
+ text = if (isNative) "" else "Alice",
+ color = Color.White,
+ fontSize = 10.sp,
+ fontWeight = FontWeight.Bold
+ )
+ Spacer(Modifier.weight(1f))
+ // Camera cutout mock
+ Box(Modifier
+ .size(14.dp)
+ .background(Color(0xFF1F1F1F), CircleShape))
+ Spacer(Modifier.weight(1f))
+ Text(
+ text = if (isNative) "Alice" else "Incoming",
+ color = Color.White,
+ fontSize = 10.sp
+ )
+ }
+ } else {
+ // EXPANDED MOCKUP
+ if (isNative) {
+ // Live Update Design
+ Column(Modifier
+ .fillMaxSize()
+ .padding(16.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Box(Modifier
+ .size(16.dp)
+ .background(Color(0xFF3DDA82), CircleShape), contentAlignment = Alignment.Center) {
+ Icon(Icons.Default.Call, null, tint = Color.Black, modifier = Modifier.size(12.dp))
+ }
+ Spacer(Modifier.width(8.dp))
+ Text("Hyper Bridge • now", color = Color.White.copy(0.6f), fontSize = 10.sp)
+ }
+ Spacer(Modifier.height(4.dp))
+ Column(Modifier.padding(start= 24.dp)) {
+ Text(
+ "Alice",
+ color = Color.White,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Bold
+ )
+ Text(
+ "Incoming Call",
+ color = Color.White.copy(0.7f),
+ fontSize = 12.sp
+ )
+
+ Spacer(Modifier.height(4.dp))
+
+ Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
+ Text(
+ "Reject",
+ color = Color(0xFF3DDA82),
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Bold
+ )
+ Text(
+ "Answer",
+ color = Color(0xFF3DDA82),
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ }
+ }
+ } else {
+ // Xiaomi Featured Design
+ Row(Modifier
+ .fillMaxSize()
+ .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically) {
+ Icon(Icons.Default.Group, null, tint = Color.White, modifier = Modifier.size(32.dp))
+ Spacer(Modifier.width(8.dp))
+ Column(Modifier.weight(1f)) {
+ Text("Alice", color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Bold)
+ Text("Incoming Call", color = Color.White.copy(0.7f), fontSize = 12.sp)
+
+ }
+ Box(Modifier
+ .size(46.dp)
+ .background(Color(0xFFFF3B30), CircleShape) , contentAlignment = Alignment.Center)
+ {
+ Icon(Icons.Default.CallEnd, null, tint = Color.White, modifier = Modifier.size(32.dp))
+ }
+ Spacer(Modifier.width(16.dp))
+ Box(Modifier
+ .size(46.dp)
+ .background(Color(0xFF34C759), CircleShape), contentAlignment = Alignment.Center)
+ {
+ Icon(Icons.Default.Call, null, tint = Color.White, modifier = Modifier.size(32.dp))
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun EngineOptionCard(title: String, description: String, isSelected: Boolean, onClick: () -> Unit) {
+ Card(
+ onClick = onClick,
+ shape = RoundedCornerShape(16.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer.copy(0.3f) else MaterialTheme.colorScheme.surfaceContainerLow
+ ),
+ border = if (isSelected) androidx.compose.foundation.BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
+ RadioButton(selected = isSelected, onClick = null)
+ Spacer(Modifier.width(12.dp))
+ Column {
+ Text(title, fontWeight = FontWeight.Bold)
+ Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+ }
+}
+
+
+@Preview(showBackground = true, name = "Xiaomi Featured (False)")
+@Composable
+fun PreviewXiaomiEngine() {
+ MaterialTheme {
+ Box(modifier = Modifier.padding(16.dp)) {
+ EnginePreview(isNative = false)
+ }
+ }
+}
+
+@Preview(showBackground = true, name = "Native Live Update (True)")
+@Composable
+fun PreviewNativeEngine() {
+ MaterialTheme {
+ Box(modifier = Modifier.padding(16.dp)) {
+ EnginePreview(isNative = true)
+ }
+ }
+}
+
+@Preview(showBackground = true, name = "Option Card Preview")
+@Composable
+fun PreviewOptionCard() {
+ MaterialTheme {
+ Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ EngineOptionCard(
+ title = "Selected Item",
+ description = "This is how it looks when selected",
+ isSelected = true,
+ onClick = {}
+ )
+ EngineOptionCard(
+ title = "Unselected Item",
+ description = "This is the default state",
+ isSelected = false,
+ onClick = {}
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/components/HyperOSColorPicker.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/components/HyperOSColorPicker.kt
index a92f1ac..a25dee6 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/ui/components/HyperOSColorPicker.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/components/HyperOSColorPicker.kt
@@ -1,14 +1,9 @@
package com.d4viddf.hyperbridge.ui.components
import androidx.compose.foundation.background
-import androidx.compose.foundation.border
-import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Slider
@@ -59,44 +54,3 @@ fun GradientSlider(
)
}
}
-
-// Reusable circular color bubble
-@Composable
-fun ColorBubble(
- color: Color,
- isSelected: Boolean,
- onClick: () -> Unit,
- backgroundBrush: Brush? = null,
- content: @Composable BoxScope.() -> Unit = {}
-) {
- Box(
- modifier = Modifier
- .size(56.dp)
- .clip(CircleShape)
- .clickable(onClick = onClick),
- contentAlignment = Alignment.Center
- ) {
- // Outer selection ring
- if (isSelected) {
- Box(
- modifier = Modifier
- .matchParentSize()
- .border(2.dp, Color(0xFF3B82F6), CircleShape) // HyperOS Blue ring
- )
- }
-
- // Inner color circle
- Box(
- modifier = Modifier
- .size(if (isSelected) 44.dp else 48.dp) // Shrinks slightly when selected
- .clip(CircleShape)
- .then(
- if (backgroundBrush != null) Modifier.background(backgroundBrush)
- else Modifier.background(color)
- ),
- contentAlignment = Alignment.Center,
- content = content
- )
- }
-}
-
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/components/IslandSettingsControl.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/components/IslandSettingsControl.kt
index ac75b6f..93696eb 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/ui/components/IslandSettingsControl.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/components/IslandSettingsControl.kt
@@ -1,137 +1,358 @@
package com.d4viddf.hyperbridge.ui.components
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccessTime
+import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material.icons.filled.Layers
import androidx.compose.material.icons.filled.Visibility
+import androidx.compose.material.icons.automirrored.filled.Reply
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.d4viddf.hyperbridge.R
import com.d4viddf.hyperbridge.models.IslandConfig
-import kotlin.math.roundToInt
+import com.d4viddf.hyperbridge.ui.theme.HyperBridgeTheme
+
+// Define our snap points (in seconds) for the auto-hide island
+val timeoutSteps = listOf(
+ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 20, 30, 45,
+ 60, 300, 900, 1800, 3600
+)
+private val timePopUpSteps = listOf(
+ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 20, 30
+)
@Composable
fun IslandSettingsControl(
config: IslandConfig,
- // If not null, represents Global Defaults to fall back on
defaultConfig: IslandConfig? = null,
onUpdate: (IslandConfig) -> Unit
) {
val isOverridden = config.isFloat != null
val displayConfig = if (isOverridden) config else (defaultConfig ?: config)
- Column {
- // 1. FLOAT TOGGLE
- SettingsSwitchRow(
- title = stringResource(R.string.setting_float),
- description = stringResource(R.string.setting_float_desc),
- icon = Icons.Default.Visibility,
- checked = displayConfig.isFloat ?: true,
- onCheckedChange = {
- onUpdate(config.copy(isFloat = it))
- }
+ // Timeout is "Enabled" if it's > 0
+ val currentTimeout = displayConfig.timeout ?: 10
+ val isTimeoutEnabled = currentTimeout > 0
+
+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
+
+ Text(
+ text = stringResource(R.string.global_behavior),
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(start = 4.dp, top = 8.dp)
)
+ Spacer(Modifier.height(8.dp))
+ // --- TIMEOUT CARD ---
+ Card(
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
+ shape = RoundedCornerShape(24.dp),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp)) {
+ // Header with Switch
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(Icons.Default.AccessTime, null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
+ Spacer(Modifier.width(20.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(stringResource(R.string.auto_hide_island), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Medium)
+ Text(
+ text = if (isTimeoutEnabled) stringResource(R.string.hides_after_a_set_time) else stringResource(
+ R.string.behavior_hide_desc
+ ),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Switch(
+ checked = isTimeoutEnabled,
+ onCheckedChange = { enabled ->
+ // When changing this, we also ensure isFloat is set to track the override
+ val newTimeout = if (enabled) 5 else 0
+ val currentIsFloat = config.isFloat ?: defaultConfig?.isFloat ?: true
+ onUpdate(config.copy(timeout = newTimeout, isFloat = currentIsFloat))
+ }
+ )
+ }
+
+ // Expandable Slider Section
+ AnimatedVisibility(
+ visible = isTimeoutEnabled,
+ enter = expandVertically(),
+ exit = shrinkVertically()
+ ) {
+ Column {
+ Spacer(Modifier.height(16.dp))
+
+ // Time Display Label
+ Text(
+ text = formatSeconds(currentTimeout),
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.primary,
+ fontWeight = FontWeight.Bold
+ )
+
+ // Slider mapping to our steps list
+ val currentIndex = timeoutSteps.indexOf(currentTimeout).coerceAtLeast(0).toFloat()
+
+ Slider(
+ value = currentIndex,
+ onValueChange = { index ->
+ val selectedSeconds = timeoutSteps[index.toInt()]
+ val currentIsFloat = config.isFloat ?: defaultConfig?.isFloat ?: true
+ onUpdate(config.copy(timeout = selectedSeconds, isFloat = currentIsFloat))
+ },
+ valueRange = 0f..(timeoutSteps.size - 1).toFloat(),
+ steps = timeoutSteps.size - 2
+ )
+
+ Text(
+ text = stringResource(R.string.behavior_desc_hide_long),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
+ modifier = Modifier.padding(top = 4.dp)
+ )
+ }
+ }
+ }
+ }
+
+ Spacer(Modifier.height(14.dp))
- HorizontalDivider(
- modifier = Modifier.padding(vertical = 8.dp).padding(start = 56.dp),
- color = MaterialTheme.colorScheme.outlineVariant.copy(0.2f)
+ Text(
+ text = stringResource(R.string.xiaomi_featured_notifications),
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(start = 4.dp)
)
- // 2. SHADE TOGGLE
- SettingsSwitchRow(
+ Spacer(Modifier.height(8.dp))
+
+ // --- FLOAT SETTINGS (Heads-up Popup) ---
+ val isFloatEnabled = displayConfig.isFloat ?: true
+ val currentFloatTimeout = displayConfig.floatTimeout ?: 10
+
+ Card(
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
+ shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp, bottomStart = 4.dp, bottomEnd = 4.dp),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp)) {
+ Row(verticalAlignment = Alignment.Top) {
+ Icon(Icons.Default.Visibility, null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
+ Spacer(Modifier.width(20.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(stringResource(R.string.setting_float), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Medium)
+ Text(stringResource(R.string.setting_float_desc), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ Switch(
+ checked = isFloatEnabled,
+ onCheckedChange = { onUpdate(config.copy(isFloat = it)) }
+ )
+ }
+
+ AnimatedVisibility(
+ visible = isFloatEnabled,
+ enter = expandVertically(),
+ exit = shrinkVertically()
+ ) {
+ Column {
+ Spacer(Modifier.height(16.dp))
+ Text(
+ text = stringResource(R.string.seconds_suffix, currentFloatTimeout),
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.primary,
+ fontWeight = FontWeight.Bold
+ )
+
+
+ // Slider mapping to our steps list
+ val currentIndexPop = timePopUpSteps.indexOf(currentFloatTimeout).coerceAtLeast(1).toFloat()
+
+ Slider(
+ value = currentIndexPop,
+ onValueChange = { index ->
+ val selectedSeconds = timePopUpSteps[index.toInt()]
+ onUpdate(config.copy(floatTimeout = selectedSeconds))
+ },
+ valueRange = 0f..(timePopUpSteps.size - 1).toFloat(),
+ steps = timePopUpSteps.size - 2,
+
+ )
+ Text(
+ text = stringResource(R.string.setting_float_timeout_desc),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
+ )
+ }
+ }
+ }
+ }
+
+ SettingsToggleCard(
title = stringResource(R.string.setting_shade),
- description = stringResource(R.string.setting_shade_desc),
+ subtitle = stringResource(R.string.setting_shade_desc),
icon = Icons.Default.Layers,
- checked = displayConfig.isShowShade ?: true,
- onCheckedChange = {
- onUpdate(config.copy(isShowShade = it))
- }
+ checked = displayConfig.isShowShade ?: false,
+ onCheckedChange = {
+ val currentIsFloat = config.isFloat ?: defaultConfig?.isFloat ?: true
+ onUpdate(config.copy(isShowShade = it, isFloat = currentIsFloat))
+ },
+ shape = RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp, bottomStart = 24.dp, bottomEnd = 24.dp)
+ )
+
+ Spacer(Modifier.height(14.dp))
+
+ Text(
+ text = stringResource(R.string.notification_management),
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(start = 4.dp)
)
- HorizontalDivider(
- modifier = Modifier.padding(vertical = 8.dp).padding(start = 56.dp),
- color = MaterialTheme.colorScheme.outlineVariant.copy(0.2f)
+ Spacer(Modifier.height(8.dp))
+
+ val removeOriginalOn = displayConfig.removeOriginalNotification == true
+
+ SettingsToggleCard(
+ title = stringResource(R.string.remove_original_notification),
+ subtitle = stringResource(R.string.remove_original_notification_desc),
+ icon = Icons.Default.DeleteSweep,
+ checked = removeOriginalOn,
+ onCheckedChange = {
+ val currentIsFloat = config.isFloat ?: defaultConfig?.isFloat ?: true
+ onUpdate(config.copy(removeOriginalNotification = it, isFloat = currentIsFloat))
+ },
+ shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp, bottomStart = 4.dp, bottomEnd = 4.dp)
)
- // 3. TIMEOUT SLIDER
- // FIX: Treat values as SECONDS directly (Default 5s)
- val timeoutSec = (displayConfig.timeout ?: 5L).toFloat()
-
- Column(modifier = Modifier.padding(vertical = 8.dp)) {
- // Header Row
- Row(verticalAlignment = Alignment.CenterVertically) {
- Icon(
- Icons.Default.AccessTime,
- null,
- tint = MaterialTheme.colorScheme.primary,
- modifier = Modifier.size(24.dp)
+ Column {
+ SettingsToggleCard(
+ title = stringResource(R.string.dismiss_with_original),
+ subtitle = stringResource(R.string.dismiss_with_original_desc),
+ icon = Icons.Default.DeleteSweep,
+ checked = displayConfig.dismissWithOriginal ?: false,
+ enabled = !removeOriginalOn,
+ onCheckedChange = {
+ val currentIsFloat = config.isFloat ?: defaultConfig?.isFloat ?: true
+ onUpdate(config.copy(dismissWithOriginal = it, isFloat = currentIsFloat))
+ },
+ shape = RoundedCornerShape(4.dp)
)
- Spacer(Modifier.width(16.dp))
-
- Text(
- text = stringResource(R.string.setting_timeout),
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.SemiBold,
- modifier = Modifier.weight(1f)
+
+ SettingsToggleCard(
+ title = stringResource(R.string.enable_inline_reply),
+ subtitle = stringResource(R.string.enable_inline_reply_desc),
+ icon = Icons.AutoMirrored.Filled.Reply,
+ checked = displayConfig.enableInlineReply ?: true,
+ enabled = !removeOriginalOn,
+ onCheckedChange = {
+ val currentIsFloat = config.isFloat ?: defaultConfig?.isFloat ?: true
+ onUpdate(config.copy(enableInlineReply = it, isFloat = currentIsFloat))
+ },
+ shape = RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp, bottomStart = if (removeOriginalOn) 4.dp else 24.dp, bottomEnd = if (removeOriginalOn) 4.dp else 24.dp)
)
- // Value Display (e.g. "5s")
- Text(
- text = stringResource(R.string.seconds_suffix, timeoutSec.roundToInt()),
- style = MaterialTheme.typography.titleMedium,
- color = MaterialTheme.colorScheme.primary,
- fontWeight = FontWeight.Bold
- )
+ AnimatedVisibility(
+ visible = removeOriginalOn,
+ enter = expandVertically(),
+ exit = shrinkVertically()
+ ) {
+ Text(
+ text = stringResource(R.string.remove_original_notification_hidden_warning),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error,
+ modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 16.dp)
+ )
+ }
}
+ }
+}
- // Full Width Slider
- Slider(
- value = timeoutSec,
- onValueChange = { seconds ->
- // FIX: Pass seconds directly as Long (No * 1000 conversion)
- onUpdate(config.copy(timeout = seconds.toInt()))
- },
- valueRange = 0f..10f,
- steps = 9,
- modifier = Modifier
- .fillMaxWidth()
- .padding(top = 4.dp, start = 40.dp)
- )
- }
+/**
+ * Formats seconds into a readable string (e.g. "10s", "5m", "1h")
+ */
+fun formatSeconds(seconds: Int): String {
+ return when {
+ seconds < 60 -> "${seconds}s"
+ seconds < 3600 -> "${seconds / 60}m"
+ else -> "${seconds / 3600}h"
}
}
@Composable
-fun SettingsSwitchRow(
- title: String, description: String, icon: ImageVector,
- checked: Boolean, onCheckedChange: (Boolean) -> Unit
+fun SettingsToggleCard(
+ title: String, subtitle: String, icon: ImageVector,
+ checked: Boolean, enabled: Boolean = true, shape: Shape, onCheckedChange: (Boolean) -> Unit
) {
- Row(
- modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
- verticalAlignment = Alignment.CenterVertically
+ Card(
+ onClick = { if (enabled) onCheckedChange(!checked) },
+ enabled = enabled,
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
+ shape = shape,
+ modifier = Modifier.fillMaxWidth()
) {
- Icon(icon, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(24.dp))
- Spacer(Modifier.width(16.dp))
- Column(modifier = Modifier.weight(1f)) {
- Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
- Text(description, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Row(
+ modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp),
+ verticalAlignment = Alignment.Top
+ ) {
+ Icon(icon, null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
+ Spacer(Modifier.width(20.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Medium)
+ Text(subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ Switch(checked = checked, enabled = enabled, onCheckedChange = onCheckedChange)
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun IslandSettingsControlPreview() {
+ HyperBridgeTheme {
+ Surface(modifier = Modifier.fillMaxSize()) {
+ IslandSettingsControl(
+ config = IslandConfig(
+ isFloat = true,
+ timeout = 5,
+ floatTimeout = 5,
+ isShowShade = true,
+ removeOriginalNotification = false,
+ dismissWithOriginal = false
+ ),
+ onUpdate = {}
+ )
}
- Switch(
- checked = checked,
- onCheckedChange = onCheckedChange,
- colors = SwitchDefaults.colors(
- checkedThumbColor = MaterialTheme.colorScheme.onPrimary,
- checkedTrackColor = MaterialTheme.colorScheme.primary,
- uncheckedThumbColor = MaterialTheme.colorScheme.outline,
- uncheckedTrackColor = MaterialTheme.colorScheme.surfaceContainerHighest
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun SettingsToggleCardPreview() {
+ HyperBridgeTheme {
+ Surface(modifier = Modifier.padding(16.dp)) {
+ SettingsToggleCard(
+ title = "Example Title",
+ subtitle = "Example subtitle for the toggle card",
+ icon = Icons.Default.Layers,
+ checked = true,
+ shape = RoundedCornerShape(24.dp),
+ onCheckedChange = {}
)
- )
+ }
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/components/NavPreviewContent.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/components/NavPreviewContent.kt
new file mode 100644
index 0000000..56b029b
--- /dev/null
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/components/NavPreviewContent.kt
@@ -0,0 +1,215 @@
+package com.d4viddf.hyperbridge.ui.components
+
+import androidx.compose.foundation.background
+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.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.TurnRight
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExposedDropdownMenuAnchorType
+import androidx.compose.material3.ExposedDropdownMenuBox
+import androidx.compose.material3.ExposedDropdownMenuDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+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.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.d4viddf.hyperbridge.R
+import com.d4viddf.hyperbridge.models.NavContent
+
+
+@Composable
+fun NavPreview(left: NavContent, right: NavContent) {
+ val leftLabel = stringResource(getNavContentLabelRes(left))
+ val rightLabel = stringResource(getNavContentLabelRes(right))
+ val cd = stringResource(R.string.cd_nav_preview, leftLabel, rightLabel)
+
+ Card(
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
+ shape = RoundedCornerShape(24.dp),
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics(mergeDescendants = true) { contentDescription = cd }
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 32.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Box(
+ modifier = Modifier
+ .width(330.dp)
+ .height(46.dp)
+ .clip(RoundedCornerShape(50))
+ .background(Color.Black)
+ ) {
+ // Camera Cutout
+ Box(
+ modifier = Modifier
+ .align(Alignment.Center)
+ .size(30.dp)
+ .clip(CircleShape)
+ .background(Color(0xFF1F1F1F))
+ )
+
+ Row(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // LEFT SIDE
+ Row(
+ modifier = Modifier.weight(1f),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Start
+ ) {
+ Icon(
+ imageVector = Icons.Default.TurnRight,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp)
+ )
+ Spacer(Modifier.width(6.dp))
+ NavContentRenderer(left, Alignment.Start)
+ }
+
+ // SPACER
+ Spacer(modifier = Modifier.width(32.dp))
+
+ // RIGHT SIDE
+ Box(
+ modifier = Modifier.weight(1f),
+ contentAlignment = Alignment.CenterEnd
+ ) {
+ NavContentRenderer(right, Alignment.End)
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun NavContentRenderer(type: NavContent, align: Alignment.Horizontal) {
+
+ // Fade Logic: Only applies to long text (Instruction)
+ val fadeBrush = if (type == NavContent.INSTRUCTION) {
+ if (align == Alignment.Start) {
+ Brush.horizontalGradient(0.85f to Color.White, 1.0f to Color.Transparent)
+ } else {
+ Brush.horizontalGradient(0.0f to Color.Transparent, 0.15f to Color.White)
+ }
+ } else null
+
+ val textStyle = if (fadeBrush != null) {
+ TextStyle(brush = fadeBrush, fontWeight = FontWeight.Bold, fontSize = 14.sp)
+ } else {
+ TextStyle(color = Color.White, fontWeight = FontWeight.Bold, fontSize = 14.sp)
+ }
+
+ val timeStyle = TextStyle(color = Color.White, fontWeight = FontWeight.Normal, fontSize = 14.sp)
+
+ when (type) {
+ NavContent.INSTRUCTION -> {
+ Text(
+ text = stringResource(R.string.nav_preview_instruction),
+ style = textStyle,
+ maxLines = 1,
+ overflow = TextOverflow.Clip
+ )
+ }
+ NavContent.DISTANCE -> {
+ Text(
+ text = stringResource(R.string.nav_preview_distance),
+ style = textStyle // Solid White
+ )
+ }
+ NavContent.ETA -> {
+ Text(
+ text = stringResource(R.string.nav_preview_time),
+ style = timeStyle // Standard weight
+ )
+ }
+ NavContent.DISTANCE_ETA -> {
+ // Combined Row
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(stringResource(R.string.nav_preview_distance), style = textStyle.copy(fontSize = 13.sp))
+ Spacer(Modifier.width(4.dp))
+ Text("•", color = Color.Gray, fontSize = 12.sp)
+ Spacer(Modifier.width(4.dp))
+ Text(stringResource(R.string.nav_preview_time), style = timeStyle.copy(fontSize = 13.sp))
+ }
+ }
+ NavContent.NONE -> { /* Empty */ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun NavDropdown(label: String, selected: NavContent, onSelect: (NavContent) -> Unit) {
+ var expanded by remember { mutableStateOf(false) }
+ Column {
+ Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary)
+ Spacer(Modifier.height(4.dp))
+ ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
+ OutlinedTextField(
+ value = stringResource(getNavContentLabelRes(selected)),
+ onValueChange = {},
+ readOnly = true,
+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
+ modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable, true).fillMaxWidth(),
+ textStyle = MaterialTheme.typography.bodyMedium
+ )
+ ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
+ NavContent.entries.forEach { option ->
+ DropdownMenuItem(
+ text = { Text(stringResource(getNavContentLabelRes(option))) },
+ onClick = { onSelect(option); expanded = false }
+ )
+ }
+ }
+ }
+ }
+}
+
+private fun getNavContentLabelRes(content: NavContent): Int {
+ return when(content) {
+ NavContent.INSTRUCTION -> R.string.nav_content_instruction
+ NavContent.DISTANCE -> R.string.nav_content_distance
+ NavContent.ETA -> R.string.nav_content_eta
+ NavContent.DISTANCE_ETA -> R.string.nav_content_distance_eta
+ NavContent.NONE -> R.string.nav_content_none
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/components/PermanentIslandPreview.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/components/PermanentIslandPreview.kt
new file mode 100644
index 0000000..67d37bd
--- /dev/null
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/components/PermanentIslandPreview.kt
@@ -0,0 +1,89 @@
+package com.d4viddf.hyperbridge.ui.components
+
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.animation.core.spring
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.BatteryFull
+import androidx.compose.material.icons.filled.SignalCellular4Bar
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+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.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun PermanentIslandPreview(islandWidthValue: Int) {
+ // Convert 0-20 value to dp width. Base width is maybe 120dp. Each step adds 5dp.
+ val targetWidth = 120.dp + (islandWidthValue * 5).dp
+
+ val width by animateDpAsState(
+ targetValue = targetWidth,
+ animationSpec = spring(0.8f, Spring.StiffnessLow),
+ label = "width"
+ )
+
+ Card(
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
+ shape = RoundedCornerShape(28.dp),
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(160.dp)
+ ) {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
+
+ // Mock Status Bar
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp, vertical = 14.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text("12:00", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold)
+ Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
+ Icon(Icons.Default.SignalCellular4Bar, null, modifier = Modifier.size(14.dp))
+ Icon(Icons.Default.BatteryFull, null, modifier = Modifier.size(14.dp))
+ }
+ }
+
+ // The Pill
+ Box(
+ modifier = Modifier
+ .padding(top = 10.dp)
+ .width(width)
+ .height(26.dp)
+ .clip(RoundedCornerShape(50.dp))
+ .background(Color.Black),
+ contentAlignment = Alignment.Center
+ ) {
+ // Empty island, just the camera cutout mock
+ Box(
+ Modifier
+ .size(14.dp)
+ .background(Color(0xFF1F1F1F), CircleShape)
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/components/PriorityEducationDialog.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/components/PriorityEducationDialog.kt
deleted file mode 100644
index 7d68813..0000000
--- a/app/src/main/java/com/d4viddf/hyperbridge/ui/components/PriorityEducationDialog.kt
+++ /dev/null
@@ -1,62 +0,0 @@
-package com.d4viddf.hyperbridge.ui.components
-
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.*
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.*
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.unit.dp
-import com.d4viddf.hyperbridge.R
-import com.d4viddf.hyperbridge.models.IslandLimitMode
-
-@Composable
-fun PriorityEducationDialog(
- onDismiss: () -> Unit,
- onConfigure: () -> Unit
-) {
- AlertDialog(
- onDismissRequest = onDismiss,
- title = {
- Text(stringResource(R.string.priority_edu_title), fontWeight = FontWeight.Bold)
- },
- text = {
- Column(horizontalAlignment = Alignment.CenterHorizontally) {
- // Show the animation for the DEFAULT mode (Most Recent)
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .height(100.dp)
- .background(MaterialTheme.colorScheme.surfaceContainer, RoundedCornerShape(12.dp)),
- contentAlignment = Alignment.Center
- ) {
- BehaviorVisualizer(mode = IslandLimitMode.MOST_RECENT)
- }
-
- Spacer(modifier = Modifier.height(16.dp))
-
- // We manually parse bold tags since AlertDialog text doesn't support HTML natively in simple Text
- // For simplicity, we just show the raw string, or you can split it if you want styling.
- // Here we assume the string resource has text that looks good plain.
- Text(
- text = stringResource(R.string.priority_edu_desc)
- .replace("", "").replace("", ""), // Strip tags
- style = MaterialTheme.typography.bodyMedium
- )
- }
- },
- confirmButton = {
- Button(onClick = onConfigure) {
- Text(stringResource(R.string.configure_now))
- }
- },
- dismissButton = {
- TextButton(onClick = onDismiss) {
- Text(stringResource(R.string.keep_default))
- }
- }
- )
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/navigation/NavGraph.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/navigation/NavGraph.kt
new file mode 100644
index 0000000..6a13fd6
--- /dev/null
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/navigation/NavGraph.kt
@@ -0,0 +1,154 @@
+package com.d4viddf.hyperbridge.ui.navigation
+
+import android.content.Context
+import android.widget.Toast
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import androidx.navigation3.runtime.entryProvider
+import com.d4viddf.hyperbridge.R
+import com.d4viddf.hyperbridge.data.AppPreferences
+import com.d4viddf.hyperbridge.ui.screens.home.HomeScreen
+import com.d4viddf.hyperbridge.ui.screens.onboarding.OnboardingScreen
+import com.d4viddf.hyperbridge.ui.screens.settings.AppPriorityScreen
+import com.d4viddf.hyperbridge.ui.screens.settings.BackupSettingsScreen
+import com.d4viddf.hyperbridge.ui.screens.settings.BlocklistAppListScreen
+import com.d4viddf.hyperbridge.ui.screens.settings.ChangelogHistoryScreen
+import com.d4viddf.hyperbridge.ui.screens.settings.EngineSettingsScreen
+import com.d4viddf.hyperbridge.ui.screens.settings.GlobalBlocklistScreen
+import com.d4viddf.hyperbridge.ui.screens.settings.GlobalSettingsScreen
+import com.d4viddf.hyperbridge.ui.screens.settings.ImportPreviewScreen
+import com.d4viddf.hyperbridge.ui.screens.settings.InfoScreen
+import com.d4viddf.hyperbridge.ui.screens.settings.IslandSettingsScreen
+import com.d4viddf.hyperbridge.ui.screens.settings.LicensesScreen
+import com.d4viddf.hyperbridge.ui.screens.settings.NavCustomizationScreen
+import com.d4viddf.hyperbridge.ui.screens.settings.PrioritySettingsScreen
+import com.d4viddf.hyperbridge.ui.screens.settings.SetupHealthScreen
+import com.d4viddf.hyperbridge.util.BackupManager
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+@Composable
+fun mainNavGraph(
+ context: Context,
+ scope: CoroutineScope,
+ preferences: AppPreferences,
+ navigator: Navigator,
+ backupManager: BackupManager,
+ currentVersionCode: Int,
+ onExit: () -> Unit
+) = entryProvider {
+ entry {
+ OnboardingScreen {
+ scope.launch {
+ preferences.setSetupComplete(true)
+ navigator.finishOnboarding(Screen.Home)
+ }
+ }
+ }
+ entry {
+ HomeScreen(
+ onSettingsClick = { navigator.navigate(Screen.Info) },
+ onNavConfigClick = { pkg -> navigator.navigate(Screen.NavCustomization(pkg)) }
+ )
+ }
+ entry {
+ InfoScreen(
+ onBack = { if (!navigator.goBack()) onExit() },
+ onSetupClick = { navigator.navigate(Screen.Setup) },
+ onLicensesClick = { navigator.navigate(Screen.Licenses) },
+ onBehaviorClick = { navigator.navigate(Screen.Behavior) },
+ onGlobalSettingsClick = { navigator.navigate(Screen.GlobalSettings) },
+ onHistoryClick = { navigator.navigate(Screen.History) },
+ onBlocklistClick = { navigator.navigate(Screen.GlobalBlocklist) },
+ onBackupClick = { navigator.navigate(Screen.Backup) }
+ )
+ }
+ entry {
+ GlobalSettingsScreen(
+ onBack = { navigator.goBack() },
+ onNavSettingsClick = { navigator.navigate(Screen.NavCustomization(null)) },
+ onInlineReplyClick = { navigator.navigate(Screen.ReplyCustomization) },
+ onIslandSettingsClick = { navigator.navigate(Screen.IslandSettings) },
+ onEngineSettingsClick = { navigator.navigate(Screen.EngineSettings) },
+ onDndSettingsClick = { navigator.navigate(Screen.DndSettings) },
+ onPermanentIslandClick = { navigator.navigate(Screen.PermanentIslandConfig) }
+ )
+ }
+ entry {
+ com.d4viddf.hyperbridge.ui.screens.settings.DndSettingsScreen(onBack = { navigator.goBack() })
+ }
+ entry {
+ com.d4viddf.hyperbridge.ui.screens.settings.PermanentIslandConfigScreen(onBack = { navigator.goBack() })
+ }
+ entry {
+ com.d4viddf.hyperbridge.ui.screens.theme.GlobalReplyCustomizationScreen(onBack = { navigator.goBack() })
+ }
+ entry { key ->
+ NavCustomizationScreen(
+ onBack = { navigator.goBack() },
+ packageName = key.packageName
+ )
+ }
+ entry {
+ EngineSettingsScreen(onBack = { navigator.goBack() })
+ }
+ entry {
+ SetupHealthScreen(onBack = { navigator.goBack() })
+ }
+ entry {
+ LicensesScreen(onBack = { navigator.goBack() })
+ }
+ entry {
+ PrioritySettingsScreen(
+ onBack = { navigator.goBack() },
+ onNavigateToPriorityList = { navigator.navigate(Screen.AppPriority) }
+ )
+ }
+ entry {
+ AppPriorityScreen(onBack = { navigator.goBack() })
+ }
+ entry {
+ ChangelogHistoryScreen(onBack = { navigator.goBack() })
+ }
+ entry {
+ GlobalBlocklistScreen(
+ onBack = { navigator.goBack() },
+ onNavigateToAppList = { navigator.navigate(Screen.BlocklistApps) }
+ )
+ }
+ entry {
+ BlocklistAppListScreen(onBack = { navigator.goBack() })
+ }
+ entry {
+ BackupSettingsScreen(
+ onBack = { navigator.goBack() },
+ backupManager = backupManager,
+ onBackupFileLoaded = { backup ->
+ navigator.navigate(Screen.ImportPreview(backup))
+ }
+ )
+ }
+ entry { key ->
+ val importSuccessMsg = stringResource(R.string.import_success)
+ val importFailedMsg = stringResource(R.string.import_failed)
+ ImportPreviewScreen(
+ backupData = key.backup,
+ onBack = { navigator.goBack() },
+ onConfirmRestore = { selection ->
+ scope.launch {
+ val result = backupManager.restoreBackup(key.backup, selection)
+ if (result.isSuccess) {
+ Toast.makeText(context, importSuccessMsg, Toast.LENGTH_LONG).show()
+ navigator.navigate(Screen.Home)
+ } else {
+ val error = result.exceptionOrNull()?.message ?: ""
+ Toast.makeText(context, importFailedMsg.format(error), Toast.LENGTH_LONG).show()
+ }
+ }
+ }
+ )
+ }
+ entry {
+ IslandSettingsScreen(onBack = { navigator.goBack() })
+ }
+}
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/navigation/NavigationState.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/navigation/NavigationState.kt
new file mode 100644
index 0000000..affd7c2
--- /dev/null
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/navigation/NavigationState.kt
@@ -0,0 +1,94 @@
+package com.d4viddf.hyperbridge.ui.navigation
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSerializable
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshots.SnapshotStateList
+import androidx.compose.runtime.toMutableStateList
+import androidx.navigation3.runtime.NavBackStack
+import androidx.navigation3.runtime.NavEntry
+import androidx.navigation3.runtime.NavKey
+import androidx.navigation3.runtime.rememberDecoratedNavEntries
+import androidx.navigation3.runtime.rememberNavBackStack
+import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
+import androidx.navigation3.runtime.serialization.NavKeySerializer
+import androidx.savedstate.compose.serialization.serializers.MutableStateSerializer
+
+/**
+ * Create a navigation state that persists config changes and process death.
+ */
+@Composable
+fun rememberNavigationState(
+ startRoute: T,
+ topLevelRoutes: Set
+): NavigationState {
+
+ val topLevelRoute = rememberSerializable(
+ startRoute, topLevelRoutes,
+ serializer = MutableStateSerializer(NavKeySerializer())
+ ) {
+ mutableStateOf(startRoute)
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ val backStacks = topLevelRoutes.associateWith { key ->
+ rememberNavBackStack(key) as NavBackStack
+ }
+
+ return remember(startRoute, topLevelRoutes) {
+ NavigationState(
+ startRoute = startRoute,
+ topLevelRoute = topLevelRoute,
+ backStacks = backStacks
+ )
+ }
+}
+
+/**
+ * State holder for navigation state.
+ *
+ * @param startRoute - the start route. The user will exit the app through this route.
+ * @param topLevelRoute - the current top level route
+ * @param backStacks - the back stacks for each top level route
+ */
+class NavigationState(
+ val startRoute: T,
+ topLevelRoute: MutableState,
+ val backStacks: Map>
+) {
+ var topLevelRoute: T by topLevelRoute
+ val stacksInUse: List
+ get() = if (topLevelRoute == startRoute) {
+ listOf(startRoute)
+ } else {
+ listOf(startRoute, topLevelRoute)
+ }
+}
+
+/**
+ * Convert NavigationState into NavEntries.
+ */
+@Composable
+fun NavigationState.toEntries(
+ entryProvider: (T) -> NavEntry
+): SnapshotStateList> {
+
+ val decoratedEntries = backStacks.mapValues { (_, stack) ->
+ val decorators = listOf(
+ rememberSaveableStateHolderNavEntryDecorator(),
+ )
+ rememberDecoratedNavEntries(
+ backStack = stack,
+ entryDecorators = decorators,
+ entryProvider = entryProvider
+ )
+ }
+
+ return stacksInUse
+ .flatMap { decoratedEntries[it] ?: emptyList() }
+ .toMutableStateList()
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/navigation/Navigator.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/navigation/Navigator.kt
new file mode 100644
index 0000000..278c078
--- /dev/null
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/navigation/Navigator.kt
@@ -0,0 +1,59 @@
+package com.d4viddf.hyperbridge.ui.navigation
+
+import androidx.navigation3.runtime.NavKey
+
+/**
+ * Handles navigation events (forward and back) by updating the navigation state.
+ */
+class Navigator(val state: NavigationState) {
+ fun navigate(route: T) {
+ if (route in state.backStacks.keys) {
+ // This is a top level route, just switch to it.
+ state.topLevelRoute = route
+ } else {
+ state.backStacks[state.topLevelRoute]?.add(route)
+ }
+ }
+
+ /**
+ * Returns true if the back event was handled, false if we're at the root of the start route.
+ */
+ fun goBack(): Boolean {
+ val currentStack = state.backStacks[state.topLevelRoute]
+ ?: error("Stack for ${state.topLevelRoute} not found")
+ val currentRoute = currentStack.lastOrNull() ?: return false
+
+ // If we're at the base of the current route, go back to the start route stack.
+ if (currentRoute == state.topLevelRoute) {
+ if (state.topLevelRoute != state.startRoute) {
+ state.topLevelRoute = state.startRoute
+ return true
+ } else {
+ return false
+ }
+ } else {
+ currentStack.removeLastOrNull()
+ return true
+ }
+ }
+
+ /**
+ * Specifically handles the transition from Onboarding to Home.
+ * Ensures the app doesn't close by adding the Home screen BEFORE clearing Onboarding.
+ */
+ fun finishOnboarding(homeRoute: T) {
+ val homeStack = state.backStacks[homeRoute]
+ ?: error("Home route $homeRoute not found in backStacks")
+
+
+ if (homeStack.isEmpty()) {
+ homeStack.add(homeRoute)
+ }
+
+ state.topLevelRoute = homeRoute
+
+ if (state.startRoute != homeRoute) {
+ state.backStacks[state.startRoute]?.clear()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/navigation/Screen.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/navigation/Screen.kt
new file mode 100644
index 0000000..f1e4b3e
--- /dev/null
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/navigation/Screen.kt
@@ -0,0 +1,28 @@
+package com.d4viddf.hyperbridge.ui.navigation
+
+import androidx.navigation3.runtime.NavKey
+import com.d4viddf.hyperbridge.data.model.HyperBridgeBackup
+import kotlinx.serialization.Serializable
+
+@Serializable
+sealed interface Screen : NavKey {
+ @Serializable data object Onboarding : Screen
+ @Serializable data object Home : Screen
+ @Serializable data object Info : Screen
+ @Serializable data object Setup : Screen
+ @Serializable data object Licenses : Screen
+ @Serializable data object Behavior : Screen
+ @Serializable data object GlobalSettings : Screen
+ @Serializable data object History : Screen
+ @Serializable data object Backup : Screen
+ @Serializable data class ImportPreview(val backup: HyperBridgeBackup) : Screen
+ @Serializable data class NavCustomization(val packageName: String?) : Screen
+ @Serializable data object EngineSettings : Screen
+ @Serializable data object AppPriority : Screen
+ @Serializable data object GlobalBlocklist : Screen
+ @Serializable data object BlocklistApps : Screen
+ @Serializable data object IslandSettings : Screen
+ @Serializable data object DndSettings : Screen
+ @Serializable data object PermanentIslandConfig : Screen
+ @Serializable data object ReplyCustomization : Screen
+}
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/design/DesignScreen.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/design/DesignScreen.kt
index b076310..41334f9 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/design/DesignScreen.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/design/DesignScreen.kt
@@ -325,10 +325,14 @@ fun SectionHeader(title: String, onClick: () -> Unit) {
fun HeroSection() {
val uriHandler = LocalUriHandler.current
val items = listOf(
- HeroItem(stringResource(R.string.design_hero_customization_title), stringResource(R.string.design_hero_customization_subtitle), Color(0xFF4CAF50),{}),
- HeroItem(stringResource(R.string.design_hero_pro_title), stringResource(R.string.design_hero_pro_subtitle), Color(0xFF2196F3),{ uriHandler.openUri("https://github.com/D4vidDf/HyperBridge/discussions/78") }),
- HeroItem(stringResource(R.string.design_hero_community_title), stringResource(R.string.design_hero_community_subtitle), Color(0xFF9C27B0),
- { uriHandler.openUri("https://github.com/D4vidDf/HyperBridge/discussions") })
+ HeroItem(stringResource(R.string.design_hero_customization_title), stringResource(R.string.design_hero_customization_subtitle), Color(0xFF4CAF50)) {},
+ HeroItem(stringResource(R.string.design_hero_pro_title), stringResource(R.string.design_hero_pro_subtitle), Color(0xFF2196F3)) {
+ uriHandler.openUri(
+ "https://github.com/D4vidDf/HyperBridge/discussions/78"
+ )
+ },
+ HeroItem(stringResource(R.string.design_hero_community_title), stringResource(R.string.design_hero_community_subtitle), Color(0xFF9C27B0)
+ ) { uriHandler.openUri("https://github.com/D4vidDf/HyperBridge/discussions") }
)
val state = rememberCarouselState { items.size }
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/design/SavedAppWidgetsScreen.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/design/SavedAppWidgetsScreen.kt
index e3160b5..0c1bf1e 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/design/SavedAppWidgetsScreen.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/design/SavedAppWidgetsScreen.kt
@@ -61,6 +61,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import androidx.core.content.edit
// --- DATA MODEL ---
data class SavedWidgetGroup(
@@ -92,7 +93,7 @@ fun SavedAppWidgetsScreen(
var tabIndex by remember { mutableIntStateOf(1) } // 0 = Favorites, 1 = All
var showPermissionReminder by remember { mutableStateOf(false) }
- val refreshTrigger = remember { mutableStateOf(0) }
+ val refreshTrigger = remember { mutableIntStateOf(0) }
val pullState = rememberPullToRefreshState()
val isRefreshing = isLoading && allGroups.isNotEmpty()
@@ -107,11 +108,11 @@ fun SavedAppWidgetsScreen(
if (lastVersion in 1 until currentVersion) {
showPermissionReminder = true
}
- sharedPrefs.edit().putInt("last_seen_version", currentVersion).apply()
+ sharedPrefs.edit { putInt("last_seen_version", currentVersion) }
}
// Fetch Saved Widgets (Reacts to new widgets being added dynamically)
- LaunchedEffect(savedIds, refreshTrigger.value) {
+ LaunchedEffect(savedIds, refreshTrigger.intValue) {
if (savedIds == null) return@LaunchedEffect
isLoading = true
@@ -127,11 +128,11 @@ fun SavedAppWidgetsScreen(
val appName = try {
val appInfo = context.packageManager.getApplicationInfo(pkg, 0)
context.packageManager.getApplicationLabel(appInfo).toString()
- } catch (e: Exception) { pkg }
+ } catch (_: Exception) { pkg }
val icon = try {
context.packageManager.getApplicationIcon(pkg)
- } catch (e: Exception) { null }
+ } catch (_: Exception) { null }
SavedWidgetGroup(pkg, appName, icon, ids)
}.sortedBy { it.appName }
@@ -318,7 +319,7 @@ fun SavedAppWidgetsScreen(
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
PullToRefreshBox(
isRefreshing = isRefreshing,
- onRefresh = { refreshTrigger.value++ },
+ onRefresh = { refreshTrigger.intValue++ },
state = pullState,
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.TopCenter,
@@ -368,7 +369,7 @@ fun SavedAppWidgetsScreen(
// 2. Remove from database and refresh UI
scope.launch {
preferences.removeWidgetId(widgetId)
- refreshTrigger.value++
+ refreshTrigger.intValue++
}
}
)
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/design/WidgetConfigScreen.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/design/WidgetConfigScreen.kt
index 89252dc..6e55816 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/design/WidgetConfigScreen.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/design/WidgetConfigScreen.kt
@@ -91,6 +91,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@@ -117,6 +118,7 @@ fun WidgetConfigScreen(
val scope = rememberCoroutineScope()
val appPreferences = remember { AppPreferences(context.applicationContext) }
val configuration = LocalConfiguration.current
+ val density = LocalDensity.current
val screenWidthDp = configuration.screenWidthDp
val previewWidthDp = screenWidthDp - 32
@@ -158,7 +160,7 @@ fun WidgetConfigScreen(
isShowShade = config.isShowShade
// Timeout Logic
- config.timeout?.let {
+ config.timeout.let {
if (it <= 0) {
isTimeoutEnabled = false
timeoutSeconds = 0
@@ -172,11 +174,14 @@ fun WidgetConfigScreen(
updateInterval = config.updateIntervalMinutes.toFloat()
}
+ val updatedToastText = stringResource(R.string.widget_updated_toast)
+ val settingsErrorText = stringResource(R.string.widget_settings_error)
+
val reconfigureLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
- Toast.makeText(context, context.getString(R.string.widget_updated_toast), Toast.LENGTH_SHORT).show()
+ Toast.makeText(context, updatedToastText, Toast.LENGTH_SHORT).show()
}
}
@@ -186,7 +191,7 @@ fun WidgetConfigScreen(
configureIntent.flags = 0
reconfigureLauncher.launch(configureIntent)
} catch (e: Exception) {
- Toast.makeText(context, context.getString(R.string.widget_settings_error), Toast.LENGTH_SHORT).show()
+ Toast.makeText(context, settingsErrorText, Toast.LENGTH_SHORT).show()
}
}
}
@@ -262,7 +267,7 @@ fun WidgetConfigScreen(
update = { wrapper ->
val hostView = wrapper.getChildAt(0)
if (hostView != null) {
- val density = context.resources.displayMetrics.density
+ val densityVal = density.density
val hDp = when (selectedSize) {
WidgetSize.SMALL -> 100
WidgetSize.MEDIUM -> 180
@@ -271,8 +276,8 @@ fun WidgetConfigScreen(
else -> 180
}
- val widthPx = (previewWidthDp * density).toInt()
- val heightPx = (hDp * density).toInt()
+ val widthPx = (previewWidthDp * densityVal).toInt()
+ val heightPx = (hDp * densityVal).toInt()
val widthSpec = View.MeasureSpec.makeMeasureSpec(widthPx, View.MeasureSpec.EXACTLY)
val heightSpec = View.MeasureSpec.makeMeasureSpec(heightPx, View.MeasureSpec.EXACTLY)
@@ -600,4 +605,4 @@ fun BehaviorSettings(
}
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/design/WidgetPickerScreen.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/design/WidgetPickerScreen.kt
index a28d3af..e35d036 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/design/WidgetPickerScreen.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/design/WidgetPickerScreen.kt
@@ -116,7 +116,7 @@ fun WidgetPickerScreen(
var allGroups by remember { mutableStateOf>(emptyList()) }
var searchQuery by remember { mutableStateOf("") }
var tabIndex by remember { mutableIntStateOf(0) } // 0 = Recommended, 1 = All
- var pendingWidgetId by remember { mutableStateOf(-1) }
+ var pendingWidgetId by remember { mutableIntStateOf(-1) }
val bindLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
@@ -140,7 +140,7 @@ fun WidgetPickerScreen(
val appName = context.packageManager.getApplicationLabel(context.packageManager.getApplicationInfo(pkg, 0)).toString()
val icon = context.packageManager.getApplicationIcon(pkg)
WidgetAppGroup(pkg, appName, icon, list)
- } catch (e: Exception) { null }
+ } catch (_: Exception) { null }
}.sortedBy { it.appName }
allGroups = uiGroups
@@ -151,11 +151,7 @@ fun WidgetPickerScreen(
val filteredWidgets = if (tabIndex == 0) {
group.widgets.filter { w ->
// Filter specifically for 1x4 and 2x4 layouts (Compatible with Island sizes)
- if (android.os.Build.VERSION.SDK_INT >= 31) {
- w.targetCellWidth == 4 && (w.targetCellHeight == 1 || w.targetCellHeight == 2)
- } else {
- w.minWidth >= 200 && w.minHeight <= 150 // Rough estimation for older Android versions
- }
+ w.targetCellWidth == 4 && (w.targetCellHeight == 1 || w.targetCellHeight == 2)
}
} else group.widgets
@@ -408,26 +404,25 @@ fun WidgetChildItem(
val label = info.loadLabel(context.packageManager)
// Convert dp dimensions to Android Grid Proportions (e.g., 4x1, 2x2)
- val cols = if (android.os.Build.VERSION.SDK_INT >= 31) {
- info.targetCellWidth
- } else {
- maxOf(1, Math.ceil((info.minWidth + 30) / 70.0).toInt())
- }
+ val cols = info.targetCellWidth
- val rows = if (android.os.Build.VERSION.SDK_INT >= 31) {
- info.targetCellHeight
- } else {
- maxOf(1, Math.ceil((info.minHeight + 30) / 70.0).toInt())
- }
+ val rows = info.targetCellHeight
val dims = "$cols × $rows"
- var preview by remember { mutableStateOf(null) }
+ var previewBitmap by remember { mutableStateOf(null) }
LaunchedEffect(info) {
withContext(Dispatchers.IO) {
- preview = try {
- info.loadPreviewImage(context, 0) ?: info.loadIcon(context, 0)
- } catch (e: Exception) { null }
+ try {
+ val d = info.loadPreviewImage(context, 0) ?: info.loadIcon(context, 0)
+ if (d != null) {
+ val w = d.intrinsicWidth.takeIf { it > 0 } ?: 200
+ val h = d.intrinsicHeight.takeIf { it > 0 } ?: 200
+ val safeW = w.coerceIn(100, 1000)
+ val safeH = h.coerceIn(100, 1000)
+ previewBitmap = d.toBitmap(width = safeW, height = safeH).asImageBitmap()
+ }
+ } catch (_: Exception) { }
}
}
@@ -447,9 +442,9 @@ fun WidgetChildItem(
modifier = Modifier.padding(24.dp),
contentAlignment = Alignment.Center
) {
- if (preview != null) {
+ if (previewBitmap != null) {
Image(
- bitmap = preview!!.toBitmap().asImageBitmap(),
+ bitmap = previewBitmap!!,
contentDescription = null,
modifier = Modifier.fillMaxWidth().heightIn(max = 200.dp),
alignment = Alignment.Center
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/design/WidgetPickerViewModel.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/design/WidgetPickerViewModel.kt
deleted file mode 100644
index 7109678..0000000
--- a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/design/WidgetPickerViewModel.kt
+++ /dev/null
@@ -1,57 +0,0 @@
-package com.d4viddf.hyperbridge.ui.screens.design
-
-import android.appwidget.AppWidgetManager
-import android.content.Context
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-
-
-class WidgetPickerViewModel : ViewModel() {
-
- private val _widgetGroups = MutableStateFlow>(emptyList())
- val widgetGroups = _widgetGroups.asStateFlow()
-
- private val _isLoading = MutableStateFlow(true)
- val isLoading = _isLoading.asStateFlow()
-
- fun loadWidgets(context: Context) {
- viewModelScope.launch {
- _isLoading.value = true
- val groups = withContext(Dispatchers.IO) {
- val appWidgetManager = AppWidgetManager.getInstance(context)
- val packageManager = context.packageManager
-
- // 1. Get all providers
- val providers = appWidgetManager.getInstalledProviders()
-
- // 2. Group by Package Name
- val grouped = providers.groupBy { it.provider.packageName }
-
- // 3. Map to UI Model
- grouped.mapNotNull { (packageName, providerList) ->
- try {
- val appInfo = packageManager.getApplicationInfo(packageName, 0)
- val appName = packageManager.getApplicationLabel(appInfo).toString()
- val appIcon = packageManager.getApplicationIcon(appInfo)
-
- WidgetAppGroup(
- packageName = packageName,
- appName = appName,
- appIcon = appIcon,
- widgets = providerList
- )
- } catch (e: Exception) {
- null // Skip apps that can't be resolved
- }
- }.sortedBy { it.appName }
- }
- _widgetGroups.value = groups
- _isLoading.value = false
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/home/HomeScreen.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/home/HomeScreen.kt
index cb35e8a..0d7803f 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/home/HomeScreen.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/home/HomeScreen.kt
@@ -15,11 +15,14 @@ import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Apps
import androidx.compose.material.icons.filled.Brush
import androidx.compose.material.icons.filled.ToggleOn
+import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.outlined.Apps
import androidx.compose.material.icons.outlined.Brush
import androidx.compose.material.icons.outlined.ToggleOff
@@ -40,6 +43,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.viewmodel.compose.viewModel
import com.d4viddf.hyperbridge.R
@@ -101,7 +105,7 @@ fun HomeScreen(
Scaffold(
bottomBar = {
AnimatedVisibility(
- visible = selectedTab != 0 || designRoute == DesignRoute.DASHBOARD,
+ visible = (selectedTab != 0 || designRoute == DesignRoute.DASHBOARD) && !showWidgetPicker && editingWidgetId == null,
enter = slideInVertically { it } + fadeIn(),
exit = slideOutVertically { it } + fadeOut()
) {
@@ -128,148 +132,230 @@ fun HomeScreen(
}
}
) { padding ->
- Box(modifier = Modifier.padding(bottom = padding.calculateBottomPadding())) {
- when (selectedTab) {
- 0 -> {
- AnimatedContent(
- targetState = designRoute,
- transitionSpec = {
- if (targetState.ordinal > initialState.ordinal) {
- slideInHorizontally { it } + fadeIn() togetherWith slideOutHorizontally { -it / 3 } + fadeOut()
- } else {
- slideInHorizontally { -it } + fadeIn() togetherWith slideOutHorizontally { it / 3 } + fadeOut()
- }
- },
- label = "DesignTabNav"
- ) { route ->
- when (route) {
- DesignRoute.DASHBOARD -> {
- DesignScreen(
- onNavigateToWidgets = { designRoute = DesignRoute.WIDGET_LIST },
- onNavigateToThemes = { designRoute = DesignRoute.THEME_MANAGER },
- onEditTheme = { themeId ->
- editingThemeId = themeId
- designRoute = DesignRoute.THEME_CREATOR
- },
- onLaunchPicker = { showWidgetPicker = true },
- onSettingsClick = onSettingsClick
- )
- }
- DesignRoute.WIDGET_LIST -> {
- SavedAppWidgetsScreen(
- onBack = { designRoute = DesignRoute.DASHBOARD },
- onEditWidget = { id -> editingWidgetId = id },
- onAddMore = { showWidgetPicker = true }
+ androidx.compose.foundation.layout.Column(
+ modifier = Modifier
+ .padding(bottom = padding.calculateBottomPadding())
+ .fillMaxSize()
+ ) {
+ val prefs = remember { com.d4viddf.hyperbridge.data.AppPreferences(context) }
+ val showWarning by prefs.featuredPermissionWarningFlow.collectAsState(initial = false)
+
+ if (showWarning) {
+ androidx.compose.material3.Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ colors = androidx.compose.material3.CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer
+ ),
+ onClick = {
+ val intent =
+ Intent(android.provider.Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
+ putExtra(
+ android.provider.Settings.EXTRA_APP_PACKAGE,
+ context.packageName
)
}
- DesignRoute.THEME_MANAGER -> {
- ThemeManagerScreen(
- onBack = { designRoute = DesignRoute.DASHBOARD },
- onFindThemes = {
- val query = "HyperBridge Theme"
- try {
- val intent = Intent(Intent.ACTION_VIEW, "market://search?q=$query&c=apps".toUri())
- intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
- context.startActivity(intent)
- } catch (_: Exception) {
- val intent = Intent(Intent.ACTION_VIEW, "https://play.google.com/store/search?q=$query&c=apps".toUri())
- intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
- context.startActivity(intent)
+ context.startActivity(intent)
+ }
+ ) {
+ androidx.compose.foundation.layout.Row(
+ modifier = Modifier.padding(16.dp),
+ verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Default.Warning,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onErrorContainer
+ )
+ androidx.compose.foundation.layout.Spacer(Modifier.width(12.dp))
+ androidx.compose.foundation.layout.Column(modifier = Modifier.weight(1f)) {
+ Text(
+ stringResource(R.string.featured_notifications_troubleshoot_title),
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.onErrorContainer
+ )
+ Text(
+ stringResource(R.string.featured_notifications_troubleshoot_desc),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onErrorContainer
+ )
+ }
+ }
+ }
+ }
+
+ Box(modifier = Modifier.weight(1f)) {
+ when (selectedTab) {
+ 0 -> {
+ AnimatedContent(
+ targetState = designRoute,
+ transitionSpec = {
+ if (targetState.ordinal > initialState.ordinal) {
+ slideInHorizontally { it } + fadeIn() togetherWith slideOutHorizontally { -it / 3 } + fadeOut()
+ } else {
+ slideInHorizontally { -it } + fadeIn() togetherWith slideOutHorizontally { it / 3 } + fadeOut()
+ }
+ },
+ label = "DesignTabNav"
+ ) { route ->
+ when (route) {
+ DesignRoute.DASHBOARD -> {
+ DesignScreen(
+ onNavigateToWidgets = {
+ designRoute = DesignRoute.WIDGET_LIST
+ },
+ onNavigateToThemes = {
+ designRoute = DesignRoute.THEME_MANAGER
+ },
+ onEditTheme = { themeId ->
+ editingThemeId = themeId
+ designRoute = DesignRoute.THEME_CREATOR
+ },
+ onLaunchPicker = { showWidgetPicker = true },
+ onSettingsClick = onSettingsClick
+ )
+ }
+
+ DesignRoute.WIDGET_LIST -> {
+ SavedAppWidgetsScreen(
+ onBack = { designRoute = DesignRoute.DASHBOARD },
+ onEditWidget = { id -> editingWidgetId = id },
+ onAddMore = { showWidgetPicker = true }
+ )
+ }
+
+ DesignRoute.THEME_MANAGER -> {
+ ThemeManagerScreen(
+ onBack = { designRoute = DesignRoute.DASHBOARD },
+ onFindThemes = {
+ val query = "HyperBridge Theme"
+ try {
+ val intent = Intent(
+ Intent.ACTION_VIEW,
+ "market://search?q=$query&c=apps".toUri()
+ )
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ context.startActivity(intent)
+ } catch (_: Exception) {
+ val intent = Intent(
+ Intent.ACTION_VIEW,
+ "https://play.google.com/store/search?q=$query&c=apps".toUri()
+ )
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ context.startActivity(intent)
+ }
+ },
+ onCreateTheme = {
+ editingThemeId = null
+ designRoute = DesignRoute.THEME_CREATOR
+ },
+ onEditTheme = { id ->
+ editingThemeId = id
+ designRoute = DesignRoute.THEME_CREATOR
}
- },
- onCreateTheme = {
- editingThemeId = null
- designRoute = DesignRoute.THEME_CREATOR
- },
- onEditTheme = { id ->
- editingThemeId = id
- designRoute = DesignRoute.THEME_CREATOR
- }
- )
- }
- DesignRoute.THEME_CREATOR -> {
- ThemeCreatorScreen(
- editThemeId = editingThemeId,
- onBack = {
- designRoute = DesignRoute.THEME_MANAGER
- editingThemeId = null
- },
- onThemeCreated = {
- designRoute = DesignRoute.THEME_MANAGER
- editingThemeId = null
- }
- )
+ )
+ }
+
+ DesignRoute.THEME_CREATOR -> {
+ ThemeCreatorScreen(
+ editThemeId = editingThemeId,
+ onBack = {
+ designRoute = DesignRoute.THEME_MANAGER
+ editingThemeId = null
+ },
+ onThemeCreated = {
+ designRoute = DesignRoute.THEME_MANAGER
+ editingThemeId = null
+ }
+ )
+ }
}
}
}
+
+ 1 -> ActiveAppsPage(
+ apps = activeApps,
+ isLoading = isLoading,
+ viewModel = viewModel,
+ onConfig = { configApp = it },
+ onSettingsClick = onSettingsClick
+ )
+
+ 2 -> LibraryPage(
+ apps = libraryApps,
+ isLoading = isLoading,
+ viewModel = viewModel,
+ onConfig = { configApp = it },
+ onSettingsClick = onSettingsClick
+ )
}
- 1 -> ActiveAppsPage(
- apps = activeApps,
- isLoading = isLoading,
- viewModel = viewModel,
- onConfig = { configApp = it },
- onSettingsClick = onSettingsClick
- )
- 2 -> LibraryPage(
- apps = libraryApps,
- isLoading = isLoading,
- viewModel = viewModel,
- onConfig = { configApp = it },
- onSettingsClick = onSettingsClick
- )
}
}
- }
- // --- OVERLAYS ---
- AnimatedVisibility(
- visible = showWidgetPicker,
- enter = slideInVertically(initialOffsetY = { it }, animationSpec = tween(400)) + fadeIn(),
- exit = slideOutVertically(targetOffsetY = { it }, animationSpec = tween(400)) + fadeOut(),
- modifier = Modifier
- .fillMaxSize()
- .background(MaterialTheme.colorScheme.surface)
- ) {
- WidgetPickerScreen(
- onBack = { showWidgetPicker = false },
- onWidgetSelected = { newId ->
- showWidgetPicker = false
- editingWidgetId = newId
- }
- )
- }
-
- AnimatedVisibility(
- visible = editingWidgetId != null,
- enter = slideInVertically(initialOffsetY = { it }, animationSpec = tween(400)) + fadeIn(),
- exit = slideOutVertically(targetOffsetY = { it }, animationSpec = tween(400)) + fadeOut(),
- modifier = Modifier
- .fillMaxSize()
- .background(MaterialTheme.colorScheme.surface)
- ) {
- if (editingWidgetId != null) {
- WidgetConfigScreen(
- widgetId = editingWidgetId!!,
- onBack = { editingWidgetId = null }
+ // --- OVERLAYS ---
+ AnimatedVisibility(
+ visible = showWidgetPicker,
+ enter = slideInVertically(
+ initialOffsetY = { it },
+ animationSpec = tween(400)
+ ) + fadeIn(),
+ exit = slideOutVertically(
+ targetOffsetY = { it },
+ animationSpec = tween(400)
+ ) + fadeOut(),
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surface)
+ ) {
+ WidgetPickerScreen(
+ onBack = { showWidgetPicker = false },
+ onWidgetSelected = { newId ->
+ showWidgetPicker = false
+ editingWidgetId = newId
+ }
)
}
- }
- // [FIXED] Safe handling of nullable state
- if (configApp != null) {
- // Capture the non-null value locally for the lambda scope
- val currentConfigApp = configApp!!
-
- AppConfigBottomSheet(
- app = currentConfigApp,
- viewModel = viewModel,
- onDismiss = { configApp = null },
- onNavConfigClick = {
- // Use the LOCAL variable, not the mutable state which might have changed
- onNavConfigClick(currentConfigApp.packageName)
- configApp = null
+ AnimatedVisibility(
+ visible = editingWidgetId != null,
+ enter = slideInVertically(
+ initialOffsetY = { it },
+ animationSpec = tween(400)
+ ) + fadeIn(),
+ exit = slideOutVertically(
+ targetOffsetY = { it },
+ animationSpec = tween(400)
+ ) + fadeOut(),
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surface)
+ ) {
+ if (editingWidgetId != null) {
+ WidgetConfigScreen(
+ widgetId = editingWidgetId!!,
+ onBack = { editingWidgetId = null }
+ )
}
- )
+ }
+
+ // [FIXED] Safe handling of nullable state
+ if (configApp != null) {
+ // Capture the non-null value locally for the lambda scope
+ val currentConfigApp = configApp!!
+
+ AppConfigBottomSheet(
+ app = currentConfigApp,
+ viewModel = viewModel,
+ onDismiss = { configApp = null },
+ onNavConfigClick = {
+ // Use the LOCAL variable, not the mutable state which might have changed
+ onNavConfigClick(currentConfigApp.packageName)
+ configApp = null
+ }
+
+ )
+ }
}
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/onboarding/OnboardingScreen.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/onboarding/OnboardingScreen.kt
index 6c844b4..12f63e2 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/onboarding/OnboardingScreen.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/onboarding/OnboardingScreen.kt
@@ -1,6 +1,7 @@
package com.d4viddf.hyperbridge.ui.screens.onboarding
import android.Manifest
+import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.os.Build
@@ -8,6 +9,11 @@ import android.provider.Settings
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@@ -18,6 +24,7 @@ import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -31,33 +38,49 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.core.graphics.drawable.toBitmap
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.ArrowForward
+import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material.icons.filled.Architecture
import androidx.compose.material.icons.filled.BatteryStd
-import androidx.compose.material.icons.filled.Bolt
-import androidx.compose.material.icons.filled.Cancel
-import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.automirrored.filled.Reply
import androidx.compose.material.icons.filled.Construction
+import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material.icons.filled.Info
+import androidx.compose.material.icons.filled.Layers
+import androidx.compose.material.icons.filled.LowPriority
+import androidx.compose.material.icons.filled.NotificationAdd
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.NotificationsActive
+import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Security
+import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Smartphone
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.filled.WifiOff
+import androidx.compose.material.icons.rounded.Devices
+import androidx.compose.material.icons.rounded.Notifications
+import androidx.compose.material.icons.rounded.Timer
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Slider
+import androidx.compose.material3.Switch
import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -65,48 +88,70 @@ 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.graphics.Color
-import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
+import com.d4viddf.hyperbridge.BuildConfig
import com.d4viddf.hyperbridge.R
+import com.d4viddf.hyperbridge.data.AppPreferences
+import com.d4viddf.hyperbridge.models.IslandConfig
+import com.d4viddf.hyperbridge.models.IslandLimitMode
+import com.d4viddf.hyperbridge.models.NotificationType
+import com.d4viddf.hyperbridge.ui.components.EngineOptionCard
+import com.d4viddf.hyperbridge.ui.components.EnginePreview
+import com.d4viddf.hyperbridge.ui.components.PermanentIslandPreview
+import com.d4viddf.hyperbridge.ui.components.formatSeconds
+import com.d4viddf.hyperbridge.ui.components.timeoutSteps
+import com.d4viddf.hyperbridge.ui.theme.HyperBridgeTheme
import com.d4viddf.hyperbridge.util.DeviceUtils
import com.d4viddf.hyperbridge.util.isNotificationServiceEnabled
import com.d4viddf.hyperbridge.util.isPostNotificationsEnabled
-import com.d4viddf.hyperbridge.util.toBitmap
import kotlinx.coroutines.launch
+import kotlin.time.Duration.Companion.milliseconds
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun OnboardingScreen(onFinish: () -> Unit) {
- // 7 Pages
- val pagerState = rememberPagerState(pageCount = { 7 })
- val scope = rememberCoroutineScope()
+ val isCN = remember { DeviceUtils.isCNRom }
+ val isXiaomi = remember { DeviceUtils.isXiaomi }
+ val isCompatibleOS = remember { DeviceUtils.isCompatibleOS() }
+ val canProceedCompat = isXiaomi && isCompatibleOS
+
val context = LocalContext.current
+ val prefs = remember { AppPreferences(context) }
+ val useNativeLiveUpdates by prefs.useNativeLiveUpdates.collectAsState(initial = false)
+ val needsShizuku = !useNativeLiveUpdates && isCN
+
+ val isShizukuWorkaroundEnabled by prefs.isShizukuWorkaroundEnabled.collectAsState(initial = false)
+ val isShizukuPermissionGranted by com.d4viddf.hyperbridge.util.ShizukuManager.isPermissionGranted.collectAsState()
+
+ val totalPages = if (needsShizuku) 18 else 17
+ val pagerState = rememberPagerState(pageCount = { totalPages })
+ val scope = rememberCoroutineScope()
// Handle Hardware Back Button
- BackHandler(enabled = pagerState.currentPage > 0) {
+ BackHandler(enabled = pagerState.currentPage > 1) {
scope.launch { pagerState.animateScrollToPage(pagerState.currentPage - 1) }
}
// --- Permissions State ---
var isListenerGranted by remember { mutableStateOf(isNotificationServiceEnabled(context)) }
var isPostGranted by remember { mutableStateOf(isPostNotificationsEnabled(context)) }
+ var isOverlayGranted by remember { mutableStateOf(Settings.canDrawOverlays(context)) }
// --- Compatibility Logic ---
- val isXiaomi = remember { DeviceUtils.isXiaomi }
- val isCompatibleOS = remember { DeviceUtils.isCompatibleOS() }
- val canProceedCompat = isXiaomi && isCompatibleOS
+ // Moved up
// --- Permission Launcher ---
val postPermissionLauncher = rememberLauncherForActivityResult(
@@ -121,6 +166,7 @@ fun OnboardingScreen(onFinish: () -> Unit) {
if (event == Lifecycle.Event.ON_RESUME) {
isListenerGranted = isNotificationServiceEnabled(context)
isPostGranted = isPostNotificationsEnabled(context)
+ isOverlayGranted = Settings.canDrawOverlays(context)
}
}
lifecycleOwner.lifecycle.addObserver(observer)
@@ -130,48 +176,87 @@ fun OnboardingScreen(onFinish: () -> Unit) {
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
bottomBar = {
- if (pagerState.currentPage > 0) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(24.dp)
- .navigationBarsPadding(),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
- ) {
- // Pagination Dots
- Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- repeat(6) { iteration ->
- val active = (pagerState.currentPage - 1) == iteration
- val width = if (active) 32.dp else 10.dp
- val color = if (active) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant
- Box(
- modifier = Modifier
- .height(10.dp)
- .width(width)
- .clip(CircleShape)
- .background(color)
- )
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(24.dp)
+ .navigationBarsPadding(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ val currentPage = pagerState.currentPage
+ val isLastPage = currentPage == totalPages - 1
+
+ if (currentPage == 0) {
+ Button(
+ onClick = { scope.launch { pagerState.animateScrollToPage(1) } },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(56.dp),
+ shape = RoundedCornerShape(16.dp),
+ colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary)
+ ) {
+ Text(
+ stringResource(R.string.get_started),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ } else {
+ val canProceed = if (needsShizuku) {
+ when (currentPage) {
+ 1 -> canProceedCompat || BuildConfig.DEBUG
+ 2 -> isPostGranted
+ 3 -> isListenerGranted
+ 4 -> isOverlayGranted
+ 12 -> {
+ // If they turn the workaround off, they can proceed without permission
+ !isShizukuWorkaroundEnabled || isShizukuPermissionGranted || BuildConfig.DEBUG
+ }
+ else -> true
+ }
+ } else {
+ when (currentPage) {
+ 1 -> canProceedCompat || BuildConfig.DEBUG
+ 2 -> isPostGranted
+ 3 -> isListenerGranted
+ 4 -> isOverlayGranted
+ else -> true
}
}
- // Blocking Logic
- val canProceed = when (pagerState.currentPage) {
- 3 -> canProceedCompat
- 4 -> isPostGranted
- 5 -> isListenerGranted
- else -> true
+ if (currentPage > 1) {
+ OutlinedButton(
+ onClick = {
+ scope.launch { pagerState.animateScrollToPage(currentPage - 1) }
+ },
+ modifier = Modifier
+ .weight(1f)
+ .height(56.dp),
+ shape = RoundedCornerShape(16.dp),
+ contentPadding = PaddingValues(0.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.back),
+ style = MaterialTheme.typography.labelLarge,
+ fontSize = 16.sp
+ )
+ }
+ } else {
+ Spacer(modifier = Modifier.weight(1f))
}
- val isLastPage = pagerState.currentPage == 6
Button(
onClick = {
if (isLastPage) onFinish()
- else scope.launch { pagerState.animateScrollToPage(pagerState.currentPage + 1) }
+ else scope.launch { pagerState.animateScrollToPage(currentPage + 1) }
},
+ modifier = Modifier
+ .weight(1f)
+ .height(56.dp),
enabled = canProceed,
shape = RoundedCornerShape(16.dp),
- contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp),
+ contentPadding = PaddingValues(0.dp),
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary)
) {
Text(
@@ -179,8 +264,6 @@ fun OnboardingScreen(onFinish: () -> Unit) {
style = MaterialTheme.typography.labelLarge,
fontSize = 16.sp
)
- Spacer(modifier = Modifier.width(8.dp))
- Icon(Icons.AutoMirrored.Filled.ArrowForward, null, Modifier.size(18.dp))
}
}
}
@@ -193,33 +276,88 @@ fun OnboardingScreen(onFinish: () -> Unit) {
.fillMaxSize(),
userScrollEnabled = false
) { page ->
- when (page) {
- 0 -> WelcomePage(onStartClick = { scope.launch { pagerState.animateScrollToPage(1) } })
- 1 -> ExplanationPage()
- 2 -> PrivacyPage()
- 3 -> CompatibilityPage()
- 4 -> PostPermissionPage(
- isGranted = isPostGranted,
- onRequest = {
- postPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
- }
- )
- 5 -> ListenerPermissionPage(context, isListenerGranted)
- 6 -> OptimizationPage(context)
+ val adjustedPage = if (needsShizuku && page > 12) page - 1 else page
+
+ if (needsShizuku && page == 12) {
+ ShizukuPage(prefs)
+ } else {
+ when (adjustedPage) {
+ 0 -> WelcomePage()
+ 1 -> CompatibilityPage()
+ 2 -> PostPermissionPage(
+ isGranted = isPostGranted,
+ onRequest = {
+ postPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
+ }
+ )
+ 3 -> ListenerPermissionPage(context, isListenerGranted)
+ 4 -> OverlayPermissionPage(context, isOverlayGranted)
+ 5 -> FeaturedNotificationCheckPage(context)
+ 6 -> OptimizationPage(context)
+ 7 -> ExplanationPage()
+ 8 -> PrivacyPage()
+ 9 -> CustomizationPage()
+ 10 -> TriggersConfigPage(prefs)
+ 11 -> EngineConfigPage(prefs)
+ 12 -> PriorityEducationPage(prefs)
+ 13 -> BehaviorConfigPage(prefs)
+ 14 -> DndConfigPage(prefs)
+ 15 -> AutoHideConfigPage(prefs)
+ 16 -> PermanentIslandConfigPage(prefs)
+ }
}
}
}
}
+@Composable
+fun DndConfigPage(prefs: AppPreferences) {
+ val autoDetect by prefs.autoDetectDndFlow.collectAsState(initial = false)
+ val isDndEnabled by prefs.isDndModeEnabledFlow.collectAsState(initial = false)
+ val scope = rememberCoroutineScope()
+
+ OnboardingPageLayout(
+ title = stringResource(R.string.dnd_mode_title),
+ description = stringResource(R.string.dnd_mode_desc),
+ icon = Icons.Default.Notifications,
+ iconColor = MaterialTheme.colorScheme.primary
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ ListOptionCard(
+ title = stringResource(R.string.dnd_auto_detect),
+ subtitle = stringResource(R.string.dnd_auto_detect_desc),
+ icon = Icons.Default.Settings,
+ shape = RoundedCornerShape(24.dp, 24.dp, 4.dp, 4.dp),
+ onClick = { scope.launch { prefs.setAutoDetectDnd(!autoDetect) } },
+ trailingContent = {
+ Switch(checked = autoDetect, onCheckedChange = { scope.launch { prefs.setAutoDetectDnd(it) } })
+ }
+ )
+
+ ListOptionCard(
+ title = stringResource(R.string.dnd_manual_toggle),
+ subtitle = stringResource(R.string.dnd_manual_toggle_desc),
+ icon = Icons.Default.Notifications,
+ shape = RoundedCornerShape(4.dp, 4.dp, 24.dp, 24.dp),
+ onClick = { scope.launch { prefs.setDndModeEnabled(!isDndEnabled) } },
+ trailingContent = {
+ Switch(checked = isDndEnabled, onCheckedChange = { scope.launch { prefs.setDndModeEnabled(it) } })
+ }
+ )
+ }
+ }
+}
+
// ==========================================
// PAGE COMPOSABLES
// ==========================================
@Composable
-fun WelcomePage(onStartClick: () -> Unit) {
+fun WelcomePage() {
val context = LocalContext.current
val appIconBitmap = remember(context) {
- try { context.packageManager.getApplicationIcon(context.packageName).toBitmap().asImageBitmap() } catch (e: Exception) { null }
+ try { context.packageManager.getApplicationIcon(context.packageName).toBitmap().asImageBitmap() }
+ catch (_: Exception) { null }
}
Column(
@@ -231,7 +369,6 @@ fun WelcomePage(onStartClick: () -> Unit) {
) {
Spacer(modifier = Modifier.weight(1f))
- // Icon (No Box Container)
if (appIconBitmap != null) {
Image(
bitmap = appIconBitmap,
@@ -239,11 +376,10 @@ fun WelcomePage(onStartClick: () -> Unit) {
modifier = Modifier.size(140.dp)
)
} else {
- Icon(
- imageVector = Icons.Default.Bolt,
+ Image(
+ painter = androidx.compose.ui.res.painterResource(id = R.drawable.ic_logo_foreground),
contentDescription = stringResource(R.string.logo_desc),
- modifier = Modifier.size(140.dp),
- tint = MaterialTheme.colorScheme.primary
+ modifier = Modifier.size(140.dp)
)
}
@@ -267,101 +403,43 @@ fun WelcomePage(onStartClick: () -> Unit) {
)
Spacer(modifier = Modifier.weight(1f))
-
- Button(
- onClick = onStartClick,
- modifier = Modifier
- .fillMaxWidth()
- .height(56.dp),
- shape = RoundedCornerShape(16.dp),
- colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary)
- ) {
- Text(
- stringResource(R.string.get_started),
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.Bold
- )
- }
- Spacer(modifier = Modifier.height(16.dp))
}
}
-// Shared Layout
@Composable
-fun OnboardingPageLayout(
- title: String,
- description: String,
- icon: ImageVector,
- iconColor: Color = MaterialTheme.colorScheme.primary,
- content: @Composable ColumnScope.() -> Unit
-) {
+fun ExplanationPage() {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
- .padding(horizontal = 24.dp),
+ .padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.weight(0.5f))
- // Expressive Icon Container
- Box(
- modifier = Modifier
- .size(120.dp)
- .background(iconColor.copy(alpha = 0.1f), CircleShape),
- contentAlignment = Alignment.Center
- ) {
- Icon(
- imageVector = icon,
- contentDescription = null,
- modifier = Modifier.size(56.dp),
- tint = iconColor
- )
- }
-
- Spacer(modifier = Modifier.height(40.dp))
-
Text(
- text = title,
+ text = stringResource(R.string.how_it_works),
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onBackground
)
- Spacer(modifier = Modifier.height(16.dp))
+ Spacer(modifier = Modifier.height(24.dp))
+
+ EnginePreview(isNative = false)
+
+ Spacer(modifier = Modifier.height(32.dp))
Text(
- text = description,
+ text = stringResource(R.string.how_it_works_desc),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
lineHeight = 24.sp
)
- Spacer(modifier = Modifier.height(32.dp))
-
- // Content Area
- Box(
- modifier = Modifier.fillMaxWidth(),
- contentAlignment = Alignment.Center
- ) {
- Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
- content()
- }
- }
-
- Spacer(modifier = Modifier.weight(1f))
- }
-}
+ Spacer(modifier = Modifier.height(24.dp))
-@Composable
-fun ExplanationPage() {
- OnboardingPageLayout(
- title = stringResource(R.string.how_it_works),
- description = stringResource(R.string.how_it_works_desc),
- icon = Icons.Default.Architecture,
- iconColor = MaterialTheme.colorScheme.tertiary
- ) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer),
shape = RoundedCornerShape(24.dp)
@@ -379,169 +457,923 @@ fun ExplanationPage() {
)
}
}
+
+ Spacer(modifier = Modifier.weight(1f))
}
}
+@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
-fun PrivacyPage() {
+fun TriggersConfigPage(prefs: AppPreferences) {
+ val activeTypes by prefs.globalNotificationTypesFlow.collectAsState(initial = emptySet())
+ val scope = rememberCoroutineScope()
+ var showSheet by remember { mutableStateOf(false) }
+ val sheetState = rememberModalBottomSheetState()
+
OnboardingPageLayout(
- title = stringResource(R.string.privacy_title),
- description = stringResource(R.string.privacy_desc),
- icon = Icons.Default.Security,
+ title = stringResource(R.string.active_triggers),
+ description = stringResource(R.string.active_triggers_global_desc),
+ icon = Icons.Default.NotificationAdd,
iconColor = MaterialTheme.colorScheme.primary
) {
- Card(
- colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
- shape = RoundedCornerShape(24.dp),
- modifier = Modifier.fillMaxWidth()
+ Button(
+ onClick = { showSheet = true },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(56.dp),
+ shape = RoundedCornerShape(16.dp)
) {
- Row(
- modifier = Modifier.padding(20.dp),
- verticalAlignment = Alignment.CenterVertically
+ Icon(Icons.Default.Settings, null)
+ Spacer(Modifier.width(12.dp))
+ Text(stringResource(R.string.configure), style = MaterialTheme.typography.bodyLarge)
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Text(
+ text = stringResource(R.string.onboarding_can_be_changed),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(horizontal = 16.dp)
+ )
+
+ if (showSheet) {
+ ModalBottomSheet(
+ onDismissRequest = { showSheet = false },
+ sheetState = sheetState
) {
- Icon(Icons.Default.WifiOff, null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
- Spacer(modifier = Modifier.width(16.dp))
- Column {
- Text(stringResource(R.string.privacy_card_title), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
- Text(stringResource(R.string.privacy_card_desc), style = MaterialTheme.typography.bodySmall)
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp)
+ .padding(bottom = 32.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ stringResource(R.string.active_triggers),
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(bottom = 8.dp)
+ )
+
+ NotificationType.entries.forEach { type ->
+ val isEnabled = activeTypes.contains(type.name)
+ Card(
+ onClick = {
+ scope.launch { prefs.updateGlobalNotificationType(type, !isEnabled) }
+ },
+ shape = RoundedCornerShape(16.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = if (isEnabled) MaterialTheme.colorScheme.primaryContainer.copy(0.3f) else MaterialTheme.colorScheme.surfaceContainerLow
+ ),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ Modifier.padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Checkbox(checked = isEnabled, onCheckedChange = null)
+ Spacer(Modifier.width(12.dp))
+ Column {
+ Text(stringResource(type.labelRes), fontWeight = FontWeight.Bold)
+ val descRes = when (type) {
+ NotificationType.STANDARD -> R.string.type_standard_desc
+ NotificationType.PROGRESS -> R.string.type_progress_desc
+ NotificationType.DOWNLOAD -> R.string.type_download_desc
+ NotificationType.MEDIA -> R.string.type_media_desc
+ NotificationType.NAVIGATION -> R.string.type_nav_desc
+ NotificationType.CALL -> R.string.type_call_desc
+ NotificationType.TIMER -> R.string.type_timer_desc
+ NotificationType.MESSAGE -> R.string.type_message_desc
+ }
+ Text(
+ stringResource(descRes),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ }
}
}
}
}
}
+@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun CompatibilityPage() {
- val isXiaomi = DeviceUtils.isXiaomi
- val isCompatibleOS = DeviceUtils.isCompatibleOS()
- val isCN = DeviceUtils.isCNRom
- val osVersion = DeviceUtils.getHyperOSVersion()
- val deviceName = DeviceUtils.getDeviceMarketName()
-
- val (icon, color, titleRes, descRes) = when {
- !isXiaomi -> Quad(Icons.Default.Cancel, MaterialTheme.colorScheme.error, R.string.unsupported_device, R.string.req_xiaomi)
- !isCompatibleOS -> Quad(Icons.Default.Cancel, MaterialTheme.colorScheme.error, R.string.unsupported_device, R.string.req_hyperos)
- else -> Quad(Icons.Default.CheckCircle, Color(0xFF34C759), R.string.device_compatible, R.string.compatible_msg)
- }
+fun PriorityEducationPage(prefs: AppPreferences) {
+ val currentMode by prefs.limitModeFlow.collectAsState(initial = IslandLimitMode.MOST_RECENT)
+ val scope = rememberCoroutineScope()
+ var showSheet by remember { mutableStateOf(false) }
+ val sheetState = rememberModalBottomSheetState()
OnboardingPageLayout(
- title = stringResource(titleRes),
- description = stringResource(descRes),
- icon = icon,
- iconColor = color
+ title = stringResource(R.string.priority_edu_title),
+ description = stringResource(R.string.priority_edu_desc),
+ icon = Icons.Default.LowPriority,
+ iconColor = MaterialTheme.colorScheme.primary
) {
- Card(
- colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
- shape = RoundedCornerShape(24.dp),
- modifier = Modifier.fillMaxWidth()
+ Button(
+ onClick = { showSheet = true },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(56.dp),
+ shape = RoundedCornerShape(16.dp)
) {
- Column(modifier = Modifier.padding(20.dp)) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- Icon(Icons.Default.Smartphone, null, Modifier.size(24.dp), tint = MaterialTheme.colorScheme.primary)
- Spacer(Modifier.width(16.dp))
- Column {
- Text(text = Build.MANUFACTURER.uppercase(), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary)
- Text(text = deviceName, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
- }
- }
- Spacer(Modifier.height(16.dp))
- HorizontalDivider(color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.1f))
- Spacer(Modifier.height(16.dp))
- Row(verticalAlignment = Alignment.CenterVertically) {
- Icon(Icons.Default.Info, null, Modifier.size(24.dp), tint = MaterialTheme.colorScheme.primary)
- Spacer(Modifier.width(16.dp))
- Column {
- Text(text = stringResource(R.string.system_version), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary)
- Text(text = osVersion, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
- }
- }
- }
+ Icon(Icons.Default.Settings, null)
+ Spacer(Modifier.width(12.dp))
+ Text(stringResource(R.string.configure), style = MaterialTheme.typography.bodyLarge)
}
- if (isCN && isXiaomi) {
- Spacer(modifier = Modifier.height(16.dp))
- Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), shape = RoundedCornerShape(24.dp)) {
- Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
- Icon(Icons.Default.Warning, null, tint = MaterialTheme.colorScheme.error)
- Spacer(Modifier.width(12.dp))
- Text(stringResource(R.string.warning_cn_rom_title), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onErrorContainer, fontWeight = FontWeight.Bold)
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Text(
+ text = stringResource(R.string.onboarding_can_be_changed),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(horizontal = 16.dp)
+ )
+
+ if (showSheet) {
+ ModalBottomSheet(
+ onDismissRequest = { showSheet = false },
+ sheetState = sheetState
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp)
+ .padding(bottom = 32.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ stringResource(R.string.island_behavior),
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(bottom = 8.dp)
+ )
+
+ IslandLimitMode.entries.forEach { mode ->
+ BehaviorOptionCard(
+ mode = mode,
+ isSelected = currentMode == mode,
+ onClick = {
+ scope.launch {
+ prefs.setLimitMode(mode)
+ prefs.setPriorityEduShown(true)
+ }
+ }
+ )
+ }
}
}
}
}
}
-// Helper
-data class Quad(val first: A, val second: B, val third: C, val fourth: D)
-
-// --- 5. POST PERMISSION PAGE ---
@Composable
-fun PostPermissionPage(isGranted: Boolean, onRequest: () -> Unit) {
- OnboardingPageLayout(
- title = stringResource(R.string.show_island),
- description = stringResource(R.string.perm_post_desc),
- icon = if (isGranted) Icons.Default.CheckCircle else Icons.Default.Notifications,
- iconColor = if (isGranted) Color(0xFF34C759) else MaterialTheme.colorScheme.primary
+fun BehaviorOptionCard(
+ mode: IslandLimitMode,
+ isSelected: Boolean,
+ onClick: () -> Unit
+) {
+ val borderColor by animateColorAsState(
+ if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent, label = "border"
+ )
+ val containerColor = if (isSelected)
+ MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)
+ else
+ MaterialTheme.colorScheme.surfaceContainerLow
+
+ Card(
+ onClick = onClick,
+ shape = RoundedCornerShape(16.dp),
+ colors = CardDefaults.cardColors(containerColor = containerColor),
+ border = if (isSelected) BorderStroke(2.dp, borderColor) else null,
+ modifier = Modifier.fillMaxWidth()
) {
- // FIX: Standard Button, Disabled when granted, Text changes, No Icon
- Button(
- onClick = { if (!isGranted) onRequest() },
- enabled = !isGranted,
- modifier = Modifier.fillMaxWidth().height(56.dp),
- shape = RoundedCornerShape(16.dp),
- colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.primary,
- )
+ Row(
+ modifier = Modifier.padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
) {
- Text(
- stringResource(if (isGranted) R.string.perm_granted else R.string.allow_notifications),
- style = MaterialTheme.typography.titleMedium
- )
+ RadioButton(selected = isSelected, onClick = null)
+ Spacer(modifier = Modifier.width(16.dp))
+ Column {
+ Text(
+ stringResource(mode.titleRes),
+ fontWeight = FontWeight.Bold,
+ style = MaterialTheme.typography.titleMedium
+ )
+ Text(
+ stringResource(mode.descRes),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
}
}
}
-// --- 6. LISTENER PERMISSION PAGE ---
@Composable
-fun ListenerPermissionPage(context: Context, isGranted: Boolean) {
+fun BehaviorConfigPage(prefs: AppPreferences) {
+ val config by prefs.globalConfigFlow.collectAsState(initial = IslandConfig())
+ val scope = rememberCoroutineScope()
+
OnboardingPageLayout(
- title = stringResource(R.string.read_data),
- description = stringResource(R.string.perm_listener_desc),
- icon = if (isGranted) Icons.Default.CheckCircle else Icons.Default.NotificationsActive,
- iconColor = if (isGranted) Color(0xFF34C759) else MaterialTheme.colorScheme.secondary
+ title = stringResource(R.string.island_behavior),
+ description = stringResource(R.string.behavior_desc_glob_config),
+ icon = Icons.Default.Settings,
+ iconColor = MaterialTheme.colorScheme.primary
) {
- // FIX: Standard Button, Disabled when granted, Text changes, No Icon
- Button(
- onClick = {
- if (!isGranted) {
- context.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ val removeOriginalOn = config.removeOriginalNotification == true
+
+ ListOptionCard(
+ title = stringResource(R.string.remove_original_notification),
+ subtitle = stringResource(R.string.remove_original_notification_desc),
+ icon = Icons.Default.Notifications,
+ shape = RoundedCornerShape(24.dp, 24.dp, 4.dp, 4.dp),
+ onClick = {
+ scope.launch {
+ prefs.updateGlobalConfig(config.copy(removeOriginalNotification = !(config.removeOriginalNotification ?: false)))
+ }
+ },
+ trailingContent = {
+ Checkbox(checked = config.removeOriginalNotification ?: false, onCheckedChange = null)
}
- },
- enabled = !isGranted,
- modifier = Modifier.fillMaxWidth().height(56.dp),
- shape = RoundedCornerShape(16.dp),
- colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.primary,
- )
- ) {
- Text(
- stringResource(if (isGranted) R.string.perm_granted else R.string.open_settings),
- style = MaterialTheme.typography.titleMedium
)
- }
+
+ AnimatedVisibility(
+ visible = removeOriginalOn,
+ enter = expandVertically(),
+ exit = shrinkVertically()
+ ) {
+ Text(
+ text = stringResource(R.string.remove_original_notification_hidden_warning),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
+ )
+ }
+
+ AnimatedVisibility(
+ visible = !removeOriginalOn,
+ enter = expandVertically(),
+ exit = shrinkVertically()
+ ) {
+ Column {
+ ListOptionCard(
+ title = stringResource(R.string.dismiss_with_original),
+ subtitle = stringResource(R.string.dismiss_with_original_desc),
+ icon = Icons.Default.DeleteSweep,
+ shape = RoundedCornerShape(4.dp),
+ onClick = {
+ scope.launch {
+ prefs.updateGlobalConfig(config.copy(dismissWithOriginal = !(config.dismissWithOriginal ?: false)))
+ }
+ },
+ trailingContent = {
+ Checkbox(
+ checked = config.dismissWithOriginal ?: false,
+ onCheckedChange = null
+ )
+ }
+ )
+
+ ListOptionCard(
+ title = stringResource(R.string.enable_inline_reply),
+ subtitle = stringResource(R.string.enable_inline_reply_desc),
+ icon = Icons.AutoMirrored.Filled.Reply,
+ shape = RoundedCornerShape(4.dp),
+ onClick = {
+ scope.launch {
+ prefs.updateGlobalConfig(config.copy(enableInlineReply = !(config.enableInlineReply ?: true)))
+ }
+ },
+ trailingContent = {
+ Checkbox(
+ checked = config.enableInlineReply ?: true,
+ onCheckedChange = null
+ )
+ }
+ )
+ }
+ }
+
+ ListOptionCard(
+ title = stringResource(R.string.config_shade_title),
+ subtitle = stringResource(R.string.config_shade_desc),
+ icon = Icons.Default.Info,
+ shape = RoundedCornerShape(4.dp, 4.dp, 24.dp, 24.dp),
+ onClick = {
+ scope.launch {
+ prefs.updateGlobalConfig(config.copy(isShowShade = !(config.isShowShade ?: false)))
+ }
+ },
+ trailingContent = {
+ Checkbox(checked = config.isShowShade ?: false, onCheckedChange = null)
+ }
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Text(
+ text = stringResource(R.string.onboarding_can_be_changed),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(horizontal = 16.dp)
+ )
+ }
+ }
+}
+
+@Composable
+fun AutoHideConfigPage(prefs: AppPreferences) {
+ val config by prefs.globalConfigFlow.collectAsState(initial = IslandConfig())
+ val scope = rememberCoroutineScope()
+ // Timeout is "Enabled" if it's > 0
+ val currentTimeout = config.timeout ?: 10
+ val isTimeoutEnabled = currentTimeout > 0
+
+ OnboardingPageLayout(
+ title = stringResource(R.string.island_behavior),
+ description = stringResource(R.string.behavior_desc_hide_long),
+ icon = Icons.Rounded.Timer,
+ iconColor = MaterialTheme.colorScheme.primary
+ ) {
+ Card(
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
+ shape = RoundedCornerShape(24.dp),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp)) {
+ // Header with Switch
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(Icons.Default.AccessTime, null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
+ Spacer(Modifier.width(20.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(stringResource(R.string.auto_hide_island), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Medium)
+ Text(
+ text = if (isTimeoutEnabled) stringResource(R.string.hides_after_a_set_time) else stringResource(
+ R.string.behavior_hide_desc
+ ),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Switch(
+ checked = isTimeoutEnabled,
+ onCheckedChange = { enabled ->
+ // When changing this, we also ensure isFloat is set to track the override
+ val newTimeout = if (enabled) 5 else 0
+ val currentIsFloat = config.isFloat ?: true
+ scope.launch { prefs.updateGlobalConfig(config.copy(timeout = newTimeout, isFloat = currentIsFloat)) }
+ }
+ )
+ }
+
+ // Expandable Slider Section
+ AnimatedVisibility(
+ visible = isTimeoutEnabled,
+ enter = expandVertically(),
+ exit = shrinkVertically()
+ ) {
+ Column {
+ Spacer(Modifier.height(16.dp))
+
+ // Time Display Label
+ Text(
+ text = formatSeconds(currentTimeout),
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.primary,
+ fontWeight = FontWeight.Bold
+ )
+
+ // Slider mapping to our steps list
+ val currentIndex = timeoutSteps.indexOf(currentTimeout).coerceAtLeast(0).toFloat()
+
+ Slider(
+ value = currentIndex,
+ onValueChange = { index ->
+ val selectedSeconds = timeoutSteps[index.toInt()]
+ val currentIsFloat = config.isFloat ?: true
+ scope.launch { prefs.updateGlobalConfig(config.copy(timeout = selectedSeconds, isFloat = currentIsFloat))}
+ },
+ valueRange = 0f..(timeoutSteps.size - 1).toFloat(),
+ steps = timeoutSteps.size - 2
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun CustomizationPage() {
+ OnboardingPageLayout(
+ title = stringResource(R.string.design),
+ description = stringResource(R.string.onboard_design_desc),
+ icon = Icons.Default.Palette,
+ iconColor = MaterialTheme.colorScheme.secondary
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ ListOptionCard(
+ title = stringResource(R.string.design_section_themes),
+ subtitle = stringResource(R.string.onboard_theme_desc),
+ icon = Icons.Default.Palette,
+ shape = RoundedCornerShape(24.dp, 24.dp, 4.dp, 4.dp),
+ onClick = {},
+ trailingContent = {}
+ )
+ ListOptionCard(
+ title = stringResource(R.string.design_section_widgets),
+ subtitle = stringResource(R.string.onboarding_widget_desc),
+ icon = Icons.Default.Architecture,
+ shape = RoundedCornerShape(4.dp, 4.dp, 24.dp, 24.dp),
+ onClick = {},
+ trailingContent = {}
+ )
+ }
+ }
+}
+
+// Shared Layout
+@Composable
+fun OnboardingPageLayout(
+ title: String,
+ description: String,
+ icon: ImageVector? = null,
+ iconColor: Color = MaterialTheme.colorScheme.primary,
+ content: @Composable ColumnScope.() -> Unit
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Spacer(modifier = Modifier.weight(0.5f))
+
+ // Expressive Icon Container
+ if (icon != null) {
+ Box(
+ modifier = Modifier
+ .size(120.dp)
+ .background(iconColor.copy(alpha = 0.1f), CircleShape),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ modifier = Modifier.size(56.dp),
+ tint = iconColor
+ )
+ }
+ Spacer(modifier = Modifier.height(40.dp))
+ }
+
+ Text(
+ text = title,
+ style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold),
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onBackground
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = description,
+ style = MaterialTheme.typography.bodyLarge,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ lineHeight = 24.sp
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ // Content Area
+ Box(
+ modifier = Modifier.fillMaxWidth(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
+ content()
+ }
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+ }
+}
+
+@Composable
+fun EngineConfigPage(prefs: AppPreferences) {
+ val useNative by prefs.useNativeLiveUpdates.collectAsState(initial = false)
+ val scope = rememberCoroutineScope()
+
+ OnboardingPageLayout(
+ title = stringResource(R.string.engine_config_title),
+ description = stringResource(R.string.engine_desc)
+ ) {
+ EnginePreview(isNative = useNative)
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ EngineOptionCard(
+ title = stringResource(R.string.engine_xiaomi_title),
+ description = stringResource(R.string.engine_xiaomi_desc),
+ isSelected = !useNative,
+ onClick = { scope.launch { prefs.setUseNativeLiveUpdates(false) } }
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ EngineOptionCard(
+ title = stringResource(R.string.engine_native_title),
+ description = stringResource(R.string.engine_native_desc),
+ isSelected = useNative,
+ onClick = { scope.launch { prefs.setUseNativeLiveUpdates(true) } }
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Text(
+ text = stringResource(R.string.onboarding_can_be_changed),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(horizontal = 16.dp)
+ )
+ }
+}
+
+@Composable
+fun PrivacyPage() {
+ OnboardingPageLayout(
+ title = stringResource(R.string.privacy_title),
+ description = stringResource(R.string.privacy_desc),
+ icon = Icons.Default.Security,
+ iconColor = MaterialTheme.colorScheme.primary
+ ) {
+ Card(
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
+ shape = RoundedCornerShape(24.dp),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier.padding(20.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(Icons.Default.WifiOff, null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
+ Spacer(modifier = Modifier.width(16.dp))
+ Column {
+ Text(stringResource(R.string.privacy_card_title), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
+ Text(stringResource(R.string.privacy_card_desc), style = MaterialTheme.typography.bodySmall)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun CompatibilityPage() {
+ val isXiaomi = DeviceUtils.isXiaomi
+ val isCompatibleOS = DeviceUtils.isCompatibleOS()
+ val isCN = DeviceUtils.isCNRom
+ val osVersion = DeviceUtils.getHyperOSVersion()
+ val deviceName = DeviceUtils.getDeviceMarketName()
+
+ val (icon, color, titleRes, descRes) = when {
+ !isXiaomi -> Quad(Icons.Rounded.Devices, MaterialTheme.colorScheme.error, R.string.unsupported_device, R.string.req_xiaomi)
+ !isCompatibleOS -> Quad(Icons.Rounded.Devices, MaterialTheme.colorScheme.error, R.string.unsupported_device, R.string.req_hyperos)
+ else -> Quad(Icons.Rounded.Devices, Color(0xFF34C759), R.string.device_compatible, R.string.compatible_msg)
+ }
+
+ OnboardingPageLayout(
+ title = stringResource(titleRes),
+ description = stringResource(descRes),
+ icon = icon,
+ iconColor = color
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ ListOptionCard(
+ title = Build.MANUFACTURER.uppercase(),
+ subtitle = deviceName,
+ icon = Icons.Default.Smartphone,
+ shape = RoundedCornerShape(24.dp, 24.dp, 4.dp, 4.dp),
+ onClick = {},
+ trailingContent = {}
+ )
+ ListOptionCard(
+ title = stringResource(R.string.system_version),
+ subtitle = osVersion,
+ icon = Icons.Default.Info,
+ shape = RoundedCornerShape(4.dp, 4.dp, 24.dp, 24.dp),
+ onClick = {},
+ trailingContent = {}
+ )
+
+ if (isCN && isXiaomi) {
+ Card(
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
+ shape = RoundedCornerShape(24.dp),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
+ Icon(Icons.Default.Warning, null, tint = MaterialTheme.colorScheme.error)
+ Spacer(Modifier.width(12.dp))
+ Text(
+ stringResource(R.string.warning_cn_rom_title),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onErrorContainer,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun ShizukuPage(prefs: AppPreferences) {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+
+ val isShizukuInstalled = remember { com.d4viddf.hyperbridge.util.ShizukuManager.isShizukuInstalled(context) }
+ val isShizukuRunning by com.d4viddf.hyperbridge.util.ShizukuManager.isShizukuRunning.collectAsState()
+ val isPermissionGranted by com.d4viddf.hyperbridge.util.ShizukuManager.isPermissionGranted.collectAsState()
+ val isWorkaroundEnabled by prefs.isShizukuWorkaroundEnabled.collectAsState(initial = false)
+
+ OnboardingPageLayout(
+ title = stringResource(R.string.shizuku_workaround_title),
+ description = stringResource(R.string.shizuku_workaround_desc),
+ icon = Icons.Default.Construction,
+ iconColor = MaterialTheme.colorScheme.primary
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = stringResource(R.string.shizuku_enable_workaround),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+ Switch(
+ checked = isWorkaroundEnabled,
+ onCheckedChange = { scope.launch { prefs.setShizukuWorkaroundEnabled(it) } },
+ enabled = isShizukuInstalled
+ )
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ if (!isShizukuInstalled) {
+ Card(
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
+ shape = RoundedCornerShape(16.dp),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
+ Icon(Icons.Default.Warning, null, tint = MaterialTheme.colorScheme.error)
+ Spacer(Modifier.width(12.dp))
+ Text(
+ stringResource(R.string.shizuku_not_installed),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onErrorContainer,
+ )
+ }
+ }
+ } else if (isWorkaroundEnabled) {
+ ListOptionCard(
+ title = stringResource(if (isPermissionGranted) R.string.shizuku_permission_granted else R.string.shizuku_status_running),
+ subtitle = stringResource(if (isPermissionGranted) R.string.shizuku_status_running else R.string.shizuku_permission_denied),
+ icon = if (isPermissionGranted) Icons.Default.Security else Icons.Default.Warning,
+ shape = RoundedCornerShape(24.dp),
+ onClick = {},
+ trailingContent = {}
+ )
+ }
+
+ if (isShizukuInstalled && isWorkaroundEnabled) {
+ Spacer(modifier = Modifier.height(24.dp))
+ Button(
+ onClick = {
+ if (!isPermissionGranted) {
+ com.d4viddf.hyperbridge.util.ShizukuManager.requestPermission(context, 1)
+ }
+ },
+ enabled = !isPermissionGranted,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(56.dp),
+ shape = RoundedCornerShape(16.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primary,
+ )
+ ) {
+ Text(
+ stringResource(if (isPermissionGranted) R.string.perm_granted else R.string.shizuku_request_permission),
+ style = MaterialTheme.typography.titleMedium
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun ListOptionCard(
+ title: String,
+ subtitle: String,
+ icon: ImageVector,
+ shape: Shape,
+ onClick: () -> Unit,
+ trailingContent: (@Composable () -> Unit)? = null
+) {
+ Card(
+ onClick = onClick,
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
+ shape = shape,
+ modifier = Modifier
+ .fillMaxWidth()
+ .defaultMinSize(minHeight = 88.dp)
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 20.dp, vertical = 16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(icon, null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
+ Spacer(Modifier.width(20.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Medium)
+ Text(subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ if (trailingContent != null) {
+ trailingContent()
+ }
+ }
+ }
+}
+
+// Helper
+data class Quad(val first: A, val second: B, val third: C, val fourth: D)
+
+// --- 5. POST PERMISSION PAGE ---
+@Composable
+fun PostPermissionPage(isGranted: Boolean, onRequest: () -> Unit) {
+ OnboardingPageLayout(
+ title = stringResource(R.string.show_island),
+ description = stringResource(R.string.perm_post_desc),
+ icon = Icons.Rounded.Notifications,
+ iconColor = MaterialTheme.colorScheme.primary
+ ) {
+ // FIX: Standard Button, Disabled when granted, Text changes, No Icon
+ Button(
+ onClick = { if (!isGranted) onRequest() },
+ enabled = !isGranted,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(56.dp),
+ shape = RoundedCornerShape(16.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primary,
+ )
+ ) {
+ Text(
+ stringResource(if (isGranted) R.string.perm_granted else R.string.allow_notifications),
+ style = MaterialTheme.typography.titleMedium
+ )
+ }
+ }
+}
+
+// --- 6. LISTENER PERMISSION PAGE ---
+@Composable
+fun ListenerPermissionPage(context: Context, isGranted: Boolean) {
+ OnboardingPageLayout(
+ title = stringResource(R.string.read_data),
+ description = stringResource(R.string.perm_listener_desc),
+ icon = Icons.Default.NotificationsActive,
+ iconColor = MaterialTheme.colorScheme.secondary
+ ) {
+ // FIX: Standard Button, Disabled when granted, Text changes, No Icon
+ Button(
+ onClick = {
+ if (!isGranted) {
+ context.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
+ }
+ },
+ enabled = !isGranted,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(56.dp),
+ shape = RoundedCornerShape(16.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primary,
+ )
+ ) {
+ Text(
+ stringResource(if (isGranted) R.string.perm_granted else R.string.open_settings),
+ style = MaterialTheme.typography.titleMedium
+ )
+ }
+ }
+}
+
+@Composable
+fun FeaturedNotificationCheckPage(context: Context) {
+ val isSupported = remember { com.d4viddf.hyperbridge.util.XiaomiNotificationHelper.isSupportIsland() }
+
+ // We want to re-check when returning to this page, so we use a LaunchedEffect
+ var isGranted by remember { mutableStateOf(false) }
+
+ androidx.compose.runtime.LaunchedEffect(Unit) {
+ while (true) {
+ isGranted = com.d4viddf.hyperbridge.util.XiaomiNotificationHelper.hasFocusPermission(context)
+ kotlinx.coroutines.delay(1000.milliseconds)
+ }
+ }
+
+ OnboardingPageLayout(
+ title = stringResource(R.string.featured_notifications_check),
+ description = stringResource(R.string.featured_notifications_check_desc),
+ icon = Icons.Default.Info,
+ iconColor = MaterialTheme.colorScheme.tertiary
+ ) {
+ if (!isSupported) {
+ Text(
+ stringResource(R.string.featured_notifications_not_supported),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ style = MaterialTheme.typography.titleMedium
+ )
+ } else {
+ Button(
+ onClick = {
+ if (!isGranted) {
+ val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
+ putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
+ }
+ context.startActivity(intent)
+ }
+ },
+ enabled = !isGranted,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(56.dp),
+ shape = RoundedCornerShape(16.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primary,
+ )
+ ) {
+ Text(
+ stringResource(if (isGranted) R.string.featured_notifications_enabled else R.string.featured_notifications_open_settings),
+ style = MaterialTheme.typography.titleMedium
+ )
+ }
+
+ if (!isGranted) {
+ Spacer(modifier = Modifier.height(16.dp))
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
+ shape = RoundedCornerShape(12.dp)
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(Icons.Default.Architecture, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(stringResource(R.string.featured_notifications_shizuku_alternative), style = MaterialTheme.typography.titleSmall)
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(stringResource(R.string.featured_notifications_shizuku_desc), style = MaterialTheme.typography.bodySmall)
+ }
+ }
+ }
+ }
}
}
// --- 7. OPTIMIZATION PAGE ---
+@SuppressLint("BatteryLife")
@Composable
fun OptimizationPage(context: Context) {
OnboardingPageLayout(
title = stringResource(R.string.optimization_title),
description = stringResource(R.string.optimization_desc),
icon = Icons.Default.BatteryStd,
- iconColor = Color(0xFFFF9800)
+ iconColor = MaterialTheme.colorScheme.secondary
) {
- OutlinedButton(
+ Button(
onClick = {
try {
val intent = Intent()
@@ -550,9 +1382,11 @@ fun OptimizationPage(context: Context) {
"com.miui.permcenter.autostart.AutoStartManagementActivity"
)
context.startActivity(intent)
- } catch (e: Exception) { }
+ } catch (_: Exception) { }
},
- modifier = Modifier.fillMaxWidth().height(56.dp),
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(56.dp),
shape = RoundedCornerShape(16.dp)
) {
Text(stringResource(R.string.enable_autostart), style = MaterialTheme.typography.bodyLarge)
@@ -560,19 +1394,247 @@ fun OptimizationPage(context: Context) {
Spacer(modifier = Modifier.height(12.dp))
- OutlinedButton(
+ Button(
onClick = {
try {
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = "package:${context.packageName}".toUri()
}
context.startActivity(intent)
- } catch (e: Exception) { }
+ } catch (_: Exception) { }
},
- modifier = Modifier.fillMaxWidth().height(56.dp),
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(56.dp),
shape = RoundedCornerShape(16.dp)
) {
- Text(stringResource(R.string.no_restrictions), style = MaterialTheme.typography.bodyLarge)
+ Text(stringResource(R.string.set_battery_no_restrictions), style = MaterialTheme.typography.bodyLarge)
+ }
+ }
+}
+
+@Preview(showBackground = true, apiLevel = 36)
+@Composable
+fun OnboardingScreenPreview() {
+ HyperBridgeTheme {
+ OnboardingScreen(onFinish = {})
+ }
+}
+
+@Preview(showBackground = true, apiLevel = 36)
+@Composable
+fun WelcomePagePreview() {
+ HyperBridgeTheme {
+ WelcomePage()
+ }
+}
+
+@Preview(showBackground = true, apiLevel = 36)
+@Composable
+fun ExplanationPagePreview() {
+ HyperBridgeTheme {
+ ExplanationPage()
+ }
+}
+
+@Preview(showBackground = true, apiLevel = 36)
+@Composable
+fun PrivacyPagePreview() {
+ HyperBridgeTheme {
+ PrivacyPage()
+ }
+}
+
+@Preview(showBackground = true, apiLevel = 36)
+@Composable
+fun CompatibilityPagePreview() {
+ HyperBridgeTheme {
+ CompatibilityPage()
+ }
+}
+
+@Preview(showBackground = true, apiLevel = 36)
+@Composable
+fun PostPermissionPagePreview() {
+ HyperBridgeTheme {
+ PostPermissionPage(isGranted = false, onRequest = {})
+ }
+}
+
+@Preview(showBackground = true, apiLevel = 36)
+@Composable
+fun ListenerPermissionPagePreview() {
+ HyperBridgeTheme {
+ ListenerPermissionPage(context = LocalContext.current, isGranted = false)
+ }
+}
+
+@Preview(showBackground = true, apiLevel = 36)
+@Composable
+fun TriggersConfigPagePreview() {
+ HyperBridgeTheme {
+ TriggersConfigPage(prefs = AppPreferences(LocalContext.current))
+ }
+}
+
+@Preview(showBackground = true, apiLevel = 36)
+@Composable
+fun PriorityEducationPagePreview() {
+ HyperBridgeTheme {
+ PriorityEducationPage(prefs = AppPreferences(LocalContext.current))
+ }
+}
+
+@Preview(showBackground = true, apiLevel = 36)
+@Composable
+fun BehaviorConfigPagePreview() {
+ HyperBridgeTheme {
+ BehaviorConfigPage(prefs = AppPreferences(LocalContext.current))
+ }
+}
+
+@Preview(showBackground = true, apiLevel = 36)
+@Composable
+fun CustomizationPagePreview() {
+ HyperBridgeTheme {
+ CustomizationPage()
+ }
+}
+
+@Preview(showBackground = true, apiLevel = 36)
+@Composable
+fun EngineConfigPagePreview() {
+ HyperBridgeTheme {
+ EngineConfigPage(prefs = AppPreferences(LocalContext.current))
+ }
+}
+
+@Preview(showBackground = true, apiLevel = 36)
+@Composable
+fun OptimizationPagePreview() {
+ HyperBridgeTheme {
+ OptimizationPage(context = LocalContext.current)
+ }
+}
+
+@Preview(showBackground = true, apiLevel = 36)
+@Composable
+fun AutoHidePagePreview(){
+ HyperBridgeTheme {
+ AutoHideConfigPage(prefs = AppPreferences(LocalContext.current))
+ }
+}
+
+@Composable
+fun PermanentIslandConfigPage(prefs: AppPreferences) {
+ val isEnabled by prefs.isPermanentIslandEnabledFlow.collectAsState(initial = false)
+ val islandWidth by prefs.permanentIslandWidthFlow.collectAsState(initial = 0)
+
+ var sliderValue by androidx.compose.runtime.remember { androidx.compose.runtime.mutableFloatStateOf(0f) }
+ var isDragging by androidx.compose.runtime.remember { mutableStateOf(false) }
+
+ androidx.compose.runtime.LaunchedEffect(islandWidth) {
+ if (!isDragging) {
+ sliderValue = islandWidth.toFloat()
+ }
+ }
+
+ val scope = rememberCoroutineScope()
+
+ OnboardingPageLayout(
+ title = stringResource(R.string.permanent_island_title),
+ description = stringResource(R.string.permanent_island_desc),
+ iconColor = MaterialTheme.colorScheme.primary
+ ) {
+ PermanentIslandPreview(islandWidthValue = if (isDragging) sliderValue.toInt() else islandWidth)
+
+ Spacer(Modifier.height(24.dp))
+
+ Card(
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
+ shape = RoundedCornerShape(24.dp),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+
+ Column(modifier = Modifier.weight(1f)) {
+ Text(stringResource(R.string.permanent_island_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Medium)
+ }
+ Switch(
+ checked = isEnabled,
+ onCheckedChange = { checked ->
+ scope.launch { prefs.setPermanentIslandEnabled(checked) }
+ }
+ )
+ }
+
+ if (isEnabled) {
+ Spacer(Modifier.height(16.dp))
+ Text(
+ text = stringResource(R.string.permanent_island_width),
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Medium
+ )
+ androidx.compose.material3.Slider(
+ value = sliderValue,
+ onValueChange = { value ->
+ isDragging = true
+ sliderValue = value
+ scope.launch {
+ prefs.setPermanentIslandWidth(value.toInt())
+ }
+ },
+ onValueChangeFinished = {
+ isDragging = false
+ },
+ valueRange = 0f..20f
+ )
+ }
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true, apiLevel = 36)
+@Composable
+fun PermanentIslandConfigPagePreview() {
+ HyperBridgeTheme {
+ PermanentIslandConfigPage(prefs = AppPreferences(LocalContext.current))
+ }
+}
+
+// --- 8. OVERLAY PERMISSION PAGE ---
+@Composable
+fun OverlayPermissionPage(context: Context, isGranted: Boolean) {
+ OnboardingPageLayout(
+ title = stringResource(R.string.perm_display_title),
+ description = stringResource(R.string.perm_display_onboard_desc),
+ icon = Icons.Default.Layers,
+ iconColor = MaterialTheme.colorScheme.tertiary
+ ) {
+ Button(
+ onClick = {
+ if (!isGranted) {
+ val intent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION).apply {
+ data = "package:${context.packageName}".toUri()
+ }
+ context.startActivity(intent)
+ }
+ },
+ enabled = !isGranted,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(56.dp),
+ shape = RoundedCornerShape(16.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primary,
+ )
+ ) {
+ Text(
+ stringResource(if (isGranted) R.string.perm_granted else R.string.open_settings),
+ style = MaterialTheme.typography.titleMedium
+ )
}
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/AppPriorityScreen.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/AppPriorityScreen.kt
index 9e6df90..c992b14 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/AppPriorityScreen.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/AppPriorityScreen.kt
@@ -123,7 +123,7 @@ fun AppPriorityScreen(onBack: () -> Unit) {
val appInfo = packageManager.getApplicationInfo(pkg, 0)
val name = packageManager.getApplicationLabel(appInfo).toString()
PriorityAppInfo(name, pkg)
- } catch (e: Exception) {
+ } catch (_: Exception) {
PriorityAppInfo(pkg, pkg) // Fallback
}
}
@@ -306,7 +306,7 @@ fun DraggableLazyList(
val isDragging = index == draggingIndex
- // Visual Feedback: Lift up and scale
+ // Visual Feedback: Lift and scale
val elevation by animateDpAsState(if (isDragging) 12.dp else 0.dp, label = "elevation")
val scale by animateFloatAsState(if (isDragging) 1.05f else 1f, label = "scale")
val alpha = if (isDragging) 0.9f else 1f
@@ -443,7 +443,7 @@ fun AppIconLoader(packageName: String, contentDescription: String) {
try {
val drawable = context.packageManager.getApplicationIcon(packageName)
iconBitmap = drawable.toBitmap()
- } catch (e: Exception) { /* Ignore */ }
+ } catch (_: Exception) { /* Ignore */ }
}
}
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/ChangelogHistoryScreen.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/ChangelogHistoryScreen.kt
index 27ca195..442168a 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/ChangelogHistoryScreen.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/ChangelogHistoryScreen.kt
@@ -31,7 +31,11 @@ data class VersionLog(val version: String, val titleRes: Int, val textRes: Int,
fun ChangelogHistoryScreen(onBack: () -> Unit) {
// Define history here (Newest first)
val history = listOf(
- VersionLog("0.4.2", R.string.title_0_4_2, R.string.changelog_0_4_2, isLatest = true),
+ VersionLog("0.5.5", R.string.title_0_5_5, R.string.changelog_0_5_5, isLatest = true),
+ VersionLog("0.5.2", R.string.title_0_5_2, R.string.changelog_0_5_2),
+ VersionLog("0.5.1", R.string.title_0_5_1, R.string.changelog_0_5_1),
+ VersionLog("0.5.0", R.string.title_0_5_0, R.string.changelog_0_5_0),
+ VersionLog("0.4.2", R.string.title_0_4_2, R.string.changelog_0_4_2),
VersionLog("0.4.0", R.string.title_0_4_0, R.string.changelog_0_4_0),
VersionLog("0.3.1", R.string.title_0_3_1, R.string.changelog_0_3_1),
VersionLog("0.3.0", R.string.title_0_3_0, R.string.changelog_0_3_0),
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/DndSettingsScreen.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/DndSettingsScreen.kt
new file mode 100644
index 0000000..eb437e8
--- /dev/null
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/DndSettingsScreen.kt
@@ -0,0 +1,103 @@
+package com.d4viddf.hyperbridge.ui.screens.settings
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.outlined.DoNotDisturbOn
+import androidx.compose.material.icons.outlined.NotificationsPaused
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilledTonalIconButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.d4viddf.hyperbridge.R
+import com.d4viddf.hyperbridge.data.AppPreferences
+import com.d4viddf.hyperbridge.ui.components.ListOptionCard
+import kotlinx.coroutines.launch
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun DndSettingsScreen(onBack: () -> Unit) {
+ val context = LocalContext.current
+ val prefs = remember { AppPreferences(context) }
+ val isDndModeEnabled by prefs.isDndModeEnabledFlow.collectAsState(initial = false)
+ val autoDetectDnd by prefs.autoDetectDndFlow.collectAsState(initial = false)
+ val scope = rememberCoroutineScope()
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.dnd_mode_title)) },
+ navigationIcon = {
+ FilledTonalIconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back))
+ }
+ }
+ )
+ }
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .padding(padding)
+ .padding(16.dp)
+ .verticalScroll(rememberScrollState())
+ ) {
+ Text(
+ text = stringResource(R.string.dnd_mode_desc),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(bottom = 16.dp, start = 8.dp, end = 8.dp)
+ )
+
+ ListOptionCard(
+ title = stringResource(R.string.dnd_auto_detect),
+ subtitle = stringResource(R.string.dnd_auto_detect_desc),
+ icon = Icons.Outlined.DoNotDisturbOn,
+ shape = RoundedCornerShape(24.dp, 24.dp, 4.dp, 4.dp),
+ onClick = {
+ scope.launch { prefs.setAutoDetectDnd(!autoDetectDnd) }
+ },
+ trailingContent = {
+ Switch(checked = autoDetectDnd, onCheckedChange = {
+ scope.launch { prefs.setAutoDetectDnd(it) }
+ })
+ }
+ )
+
+ Spacer(Modifier.height(2.dp))
+
+ ListOptionCard(
+ title = stringResource(R.string.dnd_manual_toggle),
+ subtitle = stringResource(R.string.dnd_manual_toggle_desc),
+ icon = Icons.Outlined.NotificationsPaused,
+ shape = RoundedCornerShape(4.dp, 4.dp, 24.dp, 24.dp),
+ onClick = {
+ scope.launch { prefs.setDndModeEnabled(!isDndModeEnabled) }
+ },
+ trailingContent = {
+ Switch(checked = isDndModeEnabled, onCheckedChange = {
+ scope.launch { prefs.setDndModeEnabled(it) }
+ })
+ }
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/EngineSettingsScreen.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/EngineSettingsScreen.kt
new file mode 100644
index 0000000..836dac2
--- /dev/null
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/EngineSettingsScreen.kt
@@ -0,0 +1,219 @@
+package com.d4viddf.hyperbridge.ui.screens.settings
+
+import android.content.Intent
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilledTonalIconButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.filled.Security
+import androidx.compose.material.icons.filled.Warning
+import com.d4viddf.hyperbridge.R
+import com.d4viddf.hyperbridge.data.AppPreferences
+import com.d4viddf.hyperbridge.data.theme.ThemeRepository
+import com.d4viddf.hyperbridge.service.NotificationReaderService
+import com.d4viddf.hyperbridge.ui.components.EngineOptionCard
+import com.d4viddf.hyperbridge.ui.components.EnginePreview
+import com.d4viddf.hyperbridge.ui.screens.onboarding.ListOptionCard
+import kotlinx.coroutines.launch
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun EngineSettingsScreen(onBack: () -> Unit) {
+ val context = LocalContext.current
+ val prefs = remember { AppPreferences(context) }
+ val repo = remember { ThemeRepository(context) }
+ val scope = rememberCoroutineScope()
+
+ // 1. Observe both the Global Preference and the Active Theme
+ val defaultEnginePref by prefs.useNativeLiveUpdates.collectAsState(initial = false)
+ val activeTheme by repo.activeTheme.collectAsState(initial = null)
+
+ // 2. Calculate which engine is ACTUALLY being used right now
+ val isCustomTheme = activeTheme != null && activeTheme!!.id.isNotEmpty()
+ val isNative = if (isCustomTheme) {
+ // [FIX] If the theme inherits (null), fall back to the global preference!
+ activeTheme!!.global.useNativeLiveUpdates ?: defaultEnginePref
+ } else {
+ defaultEnginePref
+ }
+
+ // 3. Handle Engine Changes intelligently based on active state
+ val onEngineChange = { useNative: Boolean ->
+ scope.launch {
+ if (isCustomTheme) {
+ // Patch the active Custom Theme so the setting actually sticks!
+ val currentTheme = activeTheme!!
+ val updatedGlobal = currentTheme.global.copy(useNativeLiveUpdates = useNative)
+ val updatedTheme = currentTheme.copy(global = updatedGlobal)
+ repo.saveTheme(updatedTheme)
+ repo.activateTheme(updatedTheme.id)
+ } else {
+ // Save to standard AppPreferences
+ prefs.setUseNativeLiveUpdates(useNative)
+ }
+
+ // Immediately tell the background service to reload the engine config
+ val intent = Intent(context, NotificationReaderService::class.java).apply {
+ action = NotificationReaderService.ACTION_RELOAD_THEME
+ }
+ context.startService(intent)
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.engine), fontWeight = FontWeight.Bold) },
+ navigationIcon = {
+ FilledTonalIconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
+ }
+ }
+ )
+ }
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ .padding(horizontal = 16.dp)
+ .verticalScroll(rememberScrollState())
+ ) {
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = stringResource(R.string.engine_preview_title),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+
+ EnginePreview(isNative = isNative)
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ Text(
+ text = stringResource(R.string.engine_section_design),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+
+ EngineOptionCard(
+ title = stringResource(R.string.engine_xiaomi_title),
+ description = stringResource(R.string.engine_xiaomi_desc),
+ isSelected = !isNative,
+ onClick = { onEngineChange(false) }
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ EngineOptionCard(
+ title = stringResource(R.string.engine_native_title),
+ description = stringResource(R.string.engine_native_desc),
+ isSelected = isNative,
+ onClick = { onEngineChange(true) }
+ )
+
+ if (!isNative) {
+ Spacer(modifier = Modifier.height(32.dp))
+
+ val isShizukuInstalled = remember { com.d4viddf.hyperbridge.util.ShizukuManager.isShizukuInstalled(context) }
+ val isShizukuRunning by com.d4viddf.hyperbridge.util.ShizukuManager.isShizukuRunning.collectAsState()
+ val isPermissionGranted by com.d4viddf.hyperbridge.util.ShizukuManager.isPermissionGranted.collectAsState()
+ val isWorkaroundEnabled by prefs.isShizukuWorkaroundEnabled.collectAsState(initial = false)
+
+ androidx.compose.foundation.layout.Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
+ horizontalArrangement = androidx.compose.foundation.layout.Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = stringResource(R.string.shizuku_workaround_title),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+ androidx.compose.material3.Switch(
+ checked = isWorkaroundEnabled,
+ onCheckedChange = { scope.launch { prefs.setShizukuWorkaroundEnabled(it) } },
+ enabled = isShizukuInstalled
+ )
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = stringResource(R.string.shizuku_workaround_desc),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+
+ if (isShizukuInstalled && isWorkaroundEnabled) {
+ ListOptionCard(
+ title = stringResource(if (isPermissionGranted) R.string.shizuku_permission_granted else R.string.shizuku_status_running),
+ subtitle = stringResource(if (isPermissionGranted) R.string.shizuku_status_running else R.string.shizuku_permission_denied),
+ icon = if (isPermissionGranted) Icons.Default.Security else Icons.Default.Warning,
+ shape = RoundedCornerShape(16.dp),
+ onClick = {},
+ trailingContent = {}
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ androidx.compose.material3.Button(
+ onClick = {
+ if (!isPermissionGranted) {
+ com.d4viddf.hyperbridge.util.ShizukuManager.requestPermission(context, 1)
+ }
+ },
+ enabled = !isPermissionGranted,
+ modifier = Modifier.fillMaxWidth().height(56.dp),
+ shape = androidx.compose.foundation.shape.RoundedCornerShape(16.dp),
+ colors = androidx.compose.material3.ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primary,
+ )
+ ) {
+ Text(
+ stringResource(if (isPermissionGranted) R.string.perm_granted else R.string.shizuku_request_permission),
+ style = MaterialTheme.typography.titleMedium
+ )
+ }
+ }
+
+ if (!isShizukuInstalled) {
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = stringResource(R.string.shizuku_not_installed),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/GlobalSettingsScreen.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/GlobalSettingsScreen.kt
index dcb7994..d018b89 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/GlobalSettingsScreen.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/GlobalSettingsScreen.kt
@@ -1,41 +1,58 @@
package com.d4viddf.hyperbridge.ui.screens.settings
-import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.*
+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.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
-import androidx.compose.material.icons.automirrored.rounded.ArrowForwardIos
-import androidx.compose.material.icons.filled.Navigation
-import androidx.compose.material.icons.filled.Notifications
-import androidx.compose.material3.*
-import androidx.compose.runtime.*
+import androidx.compose.material.icons.outlined.DisplaySettings
+import androidx.compose.material.icons.outlined.DoNotDisturbOn
+import androidx.compose.material.icons.outlined.Edit
+import androidx.compose.material.icons.outlined.Memory
+import androidx.compose.material.icons.outlined.Navigation
+import androidx.compose.material.icons.outlined.PushPin
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilledTonalIconButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.d4viddf.hyperbridge.R
-import com.d4viddf.hyperbridge.data.AppPreferences
-import com.d4viddf.hyperbridge.models.IslandConfig
-import com.d4viddf.hyperbridge.ui.components.IslandSettingsControl
-import kotlinx.coroutines.launch
+import com.d4viddf.hyperbridge.ui.components.ListOptionCard
+import com.d4viddf.hyperbridge.ui.screens.theme.ShapeStyle
+import com.d4viddf.hyperbridge.ui.screens.theme.getExpressiveShape
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GlobalSettingsScreen(
onBack: () -> Unit,
- onNavSettingsClick: () -> Unit // New Callback
+ onNavSettingsClick: () -> Unit,
+ onInlineReplyClick: () -> Unit,
+ onIslandSettingsClick: () -> Unit,
+ onEngineSettingsClick: () -> Unit,
+ onDndSettingsClick: () -> Unit,
+ onPermanentIslandClick: () -> Unit
) {
- val context = LocalContext.current
- val scope = rememberCoroutineScope()
- val preferences = remember { AppPreferences(context) }
-
- val globalConfig by preferences.globalConfigFlow.collectAsState(initial = IslandConfig(true, true, 5))
Scaffold(
topBar = {
@@ -49,107 +66,63 @@ fun GlobalSettingsScreen(
)
}
) { padding ->
- Column(modifier = Modifier.padding(padding).padding(16.dp)) {
- // Island Settings Card
- Card(
- colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
- ) {
- Column(Modifier.padding(16.dp)) {
- IslandSettingsControl(
- config = globalConfig,
- onUpdate = { newConfig ->
- scope.launch { preferences.updateGlobalConfig(newConfig) }
- }
- )
- }
- }
- Spacer(modifier = Modifier.height(16.dp))
-
- // Navigation Layout Card
- Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)) {
- SettingsItem(
- icon = Icons.Default.Navigation,
- title = stringResource(R.string.nav_layout_title),
- subtitle = stringResource(R.string.nav_layout_desc),
- onClick = onNavSettingsClick
- )
- }
- Spacer(modifier = Modifier.height(16.dp))
-
- var useNativeLiveUpdates by remember { mutableStateOf(false) }
-
- LaunchedEffect(Unit) {
- val prefs = context.getSharedPreferences("hyperbridge_settings", Context.MODE_PRIVATE)
- useNativeLiveUpdates = prefs.getBoolean("use_native_live_updates", false)
- }
-
- // Native Live Updates Card
- Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)) {
- SettingsSwitchItem(
- icon = Icons.Default.Notifications,
- title = stringResource(R.string.settings_live_updates_title),
- subtitle = stringResource(R.string.settings_live_updates_desc),
- checked = useNativeLiveUpdates,
- onCheckedChange = { isChecked ->
- useNativeLiveUpdates = isChecked
- context.getSharedPreferences("hyperbridge_settings", Context.MODE_PRIVATE)
- .edit().putBoolean("use_native_live_updates", isChecked).apply()
- }
- )
- }
- }
- }
-}
-
-@Composable
-fun SettingsItem(
- icon: ImageVector,
- title: String,
- subtitle: String,
- onClick: () -> Unit
-) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .clickable { onClick() }
- .padding(horizontal = 20.dp, vertical = 16.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Box(
- modifier = Modifier
- .size(40.dp)
- .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), CircleShape),
- contentAlignment = Alignment.Center
+ Column(modifier = Modifier
+ .padding(padding)
+ .padding(16.dp)
+ .verticalScroll(rememberScrollState())
) {
- Icon(
- imageVector = icon,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.primary,
- modifier = Modifier.size(24.dp)
+ // Island Settings Card
+ ListOptionCard(
+ title = stringResource(R.string.engine),
+ subtitle = stringResource(R.string.engine_desc),
+ icon = Icons.Outlined.Memory,
+ shape = getExpressiveShape(6, 0, ShapeStyle.Large),
+ onClick = onEngineSettingsClick
)
- }
- Spacer(modifier = Modifier.width(16.dp))
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = title,
- style = MaterialTheme.typography.bodyLarge,
- fontWeight = FontWeight.SemiBold,
- color = MaterialTheme.colorScheme.onSurface
+ Spacer(Modifier.height(2.dp))
+ ListOptionCard(
+ title = stringResource(R.string.island_behavior_title),
+ subtitle = stringResource(R.string.island_behavior_desc),
+ icon = Icons.Outlined.DisplaySettings,
+ shape = getExpressiveShape(6, 1, ShapeStyle.Large),
+ onClick = onIslandSettingsClick
)
- Spacer(modifier = Modifier.height(2.dp))
- Text(
- text = subtitle,
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant
+ Spacer(Modifier.height(2.dp))
+ ListOptionCard(
+ title = stringResource(R.string.dnd_mode_title),
+ subtitle = stringResource(R.string.dnd_mode_desc),
+ icon = Icons.Outlined.DoNotDisturbOn,
+ shape = getExpressiveShape(6, 2, ShapeStyle.Large),
+ onClick = onDndSettingsClick
+ )
+ Spacer(Modifier.height(2.dp))
+ ListOptionCard(
+ title = stringResource(R.string.nav_layout_title),
+ subtitle = stringResource(R.string.nav_layout_desc),
+ icon = Icons.Outlined.Navigation,
+ shape = getExpressiveShape(6, 3, ShapeStyle.Large),
+ onClick = onNavSettingsClick
+ )
+ Spacer(Modifier.height(2.dp))
+ ListOptionCard(
+ title = stringResource(R.string.inline_reply_title),
+ subtitle = stringResource(R.string.customize_inline_reply),
+ icon = Icons.Outlined.Edit,
+ shape = getExpressiveShape(6, 4, ShapeStyle.Large),
+ onClick = onInlineReplyClick
)
+ Spacer(Modifier.height(2.dp))
+ ListOptionCard(
+ title = stringResource(R.string.permanent_island_title),
+ subtitle = stringResource(R.string.permanent_island_desc),
+ icon = Icons.Outlined.PushPin,
+ shape = getExpressiveShape(6, 5, ShapeStyle.Large),
+ onClick = onPermanentIslandClick
+ )
+ Spacer(Modifier.height(16.dp))
+
+
}
- // Use AutoMirrored icon for RTL support
- Icon(
- imageVector = Icons.AutoMirrored.Rounded.ArrowForwardIos,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
- modifier = Modifier.size(16.dp)
- )
}
}
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/InfoScreen.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/InfoScreen.kt
index a0f5151..d51e543 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/InfoScreen.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/InfoScreen.kt
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -244,7 +245,7 @@ fun SettingsOptionCard(item: SettingsItemData, shape: Shape) {
onClick = item.onClick,
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
shape = shape,
- modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp)
+ modifier = Modifier.fillMaxWidth().defaultMinSize(minHeight = 72.dp)
) {
Row(
modifier = Modifier
@@ -292,6 +293,7 @@ fun LanguageSelectorDialog(onDismiss: () -> Unit) {
"Deutsch" to "de",
"English" to "en",
"Español" to "es",
+ "Français" to "fr", // Added (French)
"Italiano" to "it", // Added (Italian)
"Magyar" to "hu", // Added (Hungarian)
"Português (BR)" to "pt-BR",
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/IslandSettingsScreen.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/IslandSettingsScreen.kt
new file mode 100644
index 0000000..e713461
--- /dev/null
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/IslandSettingsScreen.kt
@@ -0,0 +1,99 @@
+package com.d4viddf.hyperbridge.ui.screens.settings
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilledTonalIconButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.d4viddf.hyperbridge.R
+import com.d4viddf.hyperbridge.data.AppPreferences
+import com.d4viddf.hyperbridge.models.IslandConfig
+import com.d4viddf.hyperbridge.ui.components.IslandSettingsControl
+import com.d4viddf.hyperbridge.ui.theme.HyperBridgeTheme
+import kotlinx.coroutines.launch
+
+@Composable
+fun IslandSettingsScreen(
+ onBack: () -> Unit,
+) {
+
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ val preferences = remember { AppPreferences(context) }
+
+ val globalConfig by preferences.globalConfigFlow.collectAsState(initial = IslandConfig(
+ isFloat = false,
+ isShowShade = false,
+ timeout = 10
+ ))
+
+ IslandSettingsContent(
+ globalConfig = globalConfig,
+ onBack = onBack,
+ onUpdateConfig = { newConfig ->
+ scope.launch { preferences.updateGlobalConfig(newConfig) }
+ }
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun IslandSettingsContent(
+ globalConfig: IslandConfig,
+ onBack: () -> Unit,
+ onUpdateConfig: (IslandConfig) -> Unit
+) {
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.global_settings)) },
+ navigationIcon = {
+ FilledTonalIconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back))
+ }
+ }
+ )
+ }
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .padding(padding)
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp)
+ ) {
+ IslandSettingsControl(
+ config = globalConfig,
+ onUpdate = onUpdateConfig
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun IslandSettingsScreenPreview() {
+ HyperBridgeTheme {
+ IslandSettingsContent(
+ globalConfig = IslandConfig(isFloat = true, isShowShade = true, timeout = 5, floatTimeout = 6),
+ onBack = {},
+ onUpdateConfig = {}
+ )
+ }
+}
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/LicensesScreen.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/LicensesScreen.kt
index 6557ca0..5083692 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/LicensesScreen.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/LicensesScreen.kt
@@ -19,7 +19,9 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
+import com.d4viddf.hyperbridge.R
data class Library(val name: String, val author: String, val license: String, val url: String)
@@ -33,13 +35,23 @@ fun LicensesScreen(onBack: () -> Unit) {
Library("Jetpack Compose", "Google", "Apache 2.0", "https://developer.android.com/jetpack/compose"),
Library("Material 3", "Google", "Apache 2.0", "https://m3.material.io/"),
Library("AndroidX Core", "Google", "Apache 2.0", "https://developer.android.com/jetpack/androidx"),
- Library("Kotlin Coroutines", "JetBrains", "Apache 2.0", "https://github.com/Kotlin/kotlinx.coroutines")
- )
+ Library("AndroidX Activity", "Google", "Apache 2.0", "https://developer.android.com/jetpack/androidx/releases/activity"),
+ Library("AndroidX AppCompat", "Google", "Apache 2.0", "https://developer.android.com/jetpack/androidx/releases/appcompat"),
+ Library("AndroidX DataStore", "Google", "Apache 2.0", "https://developer.android.com/topic/libraries/architecture/datastore"),
+ Library("AndroidX Lifecycle", "Google", "Apache 2.0", "https://developer.android.com/jetpack/androidx/releases/lifecycle"),
+ Library("AndroidX Navigation 3", "Google", "Apache 2.0", "https://developer.android.com/jetpack/androidx/releases/navigation"),
+ Library("AndroidX Palette", "Google", "Apache 2.0", "https://developer.android.com/develop/ui/views/graphics/palette"),
+ Library("AndroidX Room", "Google", "Apache 2.0", "https://developer.android.com/training/data-storage/room"),
+ Library("Gson", "Google", "Apache 2.0", "https://github.com/google/gson"),
+ Library("Kotlin Coroutines", "JetBrains", "Apache 2.0", "https://github.com/Kotlin/kotlinx.coroutines"),
+ Library("Kotlin Serialization", "JetBrains", "Apache 2.0", "https://github.com/Kotlin/kotlinx.serialization"),
+ Library("Shizuku API", "RikkaApps", "Apache 2.0", "https://github.com/RikkaApps/Shizuku-API")
+ ).sortedBy { it.name }
Scaffold(
topBar = {
TopAppBar(
- title = { Text("Open Source Licenses") },
+ title = { Text(stringResource(R.string.open_source_licenses)) },
navigationIcon = {
FilledTonalIconButton(
onClick = onBack,
@@ -49,7 +61,8 @@ fun LicensesScreen(onBack: () -> Unit) {
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
- } )
+ }
+ )
}
) { padding ->
LazyColumn(modifier = Modifier.padding(padding)) {
@@ -63,4 +76,4 @@ fun LicensesScreen(onBack: () -> Unit) {
}
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/NavCustomizationScreen.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/NavCustomizationScreen.kt
index db95220..4f49737 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/NavCustomizationScreen.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/NavCustomizationScreen.kt
@@ -1,9 +1,6 @@
package com.d4viddf.hyperbridge.ui.screens.settings
-import androidx.compose.foundation.background
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
@@ -11,24 +8,18 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
-import androidx.compose.material.icons.filled.TurnRight
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
-import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.ExposedDropdownMenuBox
-import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@@ -38,62 +29,74 @@ 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.graphics.Brush
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
-import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
import com.d4viddf.hyperbridge.R
import com.d4viddf.hyperbridge.data.AppPreferences
import com.d4viddf.hyperbridge.models.NavContent
+import com.d4viddf.hyperbridge.ui.components.NavDropdown
+import com.d4viddf.hyperbridge.ui.components.NavPreview
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NavCustomizationScreen(
onBack: () -> Unit,
- packageName: String? = null
+ packageName: String? = null,
+ showTopBar: Boolean = true // <-- NEW FLAG
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val preferences = remember { AppPreferences(context) }
+ // 1. Get Global Fallback
val globalLayout by preferences.globalNavLayoutFlow.collectAsState(initial = NavContent.DISTANCE_ETA to NavContent.INSTRUCTION)
+
+ // 2. Get local AppPreference (if packageName is provided)
val appLayout by if (packageName != null) {
preferences.getAppNavLayout(packageName).collectAsState(initial = null to null)
} else {
- remember { mutableStateOf(null to null) }
+ remember { mutableStateOf>(null to null) }
}
+ // 3. Resolve what is currently active
val isGlobalMode = packageName == null
val isUsingGlobalDefault = !isGlobalMode && appLayout.first == null
- val currentLeft = if (isGlobalMode || isUsingGlobalDefault) globalLayout.first else (appLayout.first ?: globalLayout.first)
- val currentRight = if (isGlobalMode || isUsingGlobalDefault) globalLayout.second else (appLayout.second ?: globalLayout.second)
+ val currentLeft = appLayout.first ?: globalLayout.first
+ val currentRight = appLayout.second ?: globalLayout.second
Scaffold(
topBar = {
- TopAppBar(
- title = { Text(stringResource(R.string.nav_layout_title)) },
- navigationIcon = {
- FilledTonalIconButton(onClick = onBack) {
- Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back))
+ if (showTopBar) { // Only show if requested
+ TopAppBar(
+ title = { Text(stringResource(R.string.nav_layout_title)) },
+ navigationIcon = {
+ FilledTonalIconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back))
+ }
}
- }
- )
+ )
+ }
}
) { padding ->
- Column(modifier = Modifier.padding(padding).padding(16.dp)) {
+ Column(
+ modifier = Modifier
+ .padding(padding)
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState()) // Allow scrolling to fit on smaller screens
+ .padding(
+ start = 16.dp,
+ end = 16.dp,
+ bottom = 16.dp,
+ top = if (showTopBar) 16.dp else 0.dp // <-- DYNAMIC TOP PADDING
+ )
+ ) {
Text("Preview", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(16.dp))
@@ -116,9 +119,21 @@ fun NavCustomizationScreen(
.fillMaxWidth()
.clickable {
if (isUsingGlobalDefault) {
- scope.launch { preferences.updateAppNavLayout(packageName, globalLayout.first, globalLayout.second) }
+ scope.launch {
+ preferences.updateAppNavLayout(
+ packageName,
+ globalLayout.first,
+ globalLayout.second
+ )
+ }
} else {
- scope.launch { preferences.updateAppNavLayout(packageName, null, null) }
+ scope.launch {
+ preferences.updateAppNavLayout(
+ packageName,
+ null,
+ null
+ )
+ }
}
}
.padding(vertical = 8.dp),
@@ -158,172 +173,28 @@ fun NavCustomizationScreen(
}
}
}
- }
- }
-}
-
-@Composable
-fun NavPreview(left: NavContent, right: NavContent) {
- val leftLabel = stringResource(getNavContentLabelRes(left))
- val rightLabel = stringResource(getNavContentLabelRes(right))
- val cd = stringResource(R.string.cd_nav_preview, leftLabel, rightLabel)
- Card(
- colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
- shape = RoundedCornerShape(24.dp),
- modifier = Modifier
- .fillMaxWidth()
- .semantics(mergeDescendants = true) { contentDescription = cd }
- ) {
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 32.dp),
- contentAlignment = Alignment.Center
- ) {
- // --- UPDATED ISLAND SIZE ---
- Box(
- modifier = Modifier
- .width(330.dp) // Increased from 280dp to 340dp to fit Dist+ETA
- .height(46.dp)
- .clip(RoundedCornerShape(50))
- .background(Color.Black)
+ // --- Informational Notes ---
+ Spacer(modifier = Modifier.height(24.dp))
+ Card(
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f))
) {
- // Camera Cutout
- Box(
- modifier = Modifier
- .align(Alignment.Center)
- .size(30.dp)
- .clip(CircleShape)
- .background(Color(0xFF1F1F1F))
- )
-
- Row(
- modifier = Modifier
- .fillMaxSize()
- .padding(horizontal = 12.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- // LEFT SIDE
- Row(
- modifier = Modifier.weight(1f),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.Start
- ) {
- Icon(
- imageVector = Icons.Default.TurnRight,
- contentDescription = null,
- modifier = Modifier.size(20.dp)
- )
- Spacer(Modifier.width(6.dp))
- NavContentRenderer(left, Alignment.Start)
- }
-
- // SPACER
- Spacer(modifier = Modifier.width(32.dp))
-
- // RIGHT SIDE
- Box(
- modifier = Modifier.weight(1f),
- contentAlignment = Alignment.CenterEnd
- ) {
- NavContentRenderer(right, Alignment.End)
- }
- }
- }
- }
- }
-}
-
-@Composable
-fun NavContentRenderer(type: NavContent, align: Alignment.Horizontal) {
-
- // Fade Logic: Only applies to long text (Instruction)
- val fadeBrush = if (type == NavContent.INSTRUCTION) {
- if (align == Alignment.Start) {
- Brush.horizontalGradient(0.85f to Color.White, 1.0f to Color.Transparent)
- } else {
- Brush.horizontalGradient(0.0f to Color.Transparent, 0.15f to Color.White)
- }
- } else null
-
- val textStyle = if (fadeBrush != null) {
- TextStyle(brush = fadeBrush, fontWeight = FontWeight.Bold, fontSize = 14.sp)
- } else {
- TextStyle(color = Color.White, fontWeight = FontWeight.Bold, fontSize = 14.sp)
- }
-
- val timeStyle = TextStyle(color = Color.White, fontWeight = FontWeight.Normal, fontSize = 14.sp)
-
- when (type) {
- NavContent.INSTRUCTION -> {
- Text(
- text = stringResource(R.string.nav_preview_instruction),
- style = textStyle,
- maxLines = 1,
- overflow = TextOverflow.Clip
- )
- }
- NavContent.DISTANCE -> {
- Text(
- text = stringResource(R.string.nav_preview_distance),
- style = textStyle // Solid White
- )
- }
- NavContent.ETA -> {
- Text(
- text = stringResource(R.string.nav_preview_time),
- style = timeStyle // Standard weight
- )
- }
- NavContent.DISTANCE_ETA -> {
- // Combined Row
- Row(verticalAlignment = Alignment.CenterVertically) {
- Text(stringResource(R.string.nav_preview_distance), style = textStyle.copy(fontSize = 13.sp))
- Spacer(Modifier.width(4.dp))
- Text("•", color = Color.Gray, fontSize = 12.sp)
- Spacer(Modifier.width(4.dp))
- Text(stringResource(R.string.nav_preview_time), style = timeStyle.copy(fontSize = 13.sp))
- }
- }
- NavContent.NONE -> { /* Empty */ }
- }
-}
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun NavDropdown(label: String, selected: NavContent, onSelect: (NavContent) -> Unit) {
- var expanded by remember { mutableStateOf(false) }
- Column {
- Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary)
- Spacer(Modifier.height(4.dp))
- ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
- OutlinedTextField(
- value = stringResource(getNavContentLabelRes(selected)),
- onValueChange = {},
- readOnly = true,
- trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
- modifier = Modifier.menuAnchor().fillMaxWidth(),
- textStyle = MaterialTheme.typography.bodyMedium
- )
- ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
- NavContent.entries.forEach { option ->
- DropdownMenuItem(
- text = { Text(stringResource(getNavContentLabelRes(option))) },
- onClick = { onSelect(option); expanded = false }
+ Column(Modifier.padding(16.dp)) {
+ Text(
+ text = stringResource(R.string.good_to_know),
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.primary,
+ style = MaterialTheme.typography.titleSmall
+ )
+ Spacer(Modifier.height(4.dp))
+ Text(
+ text = stringResource(R.string.nav_layout_info),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
+ Spacer(modifier = Modifier.height(32.dp))
}
}
}
-
-private fun getNavContentLabelRes(content: NavContent): Int {
- return when(content) {
- NavContent.INSTRUCTION -> R.string.nav_content_instruction
- NavContent.DISTANCE -> R.string.nav_content_distance
- NavContent.ETA -> R.string.nav_content_eta
- NavContent.DISTANCE_ETA -> R.string.nav_content_distance_eta
- NavContent.NONE -> R.string.nav_content_none
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/PermanentIslandConfigScreen.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/PermanentIslandConfigScreen.kt
new file mode 100644
index 0000000..448c3d2
--- /dev/null
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/PermanentIslandConfigScreen.kt
@@ -0,0 +1,181 @@
+package com.d4viddf.hyperbridge.ui.screens.settings
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilledTonalIconButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.d4viddf.hyperbridge.R
+import com.d4viddf.hyperbridge.data.AppPreferences
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Slider
+import androidx.compose.foundation.layout.Row
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.ui.Alignment
+import kotlinx.coroutines.launch
+import androidx.compose.ui.tooling.preview.Preview
+import com.d4viddf.hyperbridge.ui.theme.HyperBridgeTheme
+import com.d4viddf.hyperbridge.ui.components.PermanentIslandPreview
+
+@Composable
+fun PermanentIslandConfigScreen(
+ onBack: () -> Unit
+) {
+ val context = LocalContext.current
+ val prefs = AppPreferences(context)
+ val scope = rememberCoroutineScope()
+
+ val isEnabled by prefs.isPermanentIslandEnabledFlow.collectAsState(initial = false)
+ val islandWidth by prefs.permanentIslandWidthFlow.collectAsState(initial = 0)
+
+ PermanentIslandConfigContent(
+ isEnabled = isEnabled,
+ islandWidth = islandWidth,
+ onEnabledChange = { checked ->
+ scope.launch {
+ prefs.setPermanentIslandEnabled(checked)
+ }
+ },
+ onWidthChange = { width ->
+ scope.launch {
+ prefs.setPermanentIslandWidth(width)
+ }
+ },
+ onBack = onBack
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun PermanentIslandConfigContent(
+ isEnabled: Boolean,
+ islandWidth: Int,
+ onEnabledChange: (Boolean) -> Unit,
+ onWidthChange: (Int) -> Unit,
+ onBack: () -> Unit
+) {
+ var sliderValue by androidx.compose.runtime.remember { androidx.compose.runtime.mutableFloatStateOf(0f) }
+ var isDragging by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
+
+ androidx.compose.runtime.LaunchedEffect(islandWidth) {
+ if (!isDragging) {
+ sliderValue = islandWidth.toFloat()
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.permanent_island_title)) },
+ navigationIcon = {
+ FilledTonalIconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back))
+ }
+ }
+ )
+ }
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .padding(padding)
+ .padding(16.dp)
+ .fillMaxSize()
+ ) {
+ PermanentIslandPreview(islandWidthValue = if (isDragging) sliderValue.toInt() else islandWidth)
+
+ Spacer(Modifier.height(24.dp))
+
+
+
+ Card(
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
+ shape = androidx.compose.foundation.shape.RoundedCornerShape(24.dp),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = stringResource(R.string.permanent_island_title),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = androidx.compose.ui.text.font.FontWeight.Medium
+ )
+
+ }
+ Switch(
+ checked = isEnabled,
+ onCheckedChange = onEnabledChange
+ )
+ }
+
+ if (isEnabled) {
+ Spacer(Modifier.height(16.dp))
+ Text(
+ text = stringResource(R.string.permanent_island_width),
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = androidx.compose.ui.text.font.FontWeight.Medium
+ )
+ Slider(
+ value = sliderValue,
+ onValueChange = { value ->
+ isDragging = true
+ sliderValue = value
+ onWidthChange(value.toInt())
+ },
+ onValueChangeFinished = {
+ isDragging = false
+ },
+ valueRange = 0f..20f
+ )
+ }
+ }
+ }
+ Spacer(Modifier.height(24.dp))
+ Text(
+ text = stringResource(R.string.permanent_island_screen_desc),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun PermanentIslandConfigScreenPreview() {
+ HyperBridgeTheme {
+ PermanentIslandConfigContent(
+ isEnabled = true,
+ islandWidth = 10,
+ onEnabledChange = {},
+ onWidthChange = {},
+ onBack = {}
+ )
+ }
+}
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/SetupHealthScreen.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/SetupHealthScreen.kt
index 335c13c..a7ca4f0 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/SetupHealthScreen.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/settings/SetupHealthScreen.kt
@@ -31,6 +31,7 @@ import androidx.compose.material.icons.filled.RestartAlt
import androidx.compose.material.icons.filled.Smartphone
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.Warning
+import androidx.compose.material.icons.filled.Layers
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@@ -64,6 +65,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import com.d4viddf.hyperbridge.R
import com.d4viddf.hyperbridge.util.DeviceUtils
+import com.d4viddf.hyperbridge.util.XiaomiNotificationHelper
import com.d4viddf.hyperbridge.util.isNotificationServiceEnabled
import com.d4viddf.hyperbridge.util.isPostNotificationsEnabled
import com.d4viddf.hyperbridge.util.openAutoStartSettings
@@ -81,7 +83,9 @@ fun SetupHealthScreen(onBack: () -> Unit) {
// --- STATE ---
var isListenerGranted by remember { mutableStateOf(isNotificationServiceEnabled(context)) }
var isPostGranted by remember { mutableStateOf(isPostNotificationsEnabled(context)) }
+ var isOverlayGranted by remember { mutableStateOf(Settings.canDrawOverlays(context)) }
var isBatteryOptimized by remember { mutableStateOf(isIgnoringBatteryOptimizations(context)) }
+ var isFeaturedGranted by remember { mutableStateOf(false) }
// --- LIFECYCLE ---
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
@@ -90,7 +94,9 @@ fun SetupHealthScreen(onBack: () -> Unit) {
if (event == Lifecycle.Event.ON_RESUME) {
isListenerGranted = isNotificationServiceEnabled(context)
isPostGranted = isPostNotificationsEnabled(context)
+ isOverlayGranted = Settings.canDrawOverlays(context)
isBatteryOptimized = isIgnoringBatteryOptimizations(context)
+ isFeaturedGranted = XiaomiNotificationHelper.hasFocusPermission(context)
}
}
lifecycleOwner.lifecycle.addObserver(observer)
@@ -189,7 +195,7 @@ fun SetupHealthScreen(onBack: () -> Unit) {
// Post Notif
HealthItem(
title = stringResource(R.string.show_island),
- subtitle = stringResource(R.string.perm_display_desc),
+ subtitle = stringResource(R.string.perm_post_desc),
icon = Icons.Default.Visibility,
isGranted = isPostGranted,
onClick = {
@@ -197,9 +203,42 @@ fun SetupHealthScreen(onBack: () -> Unit) {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intent.data = "package:${context.packageName}".toUri()
context.startActivity(intent)
- } catch (e: Exception) { }
+ } catch (_: Exception) { }
}
)
+
+ // Overlay
+ HorizontalDivider(modifier = Modifier.padding(horizontal = 20.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(0.2f))
+ HealthItem(
+ title = stringResource(R.string.perm_display_title),
+ subtitle = stringResource(R.string.perm_display_onboard_desc),
+ icon = Icons.Default.Layers,
+ isGranted = isOverlayGranted,
+ onClick = {
+ try {
+ val intent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION)
+ intent.data = "package:${context.packageName}".toUri()
+ context.startActivity(intent)
+ } catch (_: Exception) { }
+ }
+ )
+
+ val isSupported = XiaomiNotificationHelper.isSupportIsland()
+ if (isXiaomi && isSupported) {
+ HorizontalDivider(modifier = Modifier.padding(horizontal = 20.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(0.2f))
+ HealthItem(
+ title = stringResource(R.string.xiaomi_featured_notifications),
+ subtitle = stringResource(R.string.featured_notifications_open_settings),
+ icon = Icons.Default.Smartphone,
+ isGranted = isFeaturedGranted,
+ onClick = {
+ val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
+ putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
+ }
+ context.startActivity(intent)
+ }
+ )
+ }
}
Spacer(modifier = Modifier.height(24.dp))
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/AppThemeEditorScreen.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/AppThemeEditorScreen.kt
index 3ba0f27..b68338b 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/AppThemeEditorScreen.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/AppThemeEditorScreen.kt
@@ -2,8 +2,11 @@ package com.d4viddf.hyperbridge.ui.screens.theme
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
@@ -26,6 +29,9 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.outlined.Call
import androidx.compose.material.icons.outlined.ColorLens
+import androidx.compose.material.icons.outlined.DisplaySettings
+import androidx.compose.material.icons.outlined.Map
+import androidx.compose.material.icons.outlined.NotificationsActive
import androidx.compose.material.icons.outlined.TouchApp
import androidx.compose.material.icons.outlined.Widgets
import androidx.compose.material3.AlertDialog
@@ -52,18 +58,26 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.d4viddf.hyperbridge.R
+import com.d4viddf.hyperbridge.models.NotificationType
+import com.d4viddf.hyperbridge.ui.components.SettingsToggleCard
+import com.d4viddf.hyperbridge.ui.screens.settings.NavCustomizationScreen
import com.d4viddf.hyperbridge.ui.screens.theme.content.ActionConfigSheet
import com.d4viddf.hyperbridge.ui.screens.theme.content.ActionsDetailContent
+import com.d4viddf.hyperbridge.ui.screens.theme.content.BehaviourMenuContent
import com.d4viddf.hyperbridge.ui.screens.theme.content.CallStyleSheetContent
import com.d4viddf.hyperbridge.ui.screens.theme.content.ColorsDetailContent
+import com.d4viddf.hyperbridge.ui.screens.theme.content.EngineThemeContent
import com.d4viddf.hyperbridge.ui.screens.theme.content.IconsDetailContent
-import com.d4viddf.hyperbridge.ui.screens.theme.content.SharedThemePreview
+import com.d4viddf.hyperbridge.ui.screens.theme.content.NotificationTypesContent
+import com.d4viddf.hyperbridge.ui.screens.theme.content.ThemeBehaviourContent
import com.d4viddf.hyperbridge.ui.screens.theme.content.safeParseColor
+import com.d4viddf.hyperbridge.ui.theme.HyperBridgeTheme
enum class AppEditorRoute {
- MENU, COLORS, ICONS, CALLS, ACTIONS
+ MENU, BEHAVIOR_MENU, BEHAVIOR_ENGINE, BEHAVIOR_ISLAND, BEHAVIOR_TYPES, COLORS, ICONS, CALLS, NAVIGATION, REPLY, ACTIONS
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -74,12 +88,12 @@ fun AppThemeEditor(viewModel: ThemeViewModel) {
// Logic to handle "Back" request
val handleBack = {
- if (currentRoute != AppEditorRoute.MENU) {
- // If in a sub-menu, just go back to App Menu
- currentRoute = AppEditorRoute.MENU
- } else {
- // If at App Menu root, ask to save before exiting
- showUnsavedDialog = true
+ when (currentRoute) {
+ AppEditorRoute.BEHAVIOR_ENGINE,
+ AppEditorRoute.BEHAVIOR_ISLAND,
+ AppEditorRoute.BEHAVIOR_TYPES -> currentRoute = AppEditorRoute.BEHAVIOR_MENU // Go back to sub-menu
+ AppEditorRoute.MENU -> showUnsavedDialog = true // Ask to save before exiting
+ else -> currentRoute = AppEditorRoute.MENU // Go back to main app menu
}
}
@@ -96,9 +110,15 @@ fun AppThemeEditor(viewModel: ThemeViewModel) {
Text(
text = if (currentRoute == AppEditorRoute.MENU) stringResource(R.string.edit_app) else stringResource(
when (currentRoute) {
+ AppEditorRoute.BEHAVIOR_MENU -> R.string.behaviour_triggers
+ AppEditorRoute.BEHAVIOR_ENGINE -> R.string.engine
+ AppEditorRoute.BEHAVIOR_ISLAND -> R.string.island_behavior
+ AppEditorRoute.BEHAVIOR_TYPES -> R.string.active_notifications_title
AppEditorRoute.COLORS -> R.string.creator_nav_colors
AppEditorRoute.ICONS -> R.string.creator_nav_icons
AppEditorRoute.CALLS -> R.string.creator_nav_calls
+ AppEditorRoute.NAVIGATION -> R.string.nav_layout_title
+ AppEditorRoute.REPLY -> R.string.app_name // Fallback or explicit string if preferred
AppEditorRoute.ACTIONS -> R.string.creator_nav_actions
else -> R.string.app_name
}
@@ -112,6 +132,12 @@ fun AppThemeEditor(viewModel: ThemeViewModel) {
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
+ } else if (currentRoute == AppEditorRoute.REPLY) {
+ Text(
+ text = "Inline Reply",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.primary
+ )
}
}
},
@@ -145,7 +171,7 @@ fun AppThemeEditor(viewModel: ThemeViewModel) {
AnimatedContent(
targetState = currentRoute,
transitionSpec = {
- if (targetState == AppEditorRoute.MENU) {
+ if (targetState == AppEditorRoute.MENU || targetState == AppEditorRoute.BEHAVIOR_MENU && initialState != AppEditorRoute.MENU) {
slideInHorizontally { -it } + fadeIn() togetherWith slideOutHorizontally { it } + fadeOut()
} else {
slideInHorizontally { it } + fadeIn() togetherWith slideOutHorizontally { -it } + fadeOut()
@@ -166,6 +192,28 @@ fun AppThemeEditor(viewModel: ThemeViewModel) {
viewModel = viewModel,
onNavigate = { currentRoute = it }
)
+ AppEditorRoute.BEHAVIOR_MENU -> Box(Modifier.fillMaxSize()) {
+ BehaviourMenuContent(onNavigate = {
+ currentRoute = when (it) {
+ CreatorRoute.BEHAVIOR_ENGINE -> AppEditorRoute.BEHAVIOR_ENGINE
+ CreatorRoute.BEHAVIOR_ISLAND -> AppEditorRoute.BEHAVIOR_ISLAND
+ CreatorRoute.BEHAVIOR_TYPES -> AppEditorRoute.BEHAVIOR_TYPES
+ else -> AppEditorRoute.BEHAVIOR_MENU
+ }
+ })
+ }
+ AppEditorRoute.BEHAVIOR_ENGINE -> Box(Modifier.fillMaxSize()) {
+ EngineThemeContent(
+ isNative = viewModel.appUseNativeLiveUpdates,
+ onEngineChange = { viewModel.appUseNativeLiveUpdates = it }
+ )
+ }
+ AppEditorRoute.BEHAVIOR_ISLAND -> Box(Modifier.fillMaxSize()) {
+ ThemeBehaviourContent() // Still uses standard island behavior
+ }
+ AppEditorRoute.BEHAVIOR_TYPES -> Box(Modifier.fillMaxSize()) {
+ AppNotificationTypesEditor(viewModel = viewModel)
+ }
AppEditorRoute.COLORS -> DetailScreenShell(
previewContent = { SharedThemePreview(effHighlight, false, effShape, effPadding, effAnswerColor, effDeclineColor, effAnswerShape, effDeclineShape) },
content = {
@@ -184,6 +232,16 @@ fun AppThemeEditor(viewModel: ThemeViewModel) {
AppCallEditor(viewModel)
}
)
+ AppEditorRoute.NAVIGATION -> Box(Modifier.fillMaxSize()) {
+ NavCustomizationScreen(
+ onBack = { currentRoute = AppEditorRoute.MENU },
+ packageName = viewModel.editingAppPackage,
+ showTopBar = false
+ )
+ }
+ AppEditorRoute.REPLY -> Box(Modifier.fillMaxSize()) {
+ com.d4viddf.hyperbridge.ui.screens.theme.content.ReplyStyleSheetContent(viewModel = viewModel)
+ }
AppEditorRoute.ACTIONS -> Box(Modifier.fillMaxSize()) {
AppActionEditor(viewModel)
}
@@ -266,15 +324,25 @@ fun AppEditorMenu(
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
val items = listOf(
+ AppEditorRoute.BEHAVIOR_MENU,
AppEditorRoute.COLORS,
AppEditorRoute.ICONS,
AppEditorRoute.CALLS,
+ AppEditorRoute.NAVIGATION,
+ AppEditorRoute.REPLY,
AppEditorRoute.ACTIONS
)
items.forEachIndexed { index, route ->
val shape = getExpressiveShape(items.size, index, ShapeStyle.Large)
when(route) {
+ AppEditorRoute.BEHAVIOR_MENU -> CreatorOptionCard(
+ title = stringResource(R.string.engine),
+ subtitle = stringResource(R.string.engine_timeouts_triggers),
+ icon = Icons.Outlined.DisplaySettings,
+ shape = shape,
+ onClick = { onNavigate(route) }
+ )
AppEditorRoute.COLORS -> CreatorOptionCard(
title = stringResource(R.string.creator_nav_colors),
subtitle = if (viewModel.appHighlightColor != null) stringResource(R.string.creator_sub_colors_custom) else stringResource(R.string.creator_sub_colors_default),
@@ -303,6 +371,20 @@ fun AppEditorMenu(
shape = shape,
onClick = { onNavigate(route) }
)
+ AppEditorRoute.NAVIGATION -> CreatorOptionCard(
+ title = stringResource(R.string.nav_layout_title),
+ subtitle = stringResource(R.string.nav_layout_desc),
+ icon = Icons.Outlined.Map,
+ shape = shape,
+ onClick = { onNavigate(route) }
+ )
+ AppEditorRoute.REPLY -> CreatorOptionCard(
+ title = stringResource(R.string.inline_reply_title),
+ subtitle = stringResource(R.string.customize_inline_reply),
+ icon = Icons.Outlined.Call, // Just use a call or generic icon for now
+ shape = shape,
+ onClick = { onNavigate(route) }
+ )
AppEditorRoute.ACTIONS -> CreatorOptionCard(
title = stringResource(R.string.creator_nav_actions),
subtitle = pluralStringResource(
@@ -385,4 +467,171 @@ fun AppActionEditor(viewModel: ThemeViewModel) {
}
)
}
+}
+
+
+// ========================================================================
+// APP-SPECIFIC BEHAVIOR EDITORS
+// ========================================================================
+
+@Composable
+fun AppNotificationTypesEditor(viewModel: ThemeViewModel) {
+ // If it's not null, it means the user is overriding the global setting
+ var isOverride by remember { mutableStateOf(viewModel.appEnabledNotificationTypes != null) }
+ val activeTypes = viewModel.appEnabledNotificationTypes ?: emptySet()
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp)
+ ) {
+ Text(stringResource(R.string.trigger_override), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
+ Text(stringResource(R.string.trigger_app_desc), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Spacer(Modifier.height(16.dp))
+
+ SettingsToggleCard(
+ title = stringResource(R.string.override_global_triggers),
+ subtitle = stringResource(R.string.override_global_trigger_desc),
+ icon = Icons.Outlined.NotificationsActive,
+ checked = isOverride,
+ onCheckedChange = { checked ->
+ isOverride = checked
+ // If turning on, default to everything enabled for this app
+ viewModel.appEnabledNotificationTypes = if (checked) NotificationType.entries.map { it.name }.toSet() else null
+ },
+ shape = RoundedCornerShape(24.dp)
+ )
+
+ AnimatedVisibility(
+ visible = isOverride,
+ enter = expandVertically() + fadeIn(),
+ exit = shrinkVertically() + fadeOut()
+ ) {
+ Column(Modifier.padding(top = 16.dp)) {
+ NotificationType.entries.forEachIndexed { index, type ->
+ val shape = when {
+ NotificationType.entries.size == 1 -> RoundedCornerShape(24.dp)
+ index == 0 -> RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp, bottomStart = 4.dp, bottomEnd = 4.dp)
+ index == NotificationType.entries.size - 1 -> RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp, bottomStart = 24.dp, bottomEnd = 24.dp)
+ else -> RoundedCornerShape(4.dp)
+ }
+
+ // You can map the exact icons and subtitles here as you did in ThemeSettingsContent
+ SettingsToggleCard(
+ title = stringResource(type.labelRes),
+ subtitle = stringResource(R.string.enable_triggers),
+ icon = Icons.Outlined.TouchApp, // Update this with your specific type icons if desired
+ checked = activeTypes.contains(type.name),
+ onCheckedChange = { isChecked ->
+ val newSet = if (isChecked) activeTypes + type.name else activeTypes - type.name
+ viewModel.appEnabledNotificationTypes = newSet
+ },
+ shape = shape
+ )
+
+ if (index < NotificationType.entries.size - 1) {
+ Spacer(modifier = Modifier.height(2.dp))
+ }
+ }
+ }
+ }
+ }
+}
+
+// ========================================================================
+// PREVIEWS
+// ========================================================================
+
+@Preview(showBackground = true, name = "1. App Editor Menu (Light)")
+@Composable
+fun PreviewAppEditorMenuLight() {
+ HyperBridgeTheme(darkTheme = false) {
+ Box(modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background)) {
+ BehaviourMenuContent(onNavigate = {})
+ }
+ }
+}
+
+@Preview(showBackground = true, name = "2. App Editor Menu (Dark)")
+@Composable
+fun PreviewAppEditorMenuDark() {
+ HyperBridgeTheme(darkTheme = true) {
+ Box(modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background)) {
+ BehaviourMenuContent(onNavigate = {})
+ }
+ }
+}
+
+@Preview(showBackground = true, name = "3. Notification Types (Light)")
+@Composable
+fun PreviewNotificationTypesContentLight() {
+ HyperBridgeTheme(darkTheme = false) {
+ Box(modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background)) {
+ NotificationTypesContent()
+ }
+ }
+}
+
+@Preview(showBackground = true, name = "4. Notification Types (Dark)")
+@Composable
+fun PreviewNotificationTypesContentDark() {
+ HyperBridgeTheme(darkTheme = true) {
+ Box(modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background)) {
+ NotificationTypesContent()
+ }
+ }
+}
+
+@Preview(showBackground = true, name = "5. Engine - Default Inherit (Light)")
+@Composable
+fun PreviewEngineThemeContentDefault() {
+ HyperBridgeTheme(darkTheme = false) {
+ Box(modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background)) {
+ EngineThemeContent(
+ isNative = null, // Demonstrates the Default option selected
+ onEngineChange = {}
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true, name = "6. Engine - Native Live Updates (Dark)")
+@Composable
+fun PreviewEngineThemeContentNativeDark() {
+ HyperBridgeTheme(darkTheme = true) {
+ Box(modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background)) {
+ EngineThemeContent(
+ isNative = true, // Demonstrates the Native option selected
+ onEngineChange = {}
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true, name = "7. Engine - Xiaomi Custom (Light)")
+@Composable
+fun PreviewEngineThemeContentXiaomiLight() {
+ HyperBridgeTheme(darkTheme = false) {
+ Box(modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background)) {
+ EngineThemeContent(
+ isNative = false, // Demonstrates the Xiaomi option selected
+ onEngineChange = {}
+ )
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/GlobalReplyCustomizationScreen.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/GlobalReplyCustomizationScreen.kt
new file mode 100644
index 0000000..3411503
--- /dev/null
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/GlobalReplyCustomizationScreen.kt
@@ -0,0 +1,69 @@
+package com.d4viddf.hyperbridge.ui.screens.theme
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilledTonalIconButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.d4viddf.hyperbridge.R
+import com.d4viddf.hyperbridge.ui.screens.theme.content.ReplyStyleSheetContent
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun GlobalReplyCustomizationScreen(
+ onBack: () -> Unit
+) {
+ val context = LocalContext.current
+ val viewModel: ThemeViewModel = viewModel()
+ val activeThemeId by viewModel.activeThemeId.collectAsState()
+
+ LaunchedEffect(activeThemeId) {
+ if (activeThemeId != null) {
+ viewModel.loadThemeForEditing(activeThemeId!!)
+ } else {
+ viewModel.clearCreatorState()
+ }
+ }
+
+ BackHandler {
+ viewModel.saveTheme(existingId = viewModel.currentEditingThemeId, apply = true)
+ onBack()
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.inline_reply_title)) },
+ navigationIcon = {
+ FilledTonalIconButton(onClick = {
+ viewModel.saveTheme(existingId = viewModel.currentEditingThemeId, apply = true)
+ onBack()
+ }) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back))
+ }
+ }
+ )
+ }
+ ) { padding ->
+ Box(modifier = Modifier.padding(padding).fillMaxSize()) {
+ if (viewModel.currentEditingThemeId != null) {
+ ReplyStyleSheetContent(viewModel = viewModel)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/ThemeCreatorScreen.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/ThemeCreatorScreen.kt
index 3295f9d..71a1248 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/ThemeCreatorScreen.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/ThemeCreatorScreen.kt
@@ -25,7 +25,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -41,8 +41,10 @@ import androidx.compose.material.icons.automirrored.rounded.ArrowForwardIos
import androidx.compose.material.icons.outlined.Apps
import androidx.compose.material.icons.outlined.Call
import androidx.compose.material.icons.outlined.ColorLens
+import androidx.compose.material.icons.outlined.DisplaySettings
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.Image
+import androidx.compose.material.icons.outlined.Map
import androidx.compose.material.icons.outlined.TouchApp
import androidx.compose.material.icons.outlined.Widgets
import androidx.compose.material.icons.rounded.Delete
@@ -91,18 +93,23 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.d4viddf.hyperbridge.R
import com.d4viddf.hyperbridge.models.theme.ColorMode
+import com.d4viddf.hyperbridge.ui.screens.settings.NavCustomizationScreen
import com.d4viddf.hyperbridge.ui.screens.theme.content.ActionsDetailContent
import com.d4viddf.hyperbridge.ui.screens.theme.content.AppsDetailContent
+import com.d4viddf.hyperbridge.ui.screens.theme.content.BehaviourMenuContent
import com.d4viddf.hyperbridge.ui.screens.theme.content.CallStyleSheetContent
import com.d4viddf.hyperbridge.ui.screens.theme.content.ColorsDetailContent
+import com.d4viddf.hyperbridge.ui.screens.theme.content.EngineThemeContent
import com.d4viddf.hyperbridge.ui.screens.theme.content.IconsDetailContent
-import com.d4viddf.hyperbridge.ui.screens.theme.content.SharedThemePreview
+import com.d4viddf.hyperbridge.ui.screens.theme.content.NotificationTypesContent
+import com.d4viddf.hyperbridge.ui.screens.theme.content.ThemeBehaviourContent
import com.d4viddf.hyperbridge.ui.screens.theme.content.safeParseColor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
+// [NEW] Sub-menu routing added
enum class CreatorRoute {
- MAIN_MENU, COLORS, ICONS, CALLS, ACTIONS, APPS
+ MAIN_MENU, BEHAVIOR_MENU, BEHAVIOR_ENGINE, BEHAVIOR_ISLAND, BEHAVIOR_TYPES, COLORS, ICONS, CALLS, NAVIGATION, REPLY, ACTIONS, APPS
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -114,9 +121,12 @@ fun ThemeCreatorScreen(
) {
val viewModel: ThemeViewModel = viewModel()
val activeThemeId by viewModel.activeThemeId.collectAsState()
+ val isNative by viewModel.useNativeLiveUpdates.collectAsState()
if (viewModel.editingAppPackage != null) {
- AppThemeEditor(viewModel)
+ AppThemeEditor(
+ viewModel = viewModel
+ )
} else {
var currentRoute by remember { mutableStateOf(CreatorRoute.MAIN_MENU) }
var showSettingsSheet by remember { mutableStateOf(false) }
@@ -134,8 +144,27 @@ fun ThemeCreatorScreen(
}
}
- BackHandler(enabled = currentRoute != CreatorRoute.MAIN_MENU) {
- currentRoute = CreatorRoute.MAIN_MENU
+ // --- Shared Back Logic ---
+ val handleBackNavigation = {
+ when (currentRoute) {
+ CreatorRoute.BEHAVIOR_ENGINE,
+ CreatorRoute.BEHAVIOR_ISLAND,
+ CreatorRoute.BEHAVIOR_TYPES -> currentRoute = CreatorRoute.BEHAVIOR_MENU // Return to sub-menu
+ CreatorRoute.MAIN_MENU -> {
+ viewModel.currentEditingThemeId = null
+ onBack() // Exit entirely
+ }
+ else -> currentRoute = CreatorRoute.MAIN_MENU // Return to main menu
+ }
+ }
+
+ BackHandler(enabled = true) {
+ handleBackNavigation()
+ }
+
+ BackHandler(enabled = currentRoute == CreatorRoute.MAIN_MENU) {
+ viewModel.currentEditingThemeId = null
+ onBack()
}
BackHandler(enabled = currentRoute == CreatorRoute.MAIN_MENU) {
@@ -150,9 +179,15 @@ fun ThemeCreatorScreen(
Text(
text = when (currentRoute) {
CreatorRoute.MAIN_MENU -> if (editThemeId == null) stringResource(R.string.creator_title_new) else stringResource(R.string.creator_title_edit)
+ CreatorRoute.BEHAVIOR_MENU -> stringResource(R.string.behaviour_triggers)
+ CreatorRoute.BEHAVIOR_ENGINE -> stringResource(R.string.engine)
+ CreatorRoute.BEHAVIOR_ISLAND -> stringResource(R.string.island_behavior)
+ CreatorRoute.BEHAVIOR_TYPES -> stringResource(R.string.active_notifications_title)
CreatorRoute.COLORS -> stringResource(R.string.creator_nav_colors)
CreatorRoute.ICONS -> stringResource(R.string.creator_nav_icons)
CreatorRoute.CALLS -> stringResource(R.string.creator_nav_calls)
+ CreatorRoute.NAVIGATION -> stringResource(R.string.nav_layout_title)
+ CreatorRoute.REPLY -> stringResource(R.string.inline_reply_title)
CreatorRoute.ACTIONS -> stringResource(R.string.creator_nav_actions)
CreatorRoute.APPS -> stringResource(R.string.creator_nav_apps)
},
@@ -161,14 +196,7 @@ fun ThemeCreatorScreen(
},
navigationIcon = {
FilledTonalIconButton(
- onClick = {
- if (currentRoute != CreatorRoute.MAIN_MENU) {
- currentRoute = CreatorRoute.MAIN_MENU
- } else {
- viewModel.currentEditingThemeId = null
- onBack()
- }
- },
+ onClick = handleBackNavigation,
colors = IconButtonDefaults.filledTonalIconButtonColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHighest)
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
@@ -179,7 +207,7 @@ fun ThemeCreatorScreen(
Button(
onClick = {
if (editThemeId != null && editThemeId == activeThemeId) {
- viewModel.saveTheme(editThemeId)
+ viewModel.saveTheme(existingId = editThemeId, apply = true)
viewModel.currentEditingThemeId = null
onThemeCreated()
} else {
@@ -196,12 +224,16 @@ fun ThemeCreatorScreen(
},
containerColor = MaterialTheme.colorScheme.surface
) { padding ->
- Box(modifier = Modifier.padding(top = padding.calculateTopPadding()).fillMaxSize()) {
+ Box(modifier = Modifier
+ .padding(top = padding.calculateTopPadding())
+ .fillMaxSize()) {
AnimatedContent(
targetState = currentRoute,
transitionSpec = {
- if (targetState == CreatorRoute.MAIN_MENU) slideInHorizontally { -it } + fadeIn() togetherWith slideOutHorizontally { it } + fadeOut()
- else slideInHorizontally { it } + fadeIn() togetherWith slideOutHorizontally { -it } + fadeOut()
+ if (targetState == CreatorRoute.MAIN_MENU || targetState == CreatorRoute.BEHAVIOR_MENU && initialState != CreatorRoute.MAIN_MENU)
+ slideInHorizontally { -it } + fadeIn() togetherWith slideOutHorizontally { it } + fadeOut()
+ else
+ slideInHorizontally { it } + fadeIn() togetherWith slideOutHorizontally { -it } + fadeOut()
},
label = "CreatorNav"
) { route ->
@@ -211,11 +243,32 @@ fun ThemeCreatorScreen(
onNavigate = { currentRoute = it },
onEditSettings = { showSettingsSheet = true }
)
+ CreatorRoute.BEHAVIOR_MENU -> Box(Modifier.fillMaxSize()) {
+ BehaviourMenuContent(onNavigate = { currentRoute = it })
+ }
+ CreatorRoute.BEHAVIOR_ENGINE -> {
+ EngineThemeContent(
+ isNative = isNative,
+ showDefaultOption = false,
+ onEngineChange = { viewModel.setUseNativeLiveUpdates(it ?: false) }
+ )
+ }
+ CreatorRoute.BEHAVIOR_ISLAND -> Box(Modifier.fillMaxSize()) {
+ ThemeBehaviourContent()
+ }
+ CreatorRoute.BEHAVIOR_TYPES -> Box(Modifier.fillMaxSize()) {
+ NotificationTypesContent()
+ }
+ CreatorRoute.NAVIGATION -> Box(Modifier.fillMaxSize()) {
+ NavCustomizationScreen(onBack = { currentRoute = CreatorRoute.MAIN_MENU },null, false)
+ }
+ CreatorRoute.REPLY -> Box(Modifier.fillMaxSize()) {
+ com.d4viddf.hyperbridge.ui.screens.theme.content.ReplyStyleSheetContent(viewModel = viewModel)
+ }
CreatorRoute.COLORS -> DetailScreenShell(
previewContent = {
SharedThemePreview(
highlightColorHex = viewModel.selectedColorHex,
- // [FIX 1] Convert ColorMode Enum to boolean for the preview
useAppColors = (viewModel.colorMode == ColorMode.APP_ICON),
shapeId = viewModel.selectedShapeId,
paddingPercent = viewModel.iconPaddingPercent,
@@ -228,7 +281,6 @@ fun ThemeCreatorScreen(
content = {
ColorsDetailContent(
selectedColorHex = viewModel.selectedColorHex,
- // [FIX 2] Pass ColorMode Enum instead of boolean
colorMode = viewModel.colorMode,
onColorSelected = { viewModel.selectedColorHex = it },
onColorModeChanged = { viewModel.colorMode = it }
@@ -239,7 +291,6 @@ fun ThemeCreatorScreen(
previewContent = {
SharedThemePreview(
highlightColorHex = viewModel.selectedColorHex,
- // [FIX 3] Convert ColorMode Enum to boolean
useAppColors = (viewModel.colorMode == ColorMode.APP_ICON),
shapeId = viewModel.selectedShapeId,
paddingPercent = viewModel.iconPaddingPercent,
@@ -263,7 +314,6 @@ fun ThemeCreatorScreen(
previewContent = {
SharedThemePreview(
highlightColorHex = viewModel.selectedColorHex,
- // [FIX 4] Convert ColorMode Enum to boolean
useAppColors = (viewModel.colorMode == ColorMode.APP_ICON),
shapeId = viewModel.selectedShapeId,
paddingPercent = viewModel.iconPaddingPercent,
@@ -317,16 +367,16 @@ fun SaveDialog(viewModel: ThemeViewModel, editThemeId: String?, activeThemeId: S
confirmButton = {
Button(onClick = {
onDismiss()
- viewModel.saveTheme(editThemeId, apply = true)
- viewModel.currentEditingThemeId = null // Clear state!
+ viewModel.saveTheme(existingId = editThemeId, apply = true)
+ viewModel.currentEditingThemeId = null
onThemeCreated()
}) { Text(stringResource(R.string.creator_dialog_action_save_apply)) }
},
dismissButton = {
TextButton(onClick = {
onDismiss()
- viewModel.saveTheme(editThemeId, apply = false)
- viewModel.currentEditingThemeId = null // Clear state!
+ viewModel.saveTheme(existingId = editThemeId, apply = false)
+ viewModel.currentEditingThemeId = null
onThemeCreated()
}) { Text(stringResource(R.string.creator_dialog_action_save_only)) }
}
@@ -334,13 +384,18 @@ fun SaveDialog(viewModel: ThemeViewModel, editThemeId: String?, activeThemeId: S
}
@Composable
fun CreatorMainList(viewModel: ThemeViewModel, onNavigate: (CreatorRoute) -> Unit, onEditSettings: () -> Unit) {
- Column(modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
- Box(modifier = Modifier.fillMaxWidth().height(200.dp), contentAlignment = Alignment.Center) {
- Surface(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 16.dp), shape = RoundedCornerShape(24.dp), color = MaterialTheme.colorScheme.surfaceContainer) {
+ Column(modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())) {
+ Box(modifier = Modifier
+ .fillMaxWidth()
+ .height(200.dp), contentAlignment = Alignment.Center) {
+ Surface(modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 16.dp), shape = RoundedCornerShape(24.dp), color = MaterialTheme.colorScheme.surfaceContainer) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.padding(vertical = 12.dp)) {
SharedThemePreview(
viewModel.selectedColorHex,
- // [FIX 5] Convert ColorMode Enum to boolean
(viewModel.colorMode == ColorMode.APP_ICON),
viewModel.selectedShapeId, viewModel.iconPaddingPercent,
viewModel.callAnswerColor, viewModel.callDeclineColor, viewModel.callAnswerShapeId, viewModel.callDeclineShapeId
@@ -349,13 +404,26 @@ fun CreatorMainList(viewModel: ThemeViewModel, onNavigate: (CreatorRoute) -> Uni
}
}
- Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
- Button(onClick = onEditSettings, modifier = Modifier.fillMaxWidth().height(52.dp), shape = RoundedCornerShape(24.dp), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer), elevation = ButtonDefaults.buttonElevation(defaultElevation = 0.dp)) {
+ Column(modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)) {
+ Button(onClick = onEditSettings, modifier = Modifier
+ .fillMaxWidth()
+ .height(52.dp), shape = RoundedCornerShape(24.dp), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer), elevation = ButtonDefaults.buttonElevation(defaultElevation = 0.dp)) {
Icon(Icons.Outlined.Edit, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(8.dp)); Text(stringResource(R.string.creator_btn_edit_info))
}
Spacer(Modifier.height(16.dp))
- val menuItems = CreatorRoute.entries.filter { it != CreatorRoute.MAIN_MENU }
+ val menuItems = listOf(
+ CreatorRoute.BEHAVIOR_MENU,
+ CreatorRoute.COLORS,
+ CreatorRoute.ICONS,
+ CreatorRoute.CALLS,
+ CreatorRoute.NAVIGATION,
+ CreatorRoute.REPLY,
+ CreatorRoute.ACTIONS,
+ CreatorRoute.APPS
+ )
Column(
modifier = Modifier.fillMaxWidth(),
@@ -363,15 +431,65 @@ fun CreatorMainList(viewModel: ThemeViewModel, onNavigate: (CreatorRoute) -> Uni
) {
menuItems.forEachIndexed { index, route ->
val shape = getExpressiveShape(menuItems.size, index, ShapeStyle.Large)
- val icon = when(route) { CreatorRoute.COLORS -> Icons.Outlined.ColorLens; CreatorRoute.ICONS -> Icons.Outlined.Widgets; CreatorRoute.CALLS -> Icons.Outlined.Call; CreatorRoute.ACTIONS -> Icons.Outlined.TouchApp; CreatorRoute.APPS -> Icons.Outlined.Apps; else -> Icons.Outlined.Image }
- val title = stringResource(when(route) { CreatorRoute.COLORS -> R.string.creator_nav_colors; CreatorRoute.ICONS -> R.string.creator_nav_icons; CreatorRoute.CALLS -> R.string.creator_nav_calls; CreatorRoute.ACTIONS -> R.string.creator_nav_actions; CreatorRoute.APPS -> R.string.creator_nav_apps; else -> R.string.app_name })
- val sub = stringResource(when(route) { CreatorRoute.COLORS -> R.string.creator_sub_colors; CreatorRoute.ICONS -> R.string.creator_sub_icons; CreatorRoute.CALLS -> R.string.creator_sub_calls; CreatorRoute.ACTIONS -> R.string.creator_sub_actions; CreatorRoute.APPS -> R.string.creator_sub_apps; else -> R.string.app_name })
- val trailing: (@Composable () -> Unit)? = if (route == CreatorRoute.COLORS) {
- { Box(modifier = Modifier.size(28.dp).clip(CircleShape).background(safeParseColor(viewModel.selectedColorHex)).border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape)) }
- } else if (route == CreatorRoute.ICONS) {
- { Icon(Icons.Outlined.Image, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(24.dp)) }
- } else null
+ val icon = when(route) {
+ CreatorRoute.BEHAVIOR_MENU -> Icons.Outlined.DisplaySettings
+ CreatorRoute.COLORS -> Icons.Outlined.ColorLens
+ CreatorRoute.ICONS -> Icons.Outlined.Widgets
+ CreatorRoute.CALLS -> Icons.Outlined.Call
+ CreatorRoute.NAVIGATION -> Icons.Outlined.Map
+ CreatorRoute.REPLY -> Icons.Outlined.Edit
+ CreatorRoute.ACTIONS -> Icons.Outlined.TouchApp
+ CreatorRoute.APPS -> Icons.Outlined.Apps
+ else -> Icons.Outlined.Image
+ }
+ val title = when(route) {
+ CreatorRoute.BEHAVIOR_MENU -> stringResource(R.string.engine)
+ CreatorRoute.COLORS -> stringResource(R.string.creator_nav_colors)
+ CreatorRoute.ICONS -> stringResource(R.string.creator_nav_icons)
+ CreatorRoute.CALLS -> stringResource(R.string.creator_nav_calls)
+ CreatorRoute.NAVIGATION -> stringResource(R.string.nav_layout_title)
+ CreatorRoute.REPLY -> stringResource(R.string.inline_reply_title)
+ CreatorRoute.ACTIONS -> stringResource(R.string.creator_nav_actions)
+ CreatorRoute.APPS -> stringResource(R.string.creator_nav_apps)
+ else -> stringResource(R.string.app_name)
+ }
+ val sub = when(route) {
+ CreatorRoute.BEHAVIOR_MENU -> stringResource(R.string.engine_timeouts_triggers)
+ CreatorRoute.COLORS -> stringResource(R.string.creator_sub_colors)
+ CreatorRoute.ICONS -> stringResource(R.string.creator_sub_icons)
+ CreatorRoute.CALLS -> stringResource(R.string.creator_sub_calls)
+ CreatorRoute.NAVIGATION -> stringResource(R.string.nav_layout_desc)
+ CreatorRoute.REPLY -> stringResource(R.string.customize_inline_reply)
+ CreatorRoute.ACTIONS -> stringResource(R.string.creator_sub_actions)
+ CreatorRoute.APPS -> stringResource(R.string.creator_sub_apps)
+ else -> stringResource(R.string.app_name)
+ }
+
+ val trailing: (@Composable () -> Unit)? = when (route) {
+ CreatorRoute.COLORS -> {
+ {
+ Box(
+ modifier = Modifier
+ .size(28.dp)
+ .clip(CircleShape)
+ .background(safeParseColor(viewModel.selectedColorHex))
+ .border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape)
+ )
+ }
+ }
+ CreatorRoute.ICONS -> {
+ {
+ Icon(
+ Icons.Outlined.Image,
+ null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(24.dp)
+ )
+ }
+ }
+ else -> null
+ }
CreatorOptionCard(title, sub, icon, shape, { onNavigate(route) }, trailing)
}
@@ -383,8 +501,12 @@ fun CreatorMainList(viewModel: ThemeViewModel, onNavigate: (CreatorRoute) -> Uni
@Composable
fun CreatorOptionCard(title: String, subtitle: String, icon: ImageVector, shape: Shape, onClick: () -> Unit, trailingContent: (@Composable () -> Unit)? = null) {
- Card(onClick = onClick, colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), shape = shape, modifier = Modifier.fillMaxWidth().heightIn(min = 88.dp)) {
- Row(modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 16.dp), verticalAlignment = Alignment.CenterVertically) {
+ Card(onClick = onClick, colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), shape = shape, modifier = Modifier
+ .fillMaxWidth()
+ .defaultMinSize(minHeight = 88.dp)) {
+ Row(modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 20.dp, vertical = 16.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(icon, null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.width(20.dp))
Column(modifier = Modifier.weight(1f)) {
@@ -399,12 +521,18 @@ fun CreatorOptionCard(title: String, subtitle: String, icon: ImageVector, shape:
@Composable
fun DetailScreenShell(previewContent: @Composable () -> Unit, content: @Composable () -> Unit) {
Column(modifier = Modifier.fillMaxSize()) {
- Box(modifier = Modifier.fillMaxWidth().height(200.dp), contentAlignment = Alignment.Center) {
- Surface(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 16.dp), shape = RoundedCornerShape(24.dp), color = MaterialTheme.colorScheme.surfaceContainer) {
+ Box(modifier = Modifier
+ .fillMaxWidth()
+ .height(200.dp), contentAlignment = Alignment.Center) {
+ Surface(modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 16.dp), shape = RoundedCornerShape(24.dp), color = MaterialTheme.colorScheme.surfaceContainer) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.padding(vertical = 12.dp)) { previewContent() }
}
}
- Box(modifier = Modifier.weight(1f).fillMaxWidth()) { content() }
+ Box(modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth()) { content() }
}
}
@@ -431,10 +559,18 @@ fun ThemeMetadataSheet(viewModel: ThemeViewModel, onDismiss: () -> Unit) {
}
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
- Column(modifier = Modifier.padding(24.dp).navigationBarsPadding().verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally) {
+ Column(modifier = Modifier
+ .padding(24.dp)
+ .navigationBarsPadding()
+ .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally) {
Text(stringResource(R.string.meta_title), style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.height(32.dp))
- Box(modifier = Modifier.size(120.dp).clip(RoundedCornerShape(24.dp)).background(MaterialTheme.colorScheme.surfaceContainerHigh).border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(24.dp)).clickable { iconLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) }, contentAlignment = Alignment.Center) {
+ Box(modifier = Modifier
+ .size(120.dp)
+ .clip(RoundedCornerShape(24.dp))
+ .background(MaterialTheme.colorScheme.surfaceContainerHigh)
+ .border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(24.dp))
+ .clickable { iconLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) }, contentAlignment = Alignment.Center) {
if (iconBitmap != null) { Image(bitmap = iconBitmap!!, contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
else { Icon(Icons.Outlined.Image, null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(48.dp)) }
}
@@ -442,10 +578,14 @@ fun ThemeMetadataSheet(viewModel: ThemeViewModel, onDismiss: () -> Unit) {
if (viewModel.themeIconUri != null) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
FilledTonalButton(onClick = { viewModel.themeIconUri = null }, modifier = Modifier.size(50.dp), shape = RoundedCornerShape(14.dp), colors = ButtonDefaults.filledTonalButtonColors(containerColor = MaterialTheme.colorScheme.errorContainer, contentColor = MaterialTheme.colorScheme.onErrorContainer), contentPadding = PaddingValues(0.dp)) { Icon(Icons.Rounded.Delete, null, modifier = Modifier.size(20.dp)) }
- Button(onClick = { iconLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) }, modifier = Modifier.weight(1f).height(50.dp), shape = RoundedCornerShape(14.dp), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer)) { Icon(Icons.Rounded.Edit, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(8.dp)); Text(stringResource(R.string.change_icon)) }
+ Button(onClick = { iconLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) }, modifier = Modifier
+ .weight(1f)
+ .height(50.dp), shape = RoundedCornerShape(14.dp), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer)) { Icon(Icons.Rounded.Edit, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(8.dp)); Text(stringResource(R.string.change_icon)) }
}
} else {
- Button(onClick = { iconLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) }, modifier = Modifier.fillMaxWidth().height(50.dp), shape = RoundedCornerShape(14.dp), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer)) { Icon(Icons.Rounded.Image, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(8.dp)); Text(stringResource(R.string.select_icon)) }
+ Button(onClick = { iconLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) }, modifier = Modifier
+ .fillMaxWidth()
+ .height(50.dp), shape = RoundedCornerShape(14.dp), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer)) { Icon(Icons.Rounded.Image, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(8.dp)); Text(stringResource(R.string.select_icon)) }
}
Spacer(Modifier.height(32.dp))
OutlinedTextField(value = viewModel.themeName, onValueChange = { viewModel.themeName = it }, label = { Text(stringResource(R.string.meta_label_name)) }, modifier = Modifier.fillMaxWidth(), singleLine = true, keyboardActions = KeyboardActions(onDone = { fm.clearFocus() }))
@@ -454,7 +594,9 @@ fun ThemeMetadataSheet(viewModel: ThemeViewModel, onDismiss: () -> Unit) {
Spacer(Modifier.height(16.dp))
OutlinedTextField(value = viewModel.themeDescription, onValueChange = { viewModel.themeDescription = it }, label = { Text("Description") }, modifier = Modifier.fillMaxWidth(), minLines = 3, maxLines = 5)
Spacer(Modifier.height(32.dp))
- Button(onClick = onDismiss, modifier = Modifier.fillMaxWidth().height(50.dp), shape = ButtonDefaults.shape) { Text(stringResource(R.string.meta_action_done)) }
+ Button(onClick = onDismiss, modifier = Modifier
+ .fillMaxWidth()
+ .height(50.dp), shape = ButtonDefaults.shape) { Text(stringResource(R.string.meta_action_done)) }
Spacer(Modifier.height(24.dp))
}
}
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/ThemeInstallerActivity.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/ThemeInstallerActivity.kt
index a7ff275..eb5ea86 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/ThemeInstallerActivity.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/ThemeInstallerActivity.kt
@@ -1,7 +1,6 @@
package com.d4viddf.hyperbridge.ui.screens.theme
import android.net.Uri
-import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
@@ -30,8 +29,6 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.darkColorScheme
-import androidx.compose.material3.dynamicDarkColorScheme
-import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -72,9 +69,6 @@ class ThemeInstallerActivity : ComponentActivity() {
val context = LocalContext.current
val colorScheme = when {
- Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
- if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
- }
darkTheme -> darkColorScheme()
else -> lightColorScheme()
}
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/ThemePreviews.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/ThemePreviews.kt
index b619d49..919649f 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/ThemePreviews.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/ThemePreviews.kt
@@ -1,4 +1,4 @@
-package com.d4viddf.hyperbridge.ui.screens.theme.content
+package com.d4viddf.hyperbridge.ui.screens.theme
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
@@ -35,7 +35,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
-import com.d4viddf.hyperbridge.ui.screens.theme.getShapeFromId
+import com.d4viddf.hyperbridge.ui.screens.theme.content.safeParseColor
// --- SHARED PREVIEW CAROUSEL ---
@Composable
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/ThemeShared.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/ThemeShared.kt
index b657152..888b0df 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/ThemeShared.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/ThemeShared.kt
@@ -29,9 +29,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
-import androidx.graphics.shapes.RoundedPolygon
-import android.graphics.Color as AndroidColor
import androidx.core.graphics.toColorInt
+import androidx.graphics.shapes.RoundedPolygon
// --- SHAPES ---
@@ -49,7 +48,7 @@ fun getShapeFromId(id: String): RoundedPolygon {
// --- COLORS ---
fun safeParseColor(hex: String): Color {
- return try { Color(hex.toColorInt()) } catch (e: Exception) { Color.White }
+ return try { Color(hex.toColorInt()) } catch (_: Exception) { Color.White }
}
// --- COMPONENTS ---
diff --git a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/ThemeViewModel.kt b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/ThemeViewModel.kt
index aa79186..c177606 100644
--- a/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/ThemeViewModel.kt
+++ b/app/src/main/java/com/d4viddf/hyperbridge/ui/screens/theme/ThemeViewModel.kt
@@ -38,6 +38,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.util.UUID
+import androidx.core.graphics.scale
class ThemeViewModel(application: Application) : AndroidViewModel(application) {
@@ -69,6 +70,7 @@ class ThemeViewModel(application: Application) : AndroidViewModel(application) {
var selectedColorHex by mutableStateOf("#3DDA82")
var colorMode by mutableStateOf(ColorMode.CUSTOM)
+
var isDarkThemePreview by mutableStateOf(true)
var selectedShapeId by mutableStateOf("circle")
@@ -83,6 +85,9 @@ class ThemeViewModel(application: Application) : AndroidViewModel(application) {
var themeDefaultActions by mutableStateOf
+ Hotfix Update
+ •<b>Crash Fix:</b> Resolved a critical issue that caused the app to crash when opening the settings menu in Traditional Chinese.\n
+ •<b>Under the Hood:</b> General bug fixes and stability improvements to keep your Island running smoothly.
+
+ Hue
+ Saturation
+ Lightness
+ Dynamic Colors
+ Material You (System)
+ Extracts colors from your current wallpaper.
+ Kill Active Widgets
+ Update Notice
+ HyperBridge has been updated! Please ensure that your Autostart and Background Battery permissions haven\'t been silently revoked by the system to keep your widgets running smoothly.
+ Got it
+ Recommended
+ All
+ Favorites
+ Native Live Updates
+ Preview
+ Notification Design
+ Xiaomi Featured Design
+ Modern HyperOS layout with Xiaomi intended design.
+ Live Update Design
+ Native android notification designs, works on CN ROM.
+ Engine Configuration
+ Notification Design
+ Good to know
+ • Navigation layouts will be fully integrated into the Theme Engine in an upcoming update.\n• When using the Native Live Update engine, only the Right Side content is visible.
+ Xiaomi Featured Notifications
+ Island Behavior
+
+ Engine
+ Live Updates vs Xiaomi Island
+ Do Not Disturb
+ Manage island visibility during DND
+ Pause Islands
+ Manually pause all islands until you turn this back on
+ Auto-detect System DND
+ Automatically pause islands when your phone is in Do Not Disturb mode
+
+
+ Global Behavior
+ Auto-hide Island
+ Hides after a set time
+ Visible until manually dismissed
+ Determines how long the island stays on screen before disappearing automatically.
+ Active Triggers
+ Select which global events trigger the island
+ Regular messages, emails, and alerts
+ Ongoing tasks, rendering, and operations
+ Downloads, uploads, and file transfers
+ Music and video playback controls
+ Turn-by-turn directions and ETA
+ Incoming and ongoing phone calls
+ Active timers and stopwatches
+ Select triggered events
+ Override Global Triggers
+ Set custom allowed notifications for this app
+ Trigger Override
+ Select which events trigger the island for this app.
+ Enable specific triggers
+ Open Source Licenses
+ Remove original notification
+ Automatically dismiss the source notification when a Live Update is active.
+ Dismiss with original
+ Island will disappear when you clear the original notification.
+ Enable inline reply
+ Launch the custom inline reply overlay when replying from the island.
+ Note: When enabled, Hyper Bridge cannot respond or mark messages as read. Reply and Mark as Read actions will be hidden.
+ Color Mode
+ Select Color Mode
+ Change color mode
+ Default
+ Use default standard colors
+ App Colors
+ Extract colors from the app icon
+ Material You
+ Match the system wallpaper (Monet)
+ Custom Colors
+ Manually choose your own colors
+ Textfield Color
+ Inner text box background
+ Text Color
+ Reply text color
+ Send Button Color
+ Send icon color
+ Appearance
+ Blur Effect
+ Apply glassmorphism to textfield
+ Textfield Corner Radius
+ Send Button Corner Radius
+ You can change these options later in Settings and Theme Configurator.
+ Add standard Android widgets to your island for quick access.
+ Fully customize colors, shapes and icons of your island.
+ Expand the possibilities with different customization options
+ Determines how the island behaves.
+ Preview of the widget
+ Play Store Crash Fixes & Stability\n
+ ? Theme Creator: Fixed a crash when parsing large sets of custom assets.\n
+ ? Performance: Resolved an ANR (Application Not Responding) issue on the initial Welcome Screen.\n
+ ? Widget Picker: Eliminated crashes and Out-Of-Memory (OOM) errors caused by extremely large widget previews.\n
+ ? UI Layouts: Fixed Jetpack Compose layout constraint exceptions that crashed the app when resizing certain menus.\n
+ ? Backups: Resolved OOM errors when importing large backup files by optimizing JSON parsing.\n
+ ? Warm Starts: Significantly improved app launch speeds and eliminated UI thread blocking on mid-range devices.\n
+ ? Background Service: Completely overhauled the Notification Listener memory architecture, dropping background overhead to 0% and improving battery life.\n
+
+ Performance & Stability Update
+ Fixes & Stability\n
+ WhatsApp Duplicates Fixed: Resolved an issue that caused duplicate WhatsApp notifications to appear.\n
+ Notification Flicker & Crashes Fixed: Fixed a crash caused by receiving multiple notifications in a short timeframe. The app now safely updates notifications normally, eliminating the notification flicker and improving stability.\n
+ Notification Intents Restored: Fixed an issue where clicking a featured notification would incorrectly route to the troubleshooting screen instead of the original app.\n\n
+ Features & Polish\n
+ Remove Island on Dismiss: Added a new option to automatically remove the island if the original system notification is dismissed.\n
+ Featured Notifications Diagnostics: Added a new check screen during Onboarding to verify if your Xiaomi device supports Featured Notifications and if the required permissions are enabled.\n
+ Setup Health Integration: The Featured Notifications permission status is now continuously monitored and displayed inside the Setup Health screen.\n
+ Troubleshooting Screen: Added a dedicated Troubleshooting Screen that guides users on how to enable the necessary hidden system permissions (or use Shizuku) to get islands working on strict Xiaomi ROMs.\n
+ Clearer Terminology: Renamed the \"Live Updates\" section in the settings menu to \"Notification Management\" to better reflect its purpose.\n
+ Settings UI Polish: Restored the missing rounded corners on the \"Permanent Island\" option in the Global Settings list.\n
+ Spanish Translations: Added full Spanish translations for all the new troubleshooting dialogues, setup health checks, and notification management settings.
+
+ Fixes & Refinements
+ CN ROM Support\n
+ • Enable the new Sui & Shizuku workaround to enjoy the Hyper Islands in your devices running HyperOS CN (Chinese) ROMs without native support.\n\n
+ Theme Engine Improvements\n
+ • Engine Selection: You can now choose animation engines per-app or globally.\n
+ • Shizuku Workaround: Toggle added directly to the Theme Editor Engine tab.\n
+ • Behavior Controls: Custom timeouts and popup logic are now fully supported.\n\n
+ Intelligent Permanent Island\n
+ • Always Visible: Keep the island on screen even without active notifications.\n
+ • Auto-Hide: The permanent island now automatically hides itself whenever an active widget is shown on the screen.\n\n
+ Smart Icon Tinting\n
+ • New detection system automatically analyzes extracted notification icons and intelligently tints them white if they are dark or monochrome.\n\n
+ Downloads & Progress\n
+ • New Dedicated Download Layout: Separated download and progress layouts with dedicated visual designs.\n
+ • Text Progress Support: Parsed percentage text (e.g. \"12%\") for app updates even without native progress bars.\n
+ • Smart Filtering: Restricted text-based progress checking to store and installer downloads to prevent false triggers on other status notifications.\n\n
+ Messaging Fixes\n
+ • Fixed missing sender avatars in apps like Telegram (now correctly extracts \"sender_person\" data).\n
+ • Resolved issues with grouped notifications (chat summaries) not triggering islands.\n
+ • Fixed island disappearance bugs caused by notification update race conditions.\n\n
+ System & Onboarding\n
+ • New Onboarding Screen: A brand new and easier setup process for critical permissions.\n
+ • Boot Fix: Service now starts immediately and reliably after restart.\n
+ • Smarter Alerts: Downloads and Media update silently, while messaging updates reliably trigger popup animations.\n
+ • Navigation: Faster and smoother app transitions.
+
+ The Live Update
+ If you are seeing this, something went wrong!
+ If you are seeing this, something went wrong! The system couldn\'t show the widget
+ Troubleshoot
+ Featured Notifications Check
+ Let\'s verify if your system is configured correctly to display HyperBridge islands.
+ Not Supported
+ Enabled
+ Open System Settings
+ Why is this not an island?
+ Your Xiaomi device intercepted the notification but failed to display it as a featured island because the permission is disabled in your system settings.\n\nTo fix this:\n1. Tap \"Open System Settings\"\n2. Scroll down to \"Featured Notifications\" (or similar)\n3. Enable it for HyperBridge.
+ Alternative: Shizuku Override
+ If your system doesn\'t show the setting or it\'s restricted, you can bypass the Xiaomi whitelist entirely by setting up Shizuku. HyperBridge will use Shizuku to post islands directly.
+ HyperBridge Update
+ Migrating database to Device Protected storage…
+ Update Complete
+ Notification Management
+ Inline Reply
+ Customize inline reply overlay
+ Reply...
+ Display over other apps
+ Required to show the custom inline reply overlay.
diff --git a/app/src/main/res/values-sk-rSK/strings.xml b/app/src/main/res/values-sk-rSK/strings.xml
index b8404f5..ee3b8ad 100644
--- a/app/src/main/res/values-sk-rSK/strings.xml
+++ b/app/src/main/res/values-sk-rSK/strings.xml
@@ -7,7 +7,6 @@
Hotovo
Logo aplikácie
Nastavenia
- Informácie
Konfigurácia
Vyčistiť
Zoraď podľa
@@ -16,14 +15,10 @@
Začíname
Ako to funguje
Vyberte si Vaše obľúbené aplikácie (Spotify, Mapy, Časovač). HyperBridge premení ich notifikácie na elegantný HyperIsland.
- Raný vývoj
Poznámka: Táto aplikácia je v Beta vývoji. Môžete naraziť na rôzne chyby alebo nepresné vizuálne informácie.
Kompatibilné zariadenie
Nepodporované zariadenie
Zdá sa, že vaše zariadenie používa systém HyperOS/MIUI. Môžete začať!
- HyperBridge sa spolieha na špecifické funkcie systému HyperOS. Výrobca vášho zariadenia zrejme nie je Xiaomi/POCO/Redmi.
- Systém Detekovaný
- ⚠️ Aplikácia nemusí fungovať
Zobraz ukážku
Potrebujeme povolenie na zverejnenie „Island“ notifikácie na vašej obrazovke.
Povolenie udelené
@@ -31,26 +26,25 @@
Načítať dáta
Aby sme premostili Vaše aplikácie, potrebujeme čítať ich notifikácie (Hudba, Mapy, Časovač).
Otvoriť Nastavenia
- Stále aktívny
HyperOS ukončuje aplikácie na pozadí. Povoľte Autoštart a Žiadne obmedzenia, aby ste udržali HyperIsland aktívny.
1. Povoliť Autoštart
2. Nastavte Žiadne obmedzenia v nastaveniach batérie
Aktívny
Knižnica
- Aktívne Mosty
- Knižnica aplikácií
Vyhľadať aplikácie…
- Zoradiť: A-Z
- Zoradiť: Z-A
Žiadne aktívne mosty
Prejdite do knižnice a povoľte aplikácie.
Žiadne aplikácie nájdené
- O mne
Verzia %s
vytvorené D4vidDfom
Konfigurácia
Nastavenie systému
Oprava povolení & batérie
+
+ Permanent Island
+ Keep the Dynamic Island permanently visible on screen when there are no active notifications.
+ When enabled, an empty Dynamic Island will be shown permanently on your screen. It will automatically hide when real notifications arrive and reappear when all notifications are cleared. It will not be visible in your notification shade.
+ Island Width
O nás
Vývojár
Navštívte d4viddf.com
@@ -59,43 +53,31 @@
Licencie Oper Source
Aplikácia tretích strán
Vytvorené s ❤️ v Španielsku
- Stav aplikácie
Uistite sa, že všetky zaškrtávacie políčka sú zelené, aby aplikácia správne fungovala na pozadí.
Požadované povolenia
Prístup k upozorneniam
Vyžaduje sa čítanie udalostí aplikácie
- Povolenie na zobrazenie cez aplikácie
Optimalizácia zariadenia
Automatické spustenie Xiaomi
- Povoliť spustenie aplikácie pri reštartovaní
Žiadne obmedzenia batérie
Zabrániť systému ukončovať aplikácie
Poznámka: Aplikáciu môžete tiež uzamknúť v prehľadnom menu „Nedávne“.
Aktívny
- Vyžaduje sa akcia
Všetko
Hudba
Navigácia
Produktivita
Ostatné
- Vybrať aktívne upozornenia
Správy & Všeobecné
- Stiahnutia & Priebeh
+ Progress & Tasks
+ Downloads
Hudba & Médiá
Mapy & GPS
Hovory
Časovače & Budíky
- Aktívny
Sťahovanie dokončené
- Spracováva sa…
- Dokončené
Sťahovanie
- Čaká sa…
- Práve sa prehráva
- Neznámy volajúci
- Hovor
Mapy
- Časovač
- stiahnuté
- dokončené
@@ -105,16 +87,8 @@
- úspešný
- úspešne
- Sťahovanie dokončené
- Sťahujem
- Spracováva sa…
- Ukončené
Práve sa prehráva
- Prichádzajúci hovor
- Neznámy volajúci
- Mapy
Časovač
- Stopky
Čo je nové v %s
Úžasné!
Správanie Islandu
@@ -128,20 +102,16 @@
Definujte, ktoré typy (napr. Hovory) sú dôležitejšie.
Poradie Priorít
Vysoká priorita (Najvyššia) nahrádza Nízku prioritu (Spodná).
- Posunúť nahor
- Posunúť nadol
Nastavte poradie aplikácií
Globálne nastavenia
Vzhľad Islandu
Vyskakovacie okno s upozornením
Krátko zobrazí celý notifikačný banner pred minimalizáciou Islandu.
+ How long the heads-up popup stays visible.
Panel oznámení
Udržuje upozornenie viditeľné v paneli oznámení. Ak je vypnuté, zobrazí sa iba ako Ostrov.
- Oneskorenie automatického zatvorenia
%ds
Použiť globálne predvolené nastavenia
- Prispôsobiť
- Prepísať nastavenia
- arrival
- arrive
@@ -149,7 +119,6 @@
- reaching
- destination
- Možnosti
História verzií
Informácia o aktualizácii
• <b>Pokročilý pokrok:</b> Pridaná podpora pre „Postup krokov“ (napr. 1 z 3) a indikátory priebehu s ikonami vo vnútri panela upozornení.\n
@@ -168,10 +137,7 @@
Novinka: Ovládajte Ostrov
HyperBridge teraz dokáže spravovať správanie viacerých upozornení po dosiahnutí limitu 9 ostrovov.\n\n<b>Predvolené: Najnovšie</b>\nNové oznámenia automaticky nahradia tie najstaršie.
- Ponechať predvolené
- Konfigurovať teraz
NOVÝ
- Vlastný dizajn
Rozloženie navigácie
Vyberte, kde sa informácie zobrazia
Ľavá strana
@@ -182,8 +148,6 @@
Vzdialenosť & Čas
Prázdny
Aktívne Ostrovy
- Prepnúť %s
- Konfigurovať %s
Povoliť %s upozornenia
Zakázať %s upozornenia
Upraviť rozloženie navigácie
@@ -196,8 +160,6 @@
Náhľad navigácie: %1$s naľavo, %2$s napravo
Rozbaliť podrobnosti o verzii
Zbaliť podrobnosti verzie
- Nastavenia konfigurácie
- O informáciách
Otočiť doprava
200m
10:30
@@ -206,7 +168,6 @@
Pridať slovo (napr. Spoiler)
Pridať
Odstrániť
- Klepnutím na aplikáciu nakonfigurujete konkrétne pravidlá blokovania.
Spravujte filtre a ochranu pred spoilermi
Typy oznámení
%d aktívny
@@ -218,18 +179,18 @@
%d zablokovaných výrazov
Žiadne aktívne pravidlá
Kompatibilita systému
- Zariadenie: %s
- OS: %s
Vyžaduje HyperOS 3.0+ (Android 15+)
Vyžaduje sa zariadenie Xiaomi/Redmi/Poco
- Automatické spustenie na pozadí
- Vyžaduje sa na reštartovanie služby po reštarte.
- Povolenie
- Kompatibilné
- Nekompatibilné
- Neznáme
Zistená čínska ROM
Zdá sa, že vaše zariadenie používa čínsky firmvér. Xiaomi výrazne upravuje vnútorné funkcie upozornení na CN ROM. Funkcie môžu byť nestabilné alebo nemusia vôbec fungovať.
+ Enable Shizuku Workaround
+ Enable Workaround
+ If you are on a CN ROM, you must enable the Shizuku Workaround for notifications to trigger correctly.
+ Shizuku is running
+ Permission Granted
+ Permission Denied
+ Grant Shizuku Permission
+ Shizuku app is not installed or running. Please install and start Shizuku first.
Verzia systému
Prichádzajúci hovor
Aktívny hovor
@@ -289,8 +250,6 @@
Export zlyhal: %s
Dáta boli úspešne obnovené
Import zlyhal: %s
- Neplatný záložný súbor HyperBridge
- Upozornenie: Záloha z inej verzie databázy
Stav nie je možné overiť automaticky.\nSkontrolujte to manuálne, aby ste sa uistili, že služba funguje správne
Informácie o zálohe
Dátum
@@ -300,7 +259,6 @@
(Prázdny)
Obnoviť vybrané údaje
Optimalizácia zariadenia
- Nastaviť „Žiadne obmedzenia“
Najprv offline
Nie je potrebné pripojenie na internet.
Zálohovanie & Aktualizácia Polyglot
@@ -371,16 +329,13 @@
Odinštalované
Nastavenia
Uložené widgety
- Nový widget
Nenašli sa žiadne uložené widgety.
Widget #%d
Zobraziť na ostrove
- Importovať tému
Analýza balíka tém...
Téma pripravená
Vytvorené používateľom %s
Verzia %d
- Nebol poskytnutý žiadny popis.
Chcete teraz použiť túto tému na HyperBridge?
Použiť tému
Uložiť na neskôr
@@ -388,7 +343,6 @@
Nepodarilo sa overiť súbor s témou.
Zavrieť
Moje témy
- Navigácia späť
Pridať novú tému
od %s
Aktívna téma
@@ -396,16 +350,8 @@
Predvolené nastavenie systému
Štandardný vzhľad
Pridať novú tému
- Nová téma
- Názov témy
- Meno autora
- Farba zvýraznenia
- Náhľad
- Uložiť tému
- Prosím, zadajte názov a autora
Vytvoriť novú tému
Vyhľadať
- Importovať zo súboru
Import
Upraviť
Export
@@ -433,8 +379,6 @@
Vlastná farba
Pridať vlastnú farbu
Vyberte farbu
- Vyberte si vlastnú farbu pre svoju tému.
- Hex kód
Hotovo
Štýl
Ikony
@@ -455,24 +399,7 @@
Ďatelina
Odpoveď
Zamietnuť
- Hex farba
- Nakonfigurované aplikácie
Žiadne nakonfigurované aplikácie
- Konfigurovať aplikáciu
- Vyberte aplikáciu
- Aplikácia
- Farba zvýraznenia (hex)
- Vlastné akcie
- Kľúčové slovo
- Tlačidlo obsahujúce \'%s\'
- Uložiť konfiguráciu
- Predvolená farba
-
- - %d akcie
- - %d akcie
- - %d akcia
- - %d akcie
-
Prispôsobenie
Naučte sa, ako vytvárať témy
Pro Funkcie
@@ -488,7 +415,6 @@
Systémový widget (beta verzia)
Získaj témy
Pridaj svoj prvý widget
- %d Aktívny
Dizajn
Globálne akcie
Štýlové tlačidlá podľa kľúčového slova
@@ -557,4 +483,172 @@
Poznámka: Toto vydanie obsahuje významné architektonické zmeny. Prosím, nahláste akékoľvek chyby na GitHub!
+ Hotfix Update
+ •<b>Crash Fix:</b> Resolved a critical issue that caused the app to crash when opening the settings menu in Traditional Chinese.\n
+ •<b>Under the Hood:</b> General bug fixes and stability improvements to keep your Island running smoothly.
+
+ Hue
+ Saturation
+ Lightness
+ Dynamic Colors
+ Material You (System)
+ Extracts colors from your current wallpaper.
+ Kill Active Widgets
+ Update Notice
+ HyperBridge has been updated! Please ensure that your Autostart and Background Battery permissions haven\'t been silently revoked by the system to keep your widgets running smoothly.
+ Got it
+ Recommended
+ All
+ Favorites
+ Native Live Updates
+ Preview
+ Notification Design
+ Xiaomi Featured Design
+ Modern HyperOS layout with Xiaomi intended design.
+ Live Update Design
+ Native android notification designs, works on CN ROM.
+ Engine Configuration
+ Notification Design
+ Good to know
+ • Navigation layouts will be fully integrated into the Theme Engine in an upcoming update.\n• When using the Native Live Update engine, only the Right Side content is visible.
+ Xiaomi Featured Notifications
+ Island Behavior
+
+ Engine
+ Live Updates vs Xiaomi Island
+ Do Not Disturb
+ Manage island visibility during DND
+ Pause Islands
+ Manually pause all islands until you turn this back on
+ Auto-detect System DND
+ Automatically pause islands when your phone is in Do Not Disturb mode
+
+
+ Global Behavior
+ Auto-hide Island
+ Hides after a set time
+ Visible until manually dismissed
+ Determines how long the island stays on screen before disappearing automatically.
+ Active Triggers
+ Select which global events trigger the island
+ Regular messages, emails, and alerts
+ Ongoing tasks, rendering, and operations
+ Downloads, uploads, and file transfers
+ Music and video playback controls
+ Turn-by-turn directions and ETA
+ Incoming and ongoing phone calls
+ Active timers and stopwatches
+ Select triggered events
+ Override Global Triggers
+ Set custom allowed notifications for this app
+ Trigger Override
+ Select which events trigger the island for this app.
+ Enable specific triggers
+ Open Source Licenses
+ Remove original notification
+ Automatically dismiss the source notification when a Live Update is active.
+ Dismiss with original
+ Island will disappear when you clear the original notification.
+ Enable inline reply
+ Launch the custom inline reply overlay when replying from the island.
+ Note: When enabled, Hyper Bridge cannot respond or mark messages as read. Reply and Mark as Read actions will be hidden.
+ Color Mode
+ Select Color Mode
+ Change color mode
+ Default
+ Use default standard colors
+ App Colors
+ Extract colors from the app icon
+ Material You
+ Match the system wallpaper (Monet)
+ Custom Colors
+ Manually choose your own colors
+ Textfield Color
+ Inner text box background
+ Text Color
+ Reply text color
+ Send Button Color
+ Send icon color
+ Appearance
+ Blur Effect
+ Apply glassmorphism to textfield
+ Textfield Corner Radius
+ Send Button Corner Radius
+ You can change these options later in Settings and Theme Configurator.
+ Add standard Android widgets to your island for quick access.
+ Fully customize colors, shapes and icons of your island.
+ Expand the possibilities with different customization options
+ Determines how the island behaves.
+ Preview of the widget
+ Play Store Crash Fixes & Stability\n
+ ? Theme Creator: Fixed a crash when parsing large sets of custom assets.\n
+ ? Performance: Resolved an ANR (Application Not Responding) issue on the initial Welcome Screen.\n
+ ? Widget Picker: Eliminated crashes and Out-Of-Memory (OOM) errors caused by extremely large widget previews.\n
+ ? UI Layouts: Fixed Jetpack Compose layout constraint exceptions that crashed the app when resizing certain menus.\n
+ ? Backups: Resolved OOM errors when importing large backup files by optimizing JSON parsing.\n
+ ? Warm Starts: Significantly improved app launch speeds and eliminated UI thread blocking on mid-range devices.\n
+ ? Background Service: Completely overhauled the Notification Listener memory architecture, dropping background overhead to 0% and improving battery life.\n
+
+ Performance & Stability Update
+ Fixes & Stability\n
+ WhatsApp Duplicates Fixed: Resolved an issue that caused duplicate WhatsApp notifications to appear.\n
+ Notification Flicker & Crashes Fixed: Fixed a crash caused by receiving multiple notifications in a short timeframe. The app now safely updates notifications normally, eliminating the notification flicker and improving stability.\n
+ Notification Intents Restored: Fixed an issue where clicking a featured notification would incorrectly route to the troubleshooting screen instead of the original app.\n\n
+ Features & Polish\n
+ Remove Island on Dismiss: Added a new option to automatically remove the island if the original system notification is dismissed.\n
+ Featured Notifications Diagnostics: Added a new check screen during Onboarding to verify if your Xiaomi device supports Featured Notifications and if the required permissions are enabled.\n
+ Setup Health Integration: The Featured Notifications permission status is now continuously monitored and displayed inside the Setup Health screen.\n
+ Troubleshooting Screen: Added a dedicated Troubleshooting Screen that guides users on how to enable the necessary hidden system permissions (or use Shizuku) to get islands working on strict Xiaomi ROMs.\n
+ Clearer Terminology: Renamed the \"Live Updates\" section in the settings menu to \"Notification Management\" to better reflect its purpose.\n
+ Settings UI Polish: Restored the missing rounded corners on the \"Permanent Island\" option in the Global Settings list.\n
+ Spanish Translations: Added full Spanish translations for all the new troubleshooting dialogues, setup health checks, and notification management settings.
+
+ Fixes & Refinements
+ CN ROM Support\n
+ • Enable the new Sui & Shizuku workaround to enjoy the Hyper Islands in your devices running HyperOS CN (Chinese) ROMs without native support.\n\n
+ Theme Engine Improvements\n
+ • Engine Selection: You can now choose animation engines per-app or globally.\n
+ • Shizuku Workaround: Toggle added directly to the Theme Editor Engine tab.\n
+ • Behavior Controls: Custom timeouts and popup logic are now fully supported.\n\n
+ Intelligent Permanent Island\n
+ • Always Visible: Keep the island on screen even without active notifications.\n
+ • Auto-Hide: The permanent island now automatically hides itself whenever an active widget is shown on the screen.\n\n
+ Smart Icon Tinting\n
+ • New detection system automatically analyzes extracted notification icons and intelligently tints them white if they are dark or monochrome.\n\n
+ Downloads & Progress\n
+ • New Dedicated Download Layout: Separated download and progress layouts with dedicated visual designs.\n
+ • Text Progress Support: Parsed percentage text (e.g. \"12%\") for app updates even without native progress bars.\n
+ • Smart Filtering: Restricted text-based progress checking to store and installer downloads to prevent false triggers on other status notifications.\n\n
+ Messaging Fixes\n
+ • Fixed missing sender avatars in apps like Telegram (now correctly extracts \"sender_person\" data).\n
+ • Resolved issues with grouped notifications (chat summaries) not triggering islands.\n
+ • Fixed island disappearance bugs caused by notification update race conditions.\n\n
+ System & Onboarding\n
+ • New Onboarding Screen: A brand new and easier setup process for critical permissions.\n
+ • Boot Fix: Service now starts immediately and reliably after restart.\n
+ • Smarter Alerts: Downloads and Media update silently, while messaging updates reliably trigger popup animations.\n
+ • Navigation: Faster and smoother app transitions.
+
+ The Live Update
+ If you are seeing this, something went wrong!
+ If you are seeing this, something went wrong! The system couldn\'t show the widget
+ Troubleshoot
+ Featured Notifications Check
+ Let\'s verify if your system is configured correctly to display HyperBridge islands.
+ Not Supported
+ Enabled
+ Open System Settings
+ Why is this not an island?
+ Your Xiaomi device intercepted the notification but failed to display it as a featured island because the permission is disabled in your system settings.\n\nTo fix this:\n1. Tap \"Open System Settings\"\n2. Scroll down to \"Featured Notifications\" (or similar)\n3. Enable it for HyperBridge.
+ Alternative: Shizuku Override
+ If your system doesn\'t show the setting or it\'s restricted, you can bypass the Xiaomi whitelist entirely by setting up Shizuku. HyperBridge will use Shizuku to post islands directly.
+ HyperBridge Update
+ Migrating database to Device Protected storage…
+ Update Complete
+ Notification Management
+ Inline Reply
+ Customize inline reply overlay
+ Reply...
+ Display over other apps
+ Required to show the custom inline reply overlay.
diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml
index 2387c9d..87430e0 100644
--- a/app/src/main/res/values-tr-rTR/strings.xml
+++ b/app/src/main/res/values-tr-rTR/strings.xml
@@ -7,7 +7,6 @@
Done
App Logo
Settings
- Info
Configure
Clear
Sort Order
@@ -16,14 +15,10 @@
Get Started
How it Works
Select your favorite apps (Spotify, Maps, Timer). HyperBridge transforms their notifications into a sleek HyperIsland.
- Early Development
Note: This app is in Beta. You may encounter bugs or incorrect visual information.
Device Compatible
Unsupported Device
Your device appears to be running HyperOS/MIUI. You are good to go!
- HyperBridge relies on specific HyperOS system features. Your device manufacturer does not appear to be Xiaomi/POCO/Redmi.
- ✅ System Detected
- ⚠️ App may not work
Show Island
We need permission to post the \'Island\' notification on your screen.
Permission Granted
@@ -31,26 +26,25 @@
Read Data
To bridge your apps, we need to read their notifications (Music, Maps, Timers).
Open Settings
- Keep Alive
HyperOS kills background apps. Enable Autostart and No Restrictions to keep the island active.
1. Enable Autostart
2. Set Battery \'No Restrictions\'
Active
Library
- Active Bridges
- App Library
Search apps…
- Sort: A-Z
- Sort: Z-A
No Active Bridges
Go to Library to enable apps.
No apps found
- About
Version %s
by D4vidDf
Configuration
System Setup
Fix permissions & battery
+
+ Permanent Island
+ Keep the Dynamic Island permanently visible on screen when there are no active notifications.
+ When enabled, an empty Dynamic Island will be shown permanently on your screen. It will automatically hide when real notifications arrive and reappear when all notifications are cleared. It will not be visible in your notification shade.
+ Island Width
About
Developer
Visit d4viddf.com
@@ -59,43 +53,31 @@
Open Source Licenses
Third-party software
Made with ❤️ in Spain
- App Health
Ensure all checks are green for the app to work correctly in the background.
Required Permissions
Notification Access
Required to read app events
- Permission to display over apps
Device Optimization
Xiaomi Autostart
- Allow app to start on boot
Battery Unrestricted
Prevent system killing app
Note: Also lock the app in the \'Recents\' overview menu.
Active
- Action Needed
All
Music
Navigation
Productivity
Other
- Select active notifications
Messages & General
- Downloads & Progress
+ Progress & Tasks
+ Downloads
Music & Media
Maps & GPS
Calls
Timers & Alarms
- Active
Download Complete
- Pending…
- Finished
Downloading
- Waiting…
- Now Playing
- Unknown Caller
- Call
Maps
- Timer
- downloaded
- completed
@@ -105,16 +87,8 @@
- successful
- succeeded
- Download Complete
- Downloading
- Pending…
- Finished
Now Playing
- Incoming Call
- Unknown Caller
- Maps
Timer
- Stopwatch
What\'s New in %s
Awesome!
Island Behavior
@@ -128,20 +102,16 @@
Define which types (e.g. Calls) are more important.
Priority Order
High priority (Top) replaces Low priority (Bottom).
- Move Up
- Move Down
Configure apps order
Global Settings
Island Appearance
Heads-up Popup
Show the full notification banner briefly before minimizing to the Island.
+ How long the heads-up popup stays visible.
Notification Panel
Keep the notification visible in the notification panel. If disabled, it only appears as an Island.
- Auto-Close delay
%ds
Use Global Defaults
- Customize
- Override Settings
- arrival
- arrive
@@ -149,7 +119,6 @@
- reaching
- destination
- Options
Version History
The Information Update
• <b>Advanced Progress:</b> Added support for \"Step Progress\" (e.g. 1 of 3) and progress bars with icons inside the notification shade.\n
@@ -168,10 +137,7 @@
New: Control the Island
HyperBridge can now manage how multiple notifications behave when the 9-island limit is reached.\n\n<b>Default: Most Recent</b>\nNew notifications replace the oldest ones automatically.
- Keep Default
- Configure Now
NEW
- Custom Design
Navigation Layout
Choose where information appears
Left Side
@@ -182,8 +148,6 @@
Dist. & Time
Empty
Active Islands
- Toggle %s
- Configure %s
Enable %s notifications
Disable %s notifications
Edit navigation layout
@@ -196,8 +160,6 @@
Navigation Preview: %1$s on left, %2$s on right
Expand version details
Collapse version details
- Configuration Settings
- About Information
Turn Right
200m
10:30
@@ -206,7 +168,6 @@
Add word (e.g. Spoiler)
Add
Remove
- Tap an app to configure specific block rules.
Manage spoiler protection and filters
Notification Types
%d active
@@ -218,18 +179,18 @@
%d terms blocked
No active rules
System Compatibility
- Device: %s
- OS: %s
Requires HyperOS 3.0+ (Android 15+)
Xiaomi/Redmi/Poco device required
- Background Autostart
- Required for the service to restart after reboot.
- Grant
- Compatible
- Incompatible
- Unknown
China ROM Detected
Your device appears to be running a Chinese firmware. Xiaomi modifies notification internals significantly on CN ROMs. Features might be unstable or not work at all.
+ Enable Shizuku Workaround
+ Enable Workaround
+ If you are on a CN ROM, you must enable the Shizuku Workaround for notifications to trigger correctly.
+ Shizuku is running
+ Permission Granted
+ Permission Denied
+ Grant Shizuku Permission
+ Shizuku app is not installed or running. Please install and start Shizuku first.
System Version
Incoming Call
Active Call
@@ -289,8 +250,6 @@
Export failed: %s
Data restored successfully
Import failed: %s
- Invalid HyperBridge backup file
- Warning: Backup from a different database version
Status cannot be verified automatically.\nPlease check this manually to ensure the service works proprely
Backup Information
Date
@@ -300,7 +259,6 @@
(Empty)
Restore Selected Data
Device Optimization
- Set \"No Restrictions\"
Offline First
No internet connection required.
Backup & Polyglot Update
@@ -365,16 +323,13 @@
Uninstalled
Settings
Saved Widgets
- New Widget
No saved widgets found.
Widget #%d
Show on Island
- Import Theme
Analyzing theme package...
Theme Ready
Created by %s
Version %d
- No description provided.
Do you want to apply this theme to HyperBridge now?
Apply Theme
Save for Later
@@ -382,7 +337,6 @@
Could not verify the theme file.
Close
My Themes
- Navigate Back
Add New Theme
by %s
Active Theme
@@ -390,16 +344,8 @@
System Default
Standard Look
Add New Theme
- New Theme
- Theme Name
- Author Name
- Highlight Color
- Preview
- Save Theme
- Please enter a name and author
Create New Theme
Search
- Import from File
Import
Edit
Export
@@ -427,8 +373,6 @@
Custom Color
Add Custom Color
Pick a Color
- Select a custom color for your theme.
- Hex Code
Done
Style
Icons
@@ -449,22 +393,7 @@
Clover
Answer
Decline
- Hex Color
- Configured Apps
No apps configured
- Configure App
- Select App
- App
- Highlight Color (Hex)
- Custom Actions
- Keyword
- Button containing \'%s\'
- Save Configuration
- Default Color
-
- - %d actions
- - %d action
-
Customization
Learn how to create themes
Pro Features
@@ -480,7 +409,6 @@
System Widget (Beta)
Get Themes
Add your first Widget
- %d Active
Design
Global Actions
Style buttons by keyword
@@ -547,4 +475,172 @@
Note: This release includes significant architectural changes. Please report any bugs on GitHub!
+ Hotfix Update
+ •<b>Crash Fix:</b> Resolved a critical issue that caused the app to crash when opening the settings menu in Traditional Chinese.\n
+ •<b>Under the Hood:</b> General bug fixes and stability improvements to keep your Island running smoothly.
+
+ Hue
+ Saturation
+ Lightness
+ Dynamic Colors
+ Material You (System)
+ Extracts colors from your current wallpaper.
+ Kill Active Widgets
+ Update Notice
+ HyperBridge has been updated! Please ensure that your Autostart and Background Battery permissions haven\'t been silently revoked by the system to keep your widgets running smoothly.
+ Got it
+ Recommended
+ All
+ Favorites
+ Native Live Updates
+ Preview
+ Notification Design
+ Xiaomi Featured Design
+ Modern HyperOS layout with Xiaomi intended design.
+ Live Update Design
+ Native android notification designs, works on CN ROM.
+ Engine Configuration
+ Notification Design
+ Good to know
+ • Navigation layouts will be fully integrated into the Theme Engine in an upcoming update.\n• When using the Native Live Update engine, only the Right Side content is visible.
+ Xiaomi Featured Notifications
+ Island Behavior
+
+ Engine
+ Live Updates vs Xiaomi Island
+ Do Not Disturb
+ Manage island visibility during DND
+ Pause Islands
+ Manually pause all islands until you turn this back on
+ Auto-detect System DND
+ Automatically pause islands when your phone is in Do Not Disturb mode
+
+
+ Global Behavior
+ Auto-hide Island
+ Hides after a set time
+ Visible until manually dismissed
+ Determines how long the island stays on screen before disappearing automatically.
+ Active Triggers
+ Select which global events trigger the island
+ Regular messages, emails, and alerts
+ Ongoing tasks, rendering, and operations
+ Downloads, uploads, and file transfers
+ Music and video playback controls
+ Turn-by-turn directions and ETA
+ Incoming and ongoing phone calls
+ Active timers and stopwatches
+ Select triggered events
+ Override Global Triggers
+ Set custom allowed notifications for this app
+ Trigger Override
+ Select which events trigger the island for this app.
+ Enable specific triggers
+ Open Source Licenses
+ Remove original notification
+ Automatically dismiss the source notification when a Live Update is active.
+ Dismiss with original
+ Island will disappear when you clear the original notification.
+ Enable inline reply
+ Launch the custom inline reply overlay when replying from the island.
+ Note: When enabled, Hyper Bridge cannot respond or mark messages as read. Reply and Mark as Read actions will be hidden.
+ Color Mode
+ Select Color Mode
+ Change color mode
+ Default
+ Use default standard colors
+ App Colors
+ Extract colors from the app icon
+ Material You
+ Match the system wallpaper (Monet)
+ Custom Colors
+ Manually choose your own colors
+ Textfield Color
+ Inner text box background
+ Text Color
+ Reply text color
+ Send Button Color
+ Send icon color
+ Appearance
+ Blur Effect
+ Apply glassmorphism to textfield
+ Textfield Corner Radius
+ Send Button Corner Radius
+ You can change these options later in Settings and Theme Configurator.
+ Add standard Android widgets to your island for quick access.
+ Fully customize colors, shapes and icons of your island.
+ Expand the possibilities with different customization options
+ Determines how the island behaves.
+ Preview of the widget
+ Play Store Crash Fixes & Stability\n
+ ? Theme Creator: Fixed a crash when parsing large sets of custom assets.\n
+ ? Performance: Resolved an ANR (Application Not Responding) issue on the initial Welcome Screen.\n
+ ? Widget Picker: Eliminated crashes and Out-Of-Memory (OOM) errors caused by extremely large widget previews.\n
+ ? UI Layouts: Fixed Jetpack Compose layout constraint exceptions that crashed the app when resizing certain menus.\n
+ ? Backups: Resolved OOM errors when importing large backup files by optimizing JSON parsing.\n
+ ? Warm Starts: Significantly improved app launch speeds and eliminated UI thread blocking on mid-range devices.\n
+ ? Background Service: Completely overhauled the Notification Listener memory architecture, dropping background overhead to 0% and improving battery life.\n
+
+ Performance & Stability Update
+ Fixes & Stability\n
+ WhatsApp Duplicates Fixed: Resolved an issue that caused duplicate WhatsApp notifications to appear.\n
+ Notification Flicker & Crashes Fixed: Fixed a crash caused by receiving multiple notifications in a short timeframe. The app now safely updates notifications normally, eliminating the notification flicker and improving stability.\n
+ Notification Intents Restored: Fixed an issue where clicking a featured notification would incorrectly route to the troubleshooting screen instead of the original app.\n\n
+ Features & Polish\n
+ Remove Island on Dismiss: Added a new option to automatically remove the island if the original system notification is dismissed.\n
+ Featured Notifications Diagnostics: Added a new check screen during Onboarding to verify if your Xiaomi device supports Featured Notifications and if the required permissions are enabled.\n
+ Setup Health Integration: The Featured Notifications permission status is now continuously monitored and displayed inside the Setup Health screen.\n
+ Troubleshooting Screen: Added a dedicated Troubleshooting Screen that guides users on how to enable the necessary hidden system permissions (or use Shizuku) to get islands working on strict Xiaomi ROMs.\n
+ Clearer Terminology: Renamed the \"Live Updates\" section in the settings menu to \"Notification Management\" to better reflect its purpose.\n
+ Settings UI Polish: Restored the missing rounded corners on the \"Permanent Island\" option in the Global Settings list.\n
+ Spanish Translations: Added full Spanish translations for all the new troubleshooting dialogues, setup health checks, and notification management settings.
+
+ Fixes & Refinements
+ CN ROM Support\n
+ • Enable the new Sui & Shizuku workaround to enjoy the Hyper Islands in your devices running HyperOS CN (Chinese) ROMs without native support.\n\n
+ Theme Engine Improvements\n
+ • Engine Selection: You can now choose animation engines per-app or globally.\n
+ • Shizuku Workaround: Toggle added directly to the Theme Editor Engine tab.\n
+ • Behavior Controls: Custom timeouts and popup logic are now fully supported.\n\n
+ Intelligent Permanent Island\n
+ • Always Visible: Keep the island on screen even without active notifications.\n
+ • Auto-Hide: The permanent island now automatically hides itself whenever an active widget is shown on the screen.\n\n
+ Smart Icon Tinting\n
+ • New detection system automatically analyzes extracted notification icons and intelligently tints them white if they are dark or monochrome.\n\n
+ Downloads & Progress\n
+ • New Dedicated Download Layout: Separated download and progress layouts with dedicated visual designs.\n
+ • Text Progress Support: Parsed percentage text (e.g. \"12%\") for app updates even without native progress bars.\n
+ • Smart Filtering: Restricted text-based progress checking to store and installer downloads to prevent false triggers on other status notifications.\n\n
+ Messaging Fixes\n
+ • Fixed missing sender avatars in apps like Telegram (now correctly extracts \"sender_person\" data).\n
+ • Resolved issues with grouped notifications (chat summaries) not triggering islands.\n
+ • Fixed island disappearance bugs caused by notification update race conditions.\n\n
+ System & Onboarding\n
+ • New Onboarding Screen: A brand new and easier setup process for critical permissions.\n
+ • Boot Fix: Service now starts immediately and reliably after restart.\n
+ • Smarter Alerts: Downloads and Media update silently, while messaging updates reliably trigger popup animations.\n
+ • Navigation: Faster and smoother app transitions.
+
+ The Live Update
+ If you are seeing this, something went wrong!
+ If you are seeing this, something went wrong! The system couldn\'t show the widget
+ Troubleshoot
+ Featured Notifications Check
+ Let\'s verify if your system is configured correctly to display HyperBridge islands.
+ Not Supported
+ Enabled
+ Open System Settings
+ Why is this not an island?
+ Your Xiaomi device intercepted the notification but failed to display it as a featured island because the permission is disabled in your system settings.\n\nTo fix this:\n1. Tap \"Open System Settings\"\n2. Scroll down to \"Featured Notifications\" (or similar)\n3. Enable it for HyperBridge.
+ Alternative: Shizuku Override
+ If your system doesn\'t show the setting or it\'s restricted, you can bypass the Xiaomi whitelist entirely by setting up Shizuku. HyperBridge will use Shizuku to post islands directly.
+ HyperBridge Update
+ Migrating database to Device Protected storage…
+ Update Complete
+ Notification Management
+ Inline Reply
+ Customize inline reply overlay
+ Reply...
+ Display over other apps
+ Required to show the custom inline reply overlay.
diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml
index 34a08b1..4b551ba 100644
--- a/app/src/main/res/values-uk-rUA/strings.xml
+++ b/app/src/main/res/values-uk-rUA/strings.xml
@@ -7,7 +7,6 @@
Готово
Логотип застосунку
Налаштування
- Інформація
Налаштувати
Очистити
Порядок сортування
@@ -16,14 +15,10 @@
Розпочати
Як це працює
Виберіть улюблені застосунки (Spotify, Карти, Таймер). HyperBridge перетворить їхні сповіщення на елегантний HyperIsland.
- Рання розробка
Примітка: Ця програма в бета-версії. Ви можете натрапити на помилки або некоректне візуальне відображення.
Сумісний пристрій
Пристрій не підтримується
Здається, ваш пристрій працює на HyperOS/MIUI. Все готово до роботи!
- HyperBridge покладається на специфічні функції системи HyperOS. Здається, виробник вашого пристрою не Xiaomi/POCO/Redmi.
- ✅ Систему виявлено
- ⚠️ Застосунок може не працювати
Показати Острів
Нам потрібен дозвіл, щоб розмістити \'Острів\' сповіщення на вашому екрані.
✅ Дозвіл надано
@@ -31,26 +26,25 @@
Читання даних
Щоб при\'єднати ваші застосунки, нам потрібно читати їхні сповіщення (Музика, Карти, Таймери).
Відкрити налаштування
- Підтримувати роботу
HyperOS зупиняє фонові програми. Увімкніть Автозапуск та \"Жодних обмежень\", щоб острів залишався активним.
1. Увімкнути автозапуск
2. Економія енергії: \"Жодних обмежень\"
Активні
Бібліотека
- Активні мости
- Бібліотека застосунків
Search apps…
- Сорт: А-Я
- Сорт: Я-А
Немає активних мостів
Перейдіть до бібліотеки, щоб увімкнути застосунки.
Застосунків не знайдено
- Про застосунок
Версія %s
від D4vidDf
Конфіґурація
Налаштування системи
Виправити дозволи та батарею
+
+ Permanent Island
+ Keep the Dynamic Island permanently visible on screen when there are no active notifications.
+ When enabled, an empty Dynamic Island will be shown permanently on your screen. It will automatically hide when real notifications arrive and reappear when all notifications are cleared. It will not be visible in your notification shade.
+ Island Width
Про застосунок
Розробник
Відвідати d4viddf.com
@@ -59,43 +53,31 @@
Ліцензії Open Source
Стороннє програмне забезпечення
Зроблено з ❤️ в Іспанії
- Стан застосунку
Переконайтеся, що всі пункти зелені, щоб застосунок працював коректно у фоновому режимі.
Необхідні дозволи
Доступ до сповіщень
Потрібно для читання подій застосунків
- Дозвіл на відображення поверх інших програм
Оптимізація пристрою
Автозапуск Xiaomi
- Дозволити запуск при завантаженні
Батарея без обмежень
Запобігання зупинці системою
Примітка: Також закріпіть застосунок (замочок) у меню огляду \"Нещодавніх\".
Активно
- Потрібна дія
Усі
Музика
Навігація
Продуктивність
Інше
- Виберіть активні сповіщення
Повідомлення та загальне
Завантаження та прогрес
+ Downloads
Музика та медіа
Карти та GPS
Дзвінки
Таймери та будильники
- Активний
Завантаження завершено
- Очікування...
- Завершено
Завантаження
- Очікування...
- Зараз грає
- Невідомий абонент
- Дзвінок
Карти
- Таймер
- downloaded
- completed
@@ -105,16 +87,8 @@
- successful
- succeeded
- Завантаження завершено
- Завантаження
- Pending…
- Завершено
Зараз грає
- Вхідний дзвінок
- Невідомий абонент
- Карти
Таймер
- Секундомір
Що нового у %s
Чудово!
Поведінка острова
@@ -128,20 +102,16 @@
Визначте, які типи (напр. дзвінки) важливіші.
Порядок пріоритету
Високий пріоритет (зверху) замінює низький пріоритет (знизу).
- Вгору
- Вниз
Налаштувати порядок
Глобальні налаштування
Вигляд острова
Показати плаваючий острів
Показувати спливаюче сповіщення
+ How long the heads-up popup stays visible.
Показати в шторці
Keep the notification visible in the notification panel. If disabled, it only appears as an Island.
- Auto-Close delay
%dс
Використовувати глобальні налаштування
- Налаштувати
- Перевизначити налаштування
- arrival
- arrive
@@ -149,7 +119,6 @@
- reaching
- destination
- Опції
Історія версій
Інформаційне оновлення
• <b>Розширений прогрес:</b> Додано підтримку \"Покрокового прогресу\" (напр., 1 з 3) та індикаторів прогресу з іконками всередині шторки сповіщень.\n
@@ -168,10 +137,7 @@
Нове: Керуйте островом
HyperBridge тепер може керувати поведінкою кількох сповіщень при досягненні ліміту в 9 островів.\n\n<b>За замовчуванням: Найновіші</b>\nНові сповіщення автоматично замінюють найстаріші.
- Залишити як є
- Налаштувати зараз
НОВЕ
- Власний дизайн
Макет навігації
Виберіть, де відображати інформацію
Ліва сторона
@@ -182,8 +148,6 @@
Відстань та час
Пусто
Активні острови
- Перемкнути %s
- Налаштувати %s
Увімкнути сповіщення %s
Вимкнути сповіщення %s
Редагувати макет навігації
@@ -196,8 +160,6 @@
Попередній перегляд навігації: %1$s ліворуч, %2$s праворуч
Розгорнути деталі версії
Згорнути деталі версії
- Налаштування конфіґурації
- Інформація про застосунок
Поворот праворуч
200м
10:30
@@ -206,7 +168,6 @@
Додати слово (напр. \'Спойлер\')
Додати
Видалити
- Торкніться застосунку, щоб налаштувати правила блокування.
Керування захистом від спойлерів та фільтрами
Типи сповіщень
%d активні
@@ -218,18 +179,18 @@
Заблокованих термінів: %d
Немає активних правил
Сумісність системи
- Пристрій: %s
- ОС: %s
Потрібна HyperOS 3.0+ (Android 15+)
Потрібен пристрій Xiaomi/Redmi/Poco
- Фоновий автозапуск
- Необхідно для перезапуску сервісу після перезавантаження.
- Надати
- Сумісний
- Несумісний
- Невідомо
Виявлено китайську прошивку (CN ROM)
Схоже, ваш пристрій працює на китайській прошивці, в якій Xiaomi значно змінює внутрішню структуру сповіщень. Функції можуть бути нестабільними або взагалі не працювати.
+ Enable Shizuku Workaround
+ Enable Workaround
+ If you are on a CN ROM, you must enable the Shizuku Workaround for notifications to trigger correctly.
+ Shizuku is running
+ Permission Granted
+ Permission Denied
+ Grant Shizuku Permission
+ Shizuku app is not installed or running. Please install and start Shizuku first.
Версія системи
Вхідний дзвінок
Активний дзвінок
@@ -289,8 +250,6 @@
Помилка експорту: %s
Дані успішно відновлено
Помилка імпорту: %s
- Недійсний файл резервної копії HyperBridge
- Попередження: Резервна копія з іншої версії бази даних
Статус не можна перевірити автоматично.\nБудь ласка, перевірте це вручну, щоб забезпечити коректну роботу сервісу
Інформація про Резервну Копію
Дата
@@ -300,7 +259,6 @@
(Порожньо)
Відновити вибрані дані
Оптимізація пристрою
- Встановити «Без обмежень»
Офлайн-орієнтований
Підключення до інтернету не потрібне.
Оновлення: Резервування та Поліглот
@@ -371,16 +329,13 @@
Uninstalled
Settings
Saved Widgets
- New Widget
No saved widgets found.
Widget #%d
Show on Island
- Import Theme
Analyzing theme package...
Theme Ready
Created by %s
Version %d
- No description provided.
Do you want to apply this theme to HyperBridge now?
Apply Theme
Save for Later
@@ -388,7 +343,6 @@
Could not verify the theme file.
Close
My Themes
- Navigate Back
Add New Theme
by %s
Active Theme
@@ -396,16 +350,8 @@
System Default
Standard Look
Add New Theme
- New Theme
- Theme Name
- Author Name
- Highlight Color
- Preview
- Save Theme
- Please enter a name and author
Create New Theme
Search
- Import from File
Import
Edit
Export
@@ -433,8 +379,6 @@
Custom Color
Add Custom Color
Pick a Color
- Select a custom color for your theme.
- Hex Code
Done
Style
Icons
@@ -455,24 +399,7 @@
Clover
Answer
Decline
- Hex Color
- Configured Apps
No apps configured
- Configure App
- Select App
- App
- Highlight Color (Hex)
- Custom Actions
- Keyword
- Button containing \'%s\'
- Save Configuration
- Default Color
-
- - %d actions
- - %d actions
- - %d action
- - %d actions
-
Customization
Learn how to create themes
Pro Features
@@ -488,7 +415,6 @@
System Widget (Beta)
Get Themes
Add your first Widget
- %d Active
Design
Global Actions
Style buttons by keyword
@@ -557,4 +483,172 @@
Note: This release includes significant architectural changes. Please report any bugs on GitHub!
+ Hotfix Update
+ •<b>Crash Fix:</b> Resolved a critical issue that caused the app to crash when opening the settings menu in Traditional Chinese.\n
+ •<b>Under the Hood:</b> General bug fixes and stability improvements to keep your Island running smoothly.
+
+ Hue
+ Saturation
+ Lightness
+ Dynamic Colors
+ Material You (System)
+ Extracts colors from your current wallpaper.
+ Kill Active Widgets
+ Update Notice
+ HyperBridge has been updated! Please ensure that your Autostart and Background Battery permissions haven\'t been silently revoked by the system to keep your widgets running smoothly.
+ Got it
+ Recommended
+ All
+ Favorites
+ Native Live Updates
+ Preview
+ Notification Design
+ Xiaomi Featured Design
+ Modern HyperOS layout with Xiaomi intended design.
+ Live Update Design
+ Native android notification designs, works on CN ROM.
+ Engine Configuration
+ Notification Design
+ Good to know
+ • Navigation layouts will be fully integrated into the Theme Engine in an upcoming update.\n• When using the Native Live Update engine, only the Right Side content is visible.
+ Xiaomi Featured Notifications
+ Island Behavior
+
+ Engine
+ Live Updates vs Xiaomi Island
+ Do Not Disturb
+ Manage island visibility during DND
+ Pause Islands
+ Manually pause all islands until you turn this back on
+ Auto-detect System DND
+ Automatically pause islands when your phone is in Do Not Disturb mode
+
+
+ Global Behavior
+ Auto-hide Island
+ Hides after a set time
+ Visible until manually dismissed
+ Determines how long the island stays on screen before disappearing automatically.
+ Active Triggers
+ Select which global events trigger the island
+ Regular messages, emails, and alerts
+ Downloads, uploads, and ongoing tasks
+ Downloads, uploads, and file transfers
+ Music and video playback controls
+ Turn-by-turn directions and ETA
+ Incoming and ongoing phone calls
+ Active timers and stopwatches
+ Select triggered events
+ Override Global Triggers
+ Set custom allowed notifications for this app
+ Trigger Override
+ Select which events trigger the island for this app.
+ Enable specific triggers
+ Open Source Licenses
+ Remove original notification
+ Automatically dismiss the source notification when a Live Update is active.
+ Dismiss with original
+ Island will disappear when you clear the original notification.
+ Enable inline reply
+ Launch the custom inline reply overlay when replying from the island.
+ Note: When enabled, Hyper Bridge cannot respond or mark messages as read. Reply and Mark as Read actions will be hidden.
+ Color Mode
+ Select Color Mode
+ Change color mode
+ Default
+ Use default standard colors
+ App Colors
+ Extract colors from the app icon
+ Material You
+ Match the system wallpaper (Monet)
+ Custom Colors
+ Manually choose your own colors
+ Textfield Color
+ Inner text box background
+ Text Color
+ Reply text color
+ Send Button Color
+ Send icon color
+ Appearance
+ Blur Effect
+ Apply glassmorphism to textfield
+ Textfield Corner Radius
+ Send Button Corner Radius
+ You can change these options later in Settings and Theme Configurator.
+ Add standard Android widgets to your island for quick access.
+ Fully customize colors, shapes and icons of your island.
+ Expand the possibilities with different customization options
+ Determines how the island behaves.
+ Preview of the widget
+ Play Store Crash Fixes & Stability\n
+ ? Theme Creator: Fixed a crash when parsing large sets of custom assets.\n
+ ? Performance: Resolved an ANR (Application Not Responding) issue on the initial Welcome Screen.\n
+ ? Widget Picker: Eliminated crashes and Out-Of-Memory (OOM) errors caused by extremely large widget previews.\n
+ ? UI Layouts: Fixed Jetpack Compose layout constraint exceptions that crashed the app when resizing certain menus.\n
+ ? Backups: Resolved OOM errors when importing large backup files by optimizing JSON parsing.\n
+ ? Warm Starts: Significantly improved app launch speeds and eliminated UI thread blocking on mid-range devices.\n
+ ? Background Service: Completely overhauled the Notification Listener memory architecture, dropping background overhead to 0% and improving battery life.\n
+
+ Performance & Stability Update
+ Fixes & Stability\n
+ WhatsApp Duplicates Fixed: Resolved an issue that caused duplicate WhatsApp notifications to appear.\n
+ Notification Flicker & Crashes Fixed: Fixed a crash caused by receiving multiple notifications in a short timeframe. The app now safely updates notifications normally, eliminating the notification flicker and improving stability.\n
+ Notification Intents Restored: Fixed an issue where clicking a featured notification would incorrectly route to the troubleshooting screen instead of the original app.\n\n
+ Features & Polish\n
+ Remove Island on Dismiss: Added a new option to automatically remove the island if the original system notification is dismissed.\n
+ Featured Notifications Diagnostics: Added a new check screen during Onboarding to verify if your Xiaomi device supports Featured Notifications and if the required permissions are enabled.\n
+ Setup Health Integration: The Featured Notifications permission status is now continuously monitored and displayed inside the Setup Health screen.\n
+ Troubleshooting Screen: Added a dedicated Troubleshooting Screen that guides users on how to enable the necessary hidden system permissions (or use Shizuku) to get islands working on strict Xiaomi ROMs.\n
+ Clearer Terminology: Renamed the \"Live Updates\" section in the settings menu to \"Notification Management\" to better reflect its purpose.\n
+ Settings UI Polish: Restored the missing rounded corners on the \"Permanent Island\" option in the Global Settings list.\n
+ Spanish Translations: Added full Spanish translations for all the new troubleshooting dialogues, setup health checks, and notification management settings.
+
+ Fixes & Refinements
+ CN ROM Support\n
+ • Enable the new Sui & Shizuku workaround to enjoy the Hyper Islands in your devices running HyperOS CN (Chinese) ROMs without native support.\n\n
+ Theme Engine Improvements\n
+ • Engine Selection: You can now choose animation engines per-app or globally.\n
+ • Shizuku Workaround: Toggle added directly to the Theme Editor Engine tab.\n
+ • Behavior Controls: Custom timeouts and popup logic are now fully supported.\n\n
+ Intelligent Permanent Island\n
+ • Always Visible: Keep the island on screen even without active notifications.\n
+ • Auto-Hide: The permanent island now automatically hides itself whenever an active widget is shown on the screen.\n\n
+ Smart Icon Tinting\n
+ • New detection system automatically analyzes extracted notification icons and intelligently tints them white if they are dark or monochrome.\n\n
+ Downloads & Progress\n
+ • New Dedicated Download Layout: Separated download and progress layouts with dedicated visual designs.\n
+ • Text Progress Support: Parsed percentage text (e.g. \"12%\") for app updates even without native progress bars.\n
+ • Smart Filtering: Restricted text-based progress checking to store and installer downloads to prevent false triggers on other status notifications.\n\n
+ Messaging Fixes\n
+ • Fixed missing sender avatars in apps like Telegram (now correctly extracts \"sender_person\" data).\n
+ • Resolved issues with grouped notifications (chat summaries) not triggering islands.\n
+ • Fixed island disappearance bugs caused by notification update race conditions.\n\n
+ System & Onboarding\n
+ • New Onboarding Screen: A brand new and easier setup process for critical permissions.\n
+ • Boot Fix: Service now starts immediately and reliably after restart.\n
+ • Smarter Alerts: Downloads and Media update silently, while messaging updates reliably trigger popup animations.\n
+ • Navigation: Faster and smoother app transitions.
+
+ The Live Update
+ If you are seeing this, something went wrong!
+ If you are seeing this, something went wrong! The system couldn\'t show the widget
+ Troubleshoot
+ Featured Notifications Check
+ Let\'s verify if your system is configured correctly to display HyperBridge islands.
+ Not Supported
+ Enabled
+ Open System Settings
+ Why is this not an island?
+ Your Xiaomi device intercepted the notification but failed to display it as a featured island because the permission is disabled in your system settings.\n\nTo fix this:\n1. Tap \"Open System Settings\"\n2. Scroll down to \"Featured Notifications\" (or similar)\n3. Enable it for HyperBridge.
+ Alternative: Shizuku Override
+ If your system doesn\'t show the setting or it\'s restricted, you can bypass the Xiaomi whitelist entirely by setting up Shizuku. HyperBridge will use Shizuku to post islands directly.
+ HyperBridge Update
+ Migrating database to Device Protected storage…
+ Update Complete
+ Notification Management
+ Inline Reply
+ Customize inline reply overlay
+ Reply...
+ Display over other apps
+ Required to show the custom inline reply overlay.
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index b4edef5..62e7e4c 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -4,26 +4,21 @@
返回
下一步
完成
- 已完成
+ 完成
應用程式圖示
設定
- 資訊
設定
清除
排序方式
歡迎使用\nHyper Bridge
將 HyperIslands 整合至你的 HyperOS 使用體驗中。
開始使用
- 運作方式
- 選擇你常用的應用程式(Spotify、地圖、計時器)。HyperBridge 會將它們的通知轉換為簡潔俐落的 HyperIsland。
- 早期開發階段
+ 如何運作
+ 選擇你常用的應用程式(Spotify、地圖、計時器)。HyperBridge 會將它們的通知轉換為簡潔俐落的超級島。
注意:此應用程式目前為 Beta 測試版,您可能會遇到程式錯誤 或顯示資訊不正確的情況。
相容的裝置
不支援此裝置
偵測到您的裝置正在運行 HyperOS/MIUI,一切就緒!
- HyperBridge 需依賴特定的 HyperOS 系統功能。您的裝置製造商似乎並非 小米/POCO/紅米。
- ✅ 已偵測到系統
- ⚠️ 應用程式可能無法正常運作
顯示超級島
我們需要取得權限,才能在您的螢幕上顯示「超級島」通知。
✅ 已授予權限
@@ -31,26 +26,25 @@
存取資料權限
為了連接您的應用程式,我們需要存取通知內容(如:音樂、地圖、計時器)。
開啟設定
- 保持背景執行
HyperOS 會自動關閉背景程式。請開啟「自啟動」並將省電策略設為「無限制」,以維持超級島運作。
- 1. 開啟自啟動
+ 1. 開啟後台啟動管理
2. 將省電策略設為「無限制」
已啟用
資料庫
- 執行中的程式通知
- 應用程式庫
搜尋應用程式……
- 排序:A-Z
- 排序:Z-A
尚無執行中的程式通知
請前往「應用程式庫」以啟用 App。
找不到應用程式
- 關於
版本 %s
開發者:D4vidDf
設定
系統設定
修正權限 & 電池設定
+
+ 永久超級島
+ 當沒有活動通知時,保持超級島在螢幕上永久可見。
+ 啟用後,螢幕上將永久顯示一個空白的超級島。當收到實際通知時,它會自動隱藏;當所有通知清除後,它會自動重新出現。此超級島不會顯示在通知欄中。
+ 超級島寬度
關於
開發者
造訪 d4viddf.com
@@ -59,43 +53,31 @@
開源授權
第三方軟體
在西班牙由 ❤️ 打造
- 應用程式運行狀態
請確保所有檢查項目皆顯示為綠色,以確保應用程式能在背景正常運作。
必要的權限
通知存取權
讀取應用程式事件所需權限
- 請允許顯示在最上層的權限
裝置優化設定
小米自啟動
- 允許應用程式在開機時啟動
電池使用量不受限制
防止系統自動關閉應用程式
注意:請同時在「最近任務」預覽介面中鎖定此應用程式。
已啟用
- 需要採取行動
全部
音樂
導航
生產力
其他
- 選擇要顯示的通知
訊息 & 一般通知
- 下載 & 進度
+ 進度 & 任務
+ 下載
音樂 & 媒體
地圖 & GPS
通話
計時器 & 鬧鐘
- 已啟用
下載完成
- 等待中…
- 已完成
正在下載
- 正在等待…
- 正在播放
- 未知來電
- 通話
地圖
- 計時器
- 已下載
- 已完成
@@ -104,21 +86,9 @@
- 完成
- 成功
- 完成
- - 完成
- - 結束
- - 成功
- - 已安裝
- 下載完成
- 正在下載
- 等待中……
- 已完成
正在播放
- 來電
- 未知來電者
- 地圖
計時器
- 碼錶
%s的新功能
太棒了!
超級島行為
@@ -132,28 +102,23 @@
定義哪些類型(例如:電話)比較重要。
優先級排序
高優先級(頂部)會替換掉低優先級(底部)。
- 上移
- 下移
設定 App 排序
預設設定
超級島外觀
顯示於超級島
顯示提前通知
+ 彈出式提示框的顯示時長。
在通知欄中顯示
在通知中心中保留
- 逾時
- %ds
+ %d秒
使用預設設定
- 自訂
- 覆蓋設定
- - arrival
- - arrive
+ - 到達
+ - 到達
- eta
- - reaching
- - destination
+ - 到達
+ - 目標
- 選項
版本紀錄
資訊更新
•<b>進階進度顯示:</b>新增支援「分段進度」(例如:1/3)以及通知欄內含圖示的進度條。\n
@@ -172,10 +137,7 @@
全新:超級島控制功能
HyperBridge 現在可以管理當達到 9 個超級島上限時,多個通知的處理方式。\n\n<b>設值:最近優先</b>\n新的通知將會自動替換掉最舊的通知。
- 保留預設設定
- 立即設定
新功能
- 自訂外觀
導航設定
選擇資訊顯示的位置
左側
@@ -186,8 +148,6 @@
距離 & 時間
無
啟動超級島
- 切換 %s
- 設定 %s
停用 %s 通知
停用 %s 通知
編輯導航設定
@@ -200,8 +160,6 @@
導航預覽:左側顯示 %1$s,右側顯示 %2$s
開啟版本資訊
摺疊版本資訊
- 細部設定
- 關於資訊
向右轉
200m
10:30
@@ -210,7 +168,6 @@
新增關鍵字(例如:劇透)
新增
移除
- 點擊 App 以設定特定的封鎖規則。
管理防劇透保護與過濾器
通知類型
%d 個已啟用
@@ -222,18 +179,18 @@
已封鎖 %d 個關鍵字
無啟用的規則
系統相容性
- 裝置:%s
- 作業系統:%s
需要 HyperOS 3.0 以上版本 (Android 15+)
僅支援 小米/紅米/Poco 裝置
- 背景自動啟動
- 重啟裝置後需要重新啟動服務。
- 授予權限
- 相容
- 不相容
- 未知
偵測到中國版 ROM / 韌體
您的裝置似乎正在執行中國版韌體。小米在中國版 ROM 上大幅修改了通知系統底層,功能可能不穩定或無法運作。
+ 啟用 Shizuku 用法
+ 啟用解決方法
+ 如果您使用的是中國版 ROM,則必須啟用 Shizuku 方法才能正確觸發通知。
+ Shizuku 正在運作
+ ✅ 已授予權限
+ 權限被拒絕
+ 授予Shizuku權限
+ Shizuku 應用未安裝或運作。請先安裝並啟動 Shizuku。
系統版本
來電
通話中
@@ -249,7 +206,7 @@
- 擴音
- - audio
+ - 聲音
預設功能更新
隱藏通知 👻\n
@@ -293,8 +250,6 @@
匯出失敗:%s
資料還原成功
匯入失敗:%s
- 無效的 HyperBridge 備份檔案
- 警告:備份來自不同的資料庫版本
無法自動驗證狀態。\n請手動檢查以確保服務正常運作
備份資訊
日期
@@ -304,7 +259,6 @@
(無)
還原所選資料
裝置優化
- 設定為「無限制」
離線優先
無需連接網路。
備份 & 多語言更新
@@ -366,16 +320,13 @@
已卸載
設定
已儲存的小工具
- 新增小工具
未發現已儲存的小工具。
小工具 #%d
顯示於超級島
- 匯入主題
正在分析主題包……
主題已就緒
建立者:%s
版本 %d
- 未提供描述。
您現在要將此主題套用到 HyperBridge 嗎?
套用主題
稍後儲存
@@ -383,7 +334,6 @@
無法驗證主題檔案。
關閉
我的主題
- 返回
新增主題
作者:%s
目前使用中的主題
@@ -391,16 +341,8 @@
系統預設
標準外觀
新增主題
- 新主題
- 主題名稱
- 作者姓名
- 強調色彩
- 預覽
- 儲存主題
- 請輸入名稱與作者
建立新主題
搜尋
- 從檔案匯入
匯入
編輯
匯出
@@ -428,8 +370,6 @@
自訂色彩
新增自訂色彩
挑選顏色
- 為您的主題選擇一個自訂色彩。
- 十六進位碼
完成
樣式
圖示
@@ -450,21 +390,7 @@
幸運草形
接聽
拒絕
- 十六進位顏色碼
- 已設定的應用程式
尚未設定任何應用程式
- 設定應用程式
- 選擇應用程式
- 應用程式
- 強調色彩 (十六進位碼)
- 自訂動作
- 關鍵字
- 包含「%s」的按鈕
- 儲存配置
- 預設顏色
-
- - %d 個動作
-
個人化設定
了解如何建立主題
進階功能
@@ -480,7 +406,6 @@
系統小工具 (Beta)
取得主題
新增您的第一個小工具
- %d 個正在使用中
設計
預設動作
依關鍵字設定按鈕樣式
@@ -546,4 +471,170 @@
備註:本次版本包含重大架構調整,如發現任何問題,請至 GitHub 回報!
+ 修補更新
+ •</b>崩潰修復:</b>修正了在繁體中文環境下,開啟設定選單會導致應用程式閃退的嚴重問題。\n
+ •<b>底層最佳化:</b> 一般錯誤的修正與穩定性提升,確保您的超級島順暢運作。
+
+ 色調
+ 飽和度
+ 亮度
+ 超級島顏色
+ Material You (系統初始設定)
+ 從目前壁紙中提取顏色。
+ 停止運作中的小工具
+ 更新通知
+ HyperBridge 已更新!請確保您的自啟動和後台運作權限未被系統移除,以確保您的小工具正常運作。
+ 明白了
+ 建議
+ 全部
+ 我的最愛
+ 原生即時更新
+ 預覽
+ 通知設計
+ 小米特色設計
+ 現代HyperOS介面,採用小米的設計理念。
+ 即時更新設計
+ 原生安卓通知設計,適用於中國版 ROM。
+ 儲存配置
+ 通知設計
+ 太好了
+ • 導航佈局會在即將推出的更新中完全整合到主題引擎中。 \n• 使用原生即時更新引擎時,僅右側內容可見。
+ 小米特色通知
+ 超級島行為
+
+ 引擎
+ 原生即時更新 vs. 小米超級島
+ 勿擾模式
+ 在勿擾模式中選擇啟用超級島
+ 暫停超級島
+ 手動暫停所有超級島,直到您重新開啟此功能
+ 自動偵測勿擾模式
+ 當您的手機處於勿擾模式時,自動暫停小米超級島功能
+
+
+ 預設行為
+ 自動隱藏超級島
+ 在特定的時間後隱藏
+ 手動關閉前一直可見
+ 決定超級島在螢幕上停留的時間,之後會自動消失。
+ 執行中的程式通知
+ 選擇哪一個全域條件來觸發超級島
+ 預設的訊息、郵件及鬧鐘
+ 正在進行的任務、渲染和操作
+ 下載、上傳及正在進行的任務
+ 音樂及影片的播放控制
+ 逐嚮導航和預計到達時間
+ 來電與通話
+ 運作中的計時器和碼錶
+ 選擇觸發條件
+ 覆蓋預設觸發器
+ 為此應用程式設自訂允許的通知
+ 覆蓋觸發器
+ 為應用程式選擇一個條件觸發器上超級島。
+ 啟用特定的觸發器
+ 開放原始碼授權
+ 移除預設通知
+ 當即時更新處於啟用狀態時,自動關閉通知來源。
+ 不使用預設
+ 超級島通知會在您刪除通知欄通知後消失。
+ 啟用島內回覆
+ 從超級島回覆時,啟動自訂的回覆來回覆。
+ 注意:啟用此功能後,Hyper Bridge 將無法回覆或將訊息標記為已讀。 “回覆”和“標記為已讀”操作將被隱藏。
+ 色彩模式
+ 選取色彩模式
+ 更改色彩模式
+ 預設
+ 使用預設顏色
+ 應用程式顏色
+ 從應用程式圖示中提取顏色
+ Material You
+ 選擇與系統壁紙相符的壁紙(Monet)
+ 自訂顏色
+ 手動選擇顏色
+ 文字方塊顏色
+ 內部文字方塊背景
+ 文字顏色
+ 回覆文字顏色
+ 傳送按鈕顏色
+ 傳送圖示顏色
+ 顯現
+ 模糊效果
+ 對文字欄位使用毛玻璃效果
+ 文字方塊圓角半徑
+ 傳送按鈕圓角半徑
+ 您稍後可以在「設定」和「主題配置器」中變更這些選項。
+ 在您的超級島上添加Android 小工具,以便快速存取。
+ 完全自訂超級島的顏色、形狀和圖示。
+ 利用不同的自訂選項拓展可能性
+ 決定超級島的呈現方式。
+ 預覽小工具
+ Play Store Crash Fixes & Stability\n
+ ? Theme Creator: Fixed a crash when parsing large sets of custom assets.\n
+ ? Performance: Resolved an ANR (Application Not Responding) issue on the initial Welcome Screen.\n
+ ? Widget Picker: Eliminated crashes and Out-Of-Memory (OOM) errors caused by extremely large widget previews.\n
+ ? UI Layouts: Fixed Jetpack Compose layout constraint exceptions that crashed the app when resizing certain menus.\n
+ ? Backups: Resolved OOM errors when importing large backup files by optimizing JSON parsing.\n
+ ? Warm Starts: Significantly improved app launch speeds and eliminated UI thread blocking on mid-range devices.\n
+ ? Background Service: Completely overhauled the Notification Listener memory architecture, dropping background overhead to 0% and improving battery life.\n
+
+ Performance & Stability Update
+ 修復 & 穩定\n
+ WhatsApp 重複通知修復:解決了 WhatsApp 通知重複出現的問題。 \n
+ 通知閃爍 & 崩潰修復:修復了短時間內收到多個通知導致的崩潰問題。應用程式現在可以安全地正常更新通知,消除了通知閃爍並提高了穩定性。 \n
+ 通知 Intent 恢復:修正了點擊精選通知時會錯誤地跳到故障排除畫面而不是原始應用程式的問題。 \n\n
+ 功能 & 最佳化\n
+ 關閉通知時移除超級島通知:新增選項,當系統通知關閉時,超級島通知將自動移除。 \n
+ 精選通知診斷:在引導過程中新增檢查介面,用於驗證您的小米設備是否支援精選通知以及是否已啟用所需權限。 \n
+ 設定健康整合:精選通知的權限狀態現在會持續監控並顯示在設定健康介面中。 \n
+ 故障排除介面:新增專門的故障排除介面,引導使用者啟用必要的隱藏系統權限(或使用Shizuku),以使超級島通知在嚴格的小米 ROM 上正常運作。 \n
+ 更清晰的術語:將設定選單中的“即時更新”部分重新命名為“通知管理”,以更好地體現其用途。 \n
+ 設定介面最佳化:恢復了預設設定清單中「永久通知島」選項的圓角效果。 \n
+ 西班牙文翻譯:為所有新增的故障排除對話方塊、設定健康檢查和通知管理設定新增了完整的西班牙文翻譯
+ 修復 & 改進
+ 中國版ROM 支援 \n
+ • 啟用新的 Sui & Shizuku 變通方案,即可在執行 HyperOS 中國版ROM 且不支援原生功能的裝置上體驗小米超級島。 \n\n
+ 主題引擎改善\n
+ • 引擎選擇:現在您可以為每個應用程式或預設選擇動畫引擎。 \n
+ • Shizuku 變通方案:已將切換開關直接新增至主題編輯器的引擎標籤。 \n
+ • 行為控制:現在完全支援自訂逾時和彈出邏輯。 \n\n
+ 智慧永久島\n
+ • 隨時可見:即使沒有活動通知,超級島也會保持在螢幕上。 \n
+ • 自動隱藏:現在,當螢幕上顯示小工具時,永久島會自動隱藏。 \n\n
+ 智慧型圖示著色\n
+ • 新的檢測系統會自動分析提取通知圖標,如果圖標是深色或單色的,則會聰明地將其著色為白色。 \n\n
+ 下載 &進度\n
+ • 全新專用下載佈局:獨立的下載和進度佈局,採用專屬視覺設計。 \n
+ • 文字進度支援:即使沒有原生進度條,也能解析應用更新的百分比文字(例如「12%」)。 \n
+ • 智慧過濾:限制基於文字的進度檢查,僅用於商店和安裝程式下載,以防止誤觸其他狀態通知。 \n\n
+ 訊息修復\n
+ • 修正了 Telegram 等應用程式中缺少寄件者頭像的問題(現在可以正確提取「sender_person」資料)。 \n
+ • 解決了分組通知(聊天摘要)無法觸發超級島的問題。 \n
+ • 修正了通知更新競爭條件所導致的超級島消失錯誤。 \n\n
+系統 &引導流程\n
+ • 全新引導介面:關鍵權限的設定流程更方便。 \n
+ • 啟動修復:服務現在在重新啟動後能夠立即可靠地啟動。 \n
+ • 更聰明的提醒:下載和媒體更新將靜默進行,而訊息更新則會可靠地觸發彈出動畫。 \n
+ • 導航:應用程式切換更加流暢快速
+ 即時更新
+ 如果您看到此訊息,說明出現錯誤!
+ 如果您看到此訊息,表示出錯了!系統無法顯示該小工具
+ 故障排除
+ 精選通知檢查
+ 讓我們驗證一下您的系統是否已正確配置以顯示 HyperBridge 超級島。
+ 不支援
+ 啟用
+ 開啟系統設定
+ 為什麼沒有超級島?
+ 您的小米設備攔截了通知,但由於系統設定中權限已停用,因此無法將其顯示為精選通知。 \n\n要解決此問題:\n1. 點選「開啟系統設定」\n2. 向下捲動至「精選通知」(或類似選項)\n3. 為 HyperBridge 啟用此權限。
+ 替代方案:Shizuku 方法
+ 如果您的系統未顯示此設定或該設定受到限制,您可以透過設定 Shizuku 來完全繞過小米白名單。 HyperBridge 將使用 Shizuku 直接發布超級島通知。
+ HyperBridge Update
+ Migrating database to Device Protected storage…
+ Update Complete
+ 通知管理
+ 島內回覆
+ 自訂島內回覆覆蓋
+ 回覆……
+ 顯示在其他應用程式上
+ 需要顯示自訂島內回覆覆蓋。
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 120be53..90bfe5e 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -7,7 +7,6 @@
Done
App Logo
Settings
- Info
Configure
Clear
Sort Order
@@ -18,43 +17,33 @@
How it Works
Select your favorite apps (Spotify, Maps, Timer). HyperBridge transforms their notifications into a sleek HyperIsland.
- Early Development
Note: This app is in Beta. You may encounter bugs or incorrect visual information.
Device Compatible
Unsupported Device
Your device appears to be running HyperOS/MIUI. You are good to go!
- HyperBridge relies on specific HyperOS system features. Your device manufacturer does not appear to be Xiaomi/POCO/Redmi.
- ✅ System Detected
- ⚠️ App may not work
Show Island
We need permission to post the \'Island\' notification on your screen.
Permission Granted
Allow Notifications
- Read Data
+ Read Notifications
To bridge your apps, we need to read their notifications (Music, Maps, Timers).
Open Settings
- Keep Alive
HyperOS kills background apps. Enable Autostart and No Restrictions to keep the island active.
- 1. Enable Autostart
- 2. Set Battery \'No Restrictions\'
+ Allow Autostart
+ Disable Restrictions
Active
Library
- Active Bridges
- App Library
Search apps…
- Sort: A-Z
- Sort: Z-A
No Active Bridges
Go to Library to enable apps.
No apps found
- About
Version %s
by D4vidDf
@@ -62,6 +51,12 @@
System Setup
Fix permissions & battery
+
+ Permanent Island
+ Keep the Dynamic Island permanently visible on screen when there are no active notifications.
+ When enabled, an empty Dynamic Island will be shown permanently on your screen. It will automatically hide when real notifications arrive and reappear when all notifications are cleared. It will not be visible in your notification shade.
+ Island Width
+
About
Developer
Visit d4viddf.com
@@ -71,20 +66,16 @@
Third-party software
Made with ❤️ in Spain
- App Health
Ensure all checks are green for the app to work correctly in the background.
Required Permissions
Notification Access
Required to read app events
- Permission to display over apps
Device Optimization
Xiaomi Autostart
- Allow app to start on boot
Battery Unrestricted
Prevent system killing app
Note: Also lock the app in the \'Recents\' overview menu.
Active
- Action Needed
All
Music
@@ -92,25 +83,18 @@
Productivity
Other
- Select active notifications
- Messages & General
- Downloads & Progress
+ General Alerts
+ Messages & Chats
+ Progress & Tasks
+ Downloads
Music & Media
Maps & GPS
Calls
Timers & Alarms
- Active
Download Complete
- Pending…
- Finished
Downloading
- Waiting…
- Now Playing
- Unknown Caller
- Call
Maps
- Timer
- downloaded
@@ -122,17 +106,9 @@
- succeeded
- Download Complete
- Downloading
- Pending…
- Finished
Now Playing
- Incoming Call
- Unknown Caller
- Maps
Timer
- Stopwatch
What\'s New in %s
Awesome!
@@ -149,24 +125,20 @@
Define which types (e.g. Calls) are more important.
Priority Order
High priority (Top) replaces Low priority (Bottom).
- Move Up
- Move Down
Configure apps order
Global Settings
Island Appearance
Heads-up Popup
Show the full notification banner briefly before minimizing to the Island.
+ How long the heads-up popup stays visible.
Notification Panel
Keep the notification visible in the notification panel. If disabled, it only appears as an Island.
- Auto-Close delay
%ds
Use Global Defaults
- Customize
- Override Settings
- arrival
@@ -175,7 +147,6 @@
- reaching
- destination
- Options
Version History
@@ -198,14 +169,11 @@
• <b>Localization:</b> Added Spanish support.
- New: Control the Island
- HyperBridge can now manage how multiple notifications behave when the 9-island limit is reached.\n\n<b>Default: Most Recent</b>\nNew notifications replace the oldest ones automatically.
- Keep Default
- Configure Now
+ Control the Island
+ Choose how the island should behave when multiple notifications are active at the same time.
NEW
- Custom Design
Navigation Layout
Choose where information appears
Left Side
@@ -219,8 +187,6 @@
Active Islands
- Toggle %s
- Configure %s
Enable %s notifications
Disable %s notifications
Edit navigation layout
@@ -234,8 +200,6 @@
Navigation Preview: %1$s on left, %2$s on right
Expand version details
Collapse version details
- Configuration Settings
- About Information
Turn Right
200m
@@ -246,7 +210,6 @@
Add word (e.g. Spoiler)
Add
Remove
- Tap an app to configure specific block rules.
Manage spoiler protection and filters
Notification Types
@@ -261,20 +224,21 @@
No active rules
System Compatibility
- Device: %s
- OS: %s
Requires HyperOS 3.0+ (Android 15+)
Xiaomi/Redmi/Poco device required
- Background Autostart
- Required for the service to restart after reboot.
- Grant
- Compatible
- Incompatible
- Unknown
China ROM Detected
Your device appears to be running a Chinese firmware. Xiaomi modifies notification internals significantly on CN ROMs. Features might be unstable or not work at all.
+ Enable Shizuku Workaround
+ Enable Workaround
+ If you are on a CN ROM, you must enable the Shizuku Workaround for notifications to trigger correctly.
+ Shizuku is running
+ Permission Granted
+ Permission Denied
+ Grant Shizuku Permission
+ Shizuku app is not installed or running. Please install and start Shizuku first.
+
System Version
Incoming Call
@@ -346,8 +310,6 @@
Export failed: %s
Data restored successfully
Import failed: %s
- Invalid HyperBridge backup file
- Warning: Backup from a different database version
Status cannot be verified automatically.\nPlease check this manually to ensure the service works proprely
@@ -360,7 +322,6 @@
Restore Selected Data
Device Optimization
- Set "No Restrictions"
Offline First
No internet connection required.
@@ -443,17 +404,14 @@
Settings
Saved Widgets
- New Widget
No saved widgets found.
Widget #%d
Show on Island
- Import Theme
Analyzing theme package...
Theme Ready
Created by %s
Version %d
- No description provided.
Do you want to apply this theme to HyperBridge now?
Apply Theme
Save for Later
@@ -461,7 +419,6 @@
Could not verify the theme file.
Close
My Themes
- Navigate Back
Add New Theme
by %s
@@ -472,17 +429,9 @@
Standard Look
Add New Theme
- New Theme
- Theme Name
- Author Name
- Highlight Color
- Preview
- Save Theme
- Please enter a name and author
Create New Theme
Search
- Import from File
Import
Edit
Export
@@ -513,8 +462,6 @@
Custom Color
Add Custom Color
Pick a Color
- Select a custom color for your theme.
- Hex Code
Done
Style
@@ -538,24 +485,8 @@
Answer
Decline
- Hex Color
- Configured Apps
No apps configured
- Configure App
- Select App
- App
- Highlight Color (Hex)
- Custom Actions
- Keyword
- Button containing \'%s\'
- Save Configuration
- Default Color
-
- - %d actions
- - %d actions
- - %d action
-
Customization
Learn how to create themes
@@ -574,7 +505,6 @@
System Widget (Beta)
Get Themes
Add your first Widget
- %d Active
Design
Global Actions
@@ -662,7 +592,6 @@
Material You (System)
Extracts colors from your current wallpaper.
- Show Island Compatible Only
Kill Active Widgets
Update Notice
HyperBridge has been updated! Please ensure that your Autostart and Background Battery permissions haven\'t been silently revoked by the system to keep your widgets running smoothly.
@@ -672,7 +601,192 @@
All
Favorites
- Native Android Live Updates
- Transform notifications into standard Android 16 ongoing tasks instead of overriding them with custom Xiaomi Islands.
Native Live Updates
+
+ Preview
+ Notification Design
+ Xiaomi Featured Design
+ Modern HyperOS layout with Xiaomi intended design.
+ Live Update Design
+ Native android notification designs, works on CN ROM.
+
+ Engine Configuration
+ Notification Design
+ Good to know
+ • Navigation layouts will be fully integrated into the Theme Engine in an upcoming update.\n• When using the Native Live Update engine, only the Right Side content is visible.
+ Xiaomi Featured Notifications
+ Island Behavior
+
+ Engine
+ Live Updates vs Xiaomi Island
+
+ Do Not Disturb
+ Manage island visibility during DND
+ Pause Islands
+ Manually pause all islands until you turn this back on
+ Auto-detect System DND
+ Automatically pause islands when your phone is in Do Not Disturb mode
+
+
+ Global Behavior
+ Auto-hide Island
+ Hides after a set time
+ Visible until manually dismissed
+ Determines how long the island stays on screen before disappearing automatically.
+ Active Triggers
+ Select which global events trigger the island
+ General alerts and notifications
+ Messages, chats, and conversations
+ Ongoing tasks, rendering, and operations
+ Downloads, uploads, and file transfers
+ Music and video playback controls
+ Turn-by-turn directions and ETA
+ Incoming and ongoing phone calls
+ Active timers and stopwatches
+ Select triggered events
+ Override Global Triggers
+ Set custom allowed notifications for this app
+ Trigger Override
+ Select which events trigger the island for this app.
+ Enable specific triggers
+ Open Source Licenses
+ Remove original notification
+ Automatically dismiss the source notification when a Live Update is active.
+ Dismiss with original
+ Island will disappear when you clear the original notification.
+ Enable inline reply
+ Launch the custom inline reply overlay when replying from the island.
+ Note: When enabled, Hyper Bridge cannot respond or mark messages as read. Reply and Mark as Read actions will be hidden.
+
+ Color Mode
+ Select Color Mode
+ Change color mode
+ Default
+ Use default standard colors
+ App Colors
+ Extract colors from the app icon
+ Material You
+ Match the system wallpaper (Monet)
+ Custom Colors
+ Manually choose your own colors
+
+ Textfield Color
+ Inner text box background
+ Text Color
+ Reply text color
+ Send Button Color
+ Send icon color
+
+ Appearance
+ Blur Effect
+ Apply glassmorphism to textfield
+ Textfield Corner Radius
+ Send Button Corner Radius
+
+ You can change these options later in Settings and Theme Configurator.
+ Add standard Android widgets to your island for quick access.
+ Fully customize colors, shapes and icons of your island.
+ Expand the possibilities with different customization options
+ Determines how the island behaves.
+ Preview of the widget
+
+
+ Live Updates & Islands\n
+ • Icons: Fixed an issue where the original app icon wouldn\'t display properly on Android 16 Live Updates.\n
+ • Inline Replies: You can now use inline replies directly from Live Update notifications.\n
+ • Island Removal: Fixed an issue where islands were not being properly removed when dismissed.\n\n
+ Messaging Grouping Overhaul\n
+ • Deduplication: Completely reworked how messaging-style notifications (like WhatsApp) are grouped to prevent duplicated islands.\n\n
+ Fixes & Stability\n
+ • Update Migration: Fixed a critical bug where updating the app wouldn\'t trigger the proper database migration logic.\n
+ • Defaults: Disabled "Show in shade" by default on the onboarding screen.\n
+ • Localization: Resolved broken characters in translated strings.\n
+
+ Messaging & Update Fixes
+
+
+ New Inline Reply Feature!\n
+ • Inline Reply: You can now reply to messages directly from the island without opening the app!\n
+ • Customization: Fully customize the look of the new inline reply editor globally or per-app.\n
+ • Global Settings: Access the new Reply customization section directly from the Global Settings -> Inline reply.\n
+ \n
+ Play Store Crash Fixes & Stability\n
+ • Theme Creator: Fixed a crash when parsing large sets of custom assets.\n
+ • Performance: Resolved an ANR (Application Not Responding) issue on the initial Welcome Screen.\n
+ • Widget Picker: Eliminated crashes and Out-Of-Memory (OOM) errors caused by extremely large widget previews.\n
+ • UI Layouts: Fixed Jetpack Compose layout constraint exceptions that crashed the app when resizing certain menus.\n
+ • Backups: Resolved OOM errors when importing large backup files by optimizing JSON parsing.\n
+ • Warm Starts: Significantly improved app launch speeds and eliminated UI thread blocking on mid-range devices.\n
+ • Background Service: Completely overhauled the Notification Listener memory architecture, dropping background overhead to 0% and improving battery life.\n
+
+ Inline Reply, Performance & Stability
+
+
+ Fixes & Stability\n
+ WhatsApp Duplicates Fixed: Resolved an issue that caused duplicate WhatsApp notifications to appear.\n
+ Notification Flicker & Crashes Fixed: Fixed a crash caused by receiving multiple notifications in a short timeframe. The app now safely updates notifications normally, eliminating the notification flicker and improving stability.\n
+ Notification Intents Restored: Fixed an issue where clicking a featured notification would incorrectly route to the troubleshooting screen instead of the original app.\n\n
+ Features & Polish\n
+ Remove Island on Dismiss: Added a new option to automatically remove the island if the original system notification is dismissed.\n
+ Featured Notifications Diagnostics: Added a new check screen during Onboarding to verify if your Xiaomi device supports Featured Notifications and if the required permissions are enabled.\n
+ Setup Health Integration: The Featured Notifications permission status is now continuously monitored and displayed inside the Setup Health screen.\n
+ Troubleshooting Screen: Added a dedicated Troubleshooting Screen that guides users on how to enable the necessary hidden system permissions (or use Shizuku) to get islands working on strict Xiaomi ROMs.\n
+ Clearer Terminology: Renamed the "Live Updates" section in the settings menu to "Notification Management" to better reflect its purpose.\n
+ Settings UI Polish: Restored the missing rounded corners on the "Permanent Island" option in the Global Settings list.\n
+ Spanish Translations: Added full Spanish translations for all the new troubleshooting dialogues, setup health checks, and notification management settings.
+
+ Fixes & Refinements
+
+
+ CN ROM Support\n
+ • Enable the new Sui & Shizuku workaround to enjoy the Hyper Islands in your devices running HyperOS CN (Chinese) ROMs without native support.\n\n
+ Theme Engine Improvements\n
+ • Engine Selection: You can now choose animation engines per-app or globally.\n
+ • Shizuku Workaround: Toggle added directly to the Theme Editor Engine tab.\n
+ • Behavior Controls: Custom timeouts and popup logic are now fully supported.\n\n
+ Intelligent Permanent Island\n
+ • Always Visible: Keep the island on screen even without active notifications.\n
+ • Auto-Hide: The permanent island now automatically hides itself whenever an active widget is shown on the screen.\n\n
+ Smart Icon Tinting\n
+ • New detection system automatically analyzes extracted notification icons and intelligently tints them white if they are dark or monochrome.\n\n
+ Downloads & Progress\n
+ • New Dedicated Download Layout: Separated download and progress layouts with dedicated visual designs.\n
+ • Text Progress Support: Parsed percentage text (e.g. "12%") for app updates even without native progress bars.\n
+ • Smart Filtering: Restricted text-based progress checking to store and installer downloads to prevent false triggers on other status notifications.\n\n
+ Messaging Fixes\n
+ • Fixed missing sender avatars in apps like Telegram (now correctly extracts "sender_person" data).\n
+ • Resolved issues with grouped notifications (chat summaries) not triggering islands.\n
+ • Fixed island disappearance bugs caused by notification update race conditions.\n\n
+ System & Onboarding\n
+ • New Onboarding Screen: A brand new and easier setup process for critical permissions.\n
+ • Boot Fix: Service now starts immediately and reliably after restart.\n
+ • Smarter Alerts: Downloads and Media update silently, while messaging updates reliably trigger popup animations.\n
+ • Navigation: Faster and smoother app transitions.
+
+ The Live Update
+ If you are seeing this, something went wrong! Click here to see how to fix it.
+ If you are seeing this, something went wrong! The system couldn\'t show the widget
+ Troubleshoot
+
+ Featured Notifications Check
+ Let\'s verify if your system is configured correctly to display HyperBridge islands.
+ Not Supported
+ Enabled
+ Open System Settings
+ Why is this not an island?
+ Your Xiaomi device intercepted the notification but failed to display it as a featured island because the permission is disabled in your system settings.\n\nTo fix this:\n1. Tap "Open System Settings"\n2. Scroll down to "Featured Notifications" (or similar)\n3. Enable it for HyperBridge.
+ Alternative: Shizuku Override
+ If your system doesn\'t show the setting or it\'s restricted, you can bypass the Xiaomi whitelist entirely by setting up Shizuku. HyperBridge will use Shizuku to post islands directly.
+
+ HyperBridge Update
+ Migrating database to Device Protected storage…
+ Update Complete
+
+ Notification Management
+ Inline Reply
+ Customize inline reply overlay
+ Reply...
+
+ Display over other apps
+ Required to show the custom inline reply overlay.
diff --git a/build.gradle.kts b/build.gradle.kts
index f185ebf..77f9cdf 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,7 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
- alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.ksp)
}
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
index 20e2a01..04993af 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -20,4 +20,10 @@ kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
-android.nonTransitiveRClass=true
\ No newline at end of file
+android.nonTransitiveRClass=true
+android.uniquePackageNames=false
+android.dependency.useConstraints=true
+android.r8.strictFullModeForKeepRules=false
+android.builtInKotlin=true
+android.newDsl=true
+android.disallowKotlinSourceSets=false
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index bb2b667..6f28c66 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,31 +1,37 @@
[versions]
-agp = "8.13.2"
-datastorePreferences = "1.2.0"
-gson = "2.13.2"
+agp = "9.2.1"
+api = "13.1.5"
+datastorePreferences = "1.2.1"
+gson = "2.14.0"
hyperislandKit = "0.4.4"
-kotlin = "2.3.0"
-kotlinxSerializationJson = "1.9.0"
-ksp = "2.1.21-2.0.1"
-coreKtx = "1.17.0"
+haze = "1.1.1"
+kotlin = "2.3.21"
+kotlinxSerializationJson = "1.11.0"
+ksp = "2.3.2"
+coreKtx = "1.18.0"
junit = "4.13.2"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
lifecycleRuntimeKtx = "2.10.0"
-activityCompose = "1.12.2"
-composeBom = "2025.11.01"
+activityCompose = "1.13.0"
+composeBom = "2026.05.01"
materialIconsExtended = "1.7.8"
-ui = "1.10.0"
+ui = "1.11.2"
roomCompiler = "2.8.4"
roomRuntime = "2.8.4"
roomKtx = "2.8.4"
appcompat = "1.7.1"
paletteKtx = "1.0.0"
-material3 = "1.4.0"
+nav3 = "1.1.2"
+lifecycleViewmodelNav3 = "2.10.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
+api = { module = "dev.rikka.shizuku:api", version.ref = "api" }
+haze = { group = "dev.chrisbanes.haze", name = "haze", version.ref = "haze" }
+haze-materials = { group = "dev.chrisbanes.haze", name = "haze-materials", version.ref = "haze" }
gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
hyperisland-kit = { module = "io.github.d4viddf:hyperisland_kit", version.ref = "hyperislandKit" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
@@ -40,7 +46,7 @@ androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-toolin
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
-androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3", version = "1.5.0-alpha11" }
+androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3", version = "1.5.0-alpha15" }
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended", version.ref = "materialIconsExtended" }
androidx-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "ui" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "roomCompiler" }
@@ -49,10 +55,12 @@ androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref =
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
androidx-palette-ktx = { group = "androidx.palette", name = "palette-ktx", version.ref = "paletteKtx" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
-androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
+androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3" }
+androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3" }
+androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" }
+provider = { module = "dev.rikka.shizuku:provider", version.ref = "api" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 1b94922..414a2d7 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,7 +1,7 @@
#Sun Nov 23 16:10:02 CET 2025
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 3b11355..9ab95e7 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -11,6 +11,9 @@ pluginManagement {
gradlePluginPortal()
}
}
+plugins {
+ id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
+}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {