Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .idea/CrowdinSettingsPlugin.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
</p>

<p align="center">
<img src="https://img.shields.io/badge/version-0.4.0-blue?style=for-the-badge&logo=github" alt="Version 0.4.0" />
<img src="https://img.shields.io/badge/version-0.4.2-blue?style=for-the-badge&logo=github" alt="Version 0.4.2" />
<img src="https://img.shields.io/badge/kotlin-%237F52FF.svg?style=for-the-badge&logo=kotlin&logoColor=white" alt="Kotlin" />
<img src="https://img.shields.io/badge/Android-3DDC84?style=for-the-badge&logo=android&logoColor=white" alt="Android" />
<img src="https://img.shields.io/badge/Material%20Design-757575?style=for-the-badge&logo=material-design&logoColor=white" alt="Material Design" />
Expand Down Expand Up @@ -126,4 +126,4 @@ Distributed under the Apache 2.0 License. See `LICENSE` for more information.

**D4vidDf**
* Website: [d4viddf.com](https://d4viddf.com)
* GitHub: [@D4vidDf](https://github.com/D4vidDf)
* GitHub: [@D4vidDf](https://github.com/D4vidDf)
2 changes: 1 addition & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ android {
minSdk = 35
targetSdk = 36
versionCode = 16
versionName = "0.4.2"
versionName = "0.5.0-alpha01"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
Expand Down
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS" />

<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
Expand Down
13 changes: 13 additions & 0 deletions app/src/main/java/com/d4viddf/hyperbridge/data/AppPreferences.kt
Original file line number Diff line number Diff line change
Expand Up @@ -315,4 +315,17 @@ class AppPreferences(context: Context) {
dao.delete("widget_${id}_auto_update")
dao.delete("widget_${id}_update_interval")
}

// ========================================================================
// FAVORITE WIDGET APPS
// ========================================================================

val favoriteWidgetAppsFlow: Flow<Set<String>> = dao.getSettingFlow("favorite_widget_apps").map { it.deserializeSet() }

suspend fun toggleFavoriteWidgetApp(packageName: String, isFavorite: Boolean) {
val currentStr = dao.getSetting("favorite_widget_apps")
val currentSet = currentStr.deserializeSet()
val newSet = if (isFavorite) currentSet + packageName else currentSet - packageName
save("favorite_widget_apps", newSet.serialize())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,21 @@ data class GlobalConfig(
@SerialName("highlight_color") val highlightColor: String? = null,
@SerialName("background_color") val backgroundColor: String? = null,
@SerialName("text_color") val textColor: String? = "#FFFFFF",

@Deprecated("Use colorMode instead. Kept for backward compatibility with v0.4.x themes.")
@SerialName("use_app_colors") val useAppColors: Boolean = false,

// [NEW] Safe migration enum. Defaults to null for old JSONs.
@SerialName("color_mode") val colorMode: ColorMode? = null,

// SHAPE CONFIGURATION
@SerialName("icon_shape_id") val iconShapeId: String = "circle", // "circle", "square", "squircle", "cookie", "flower"
@SerialName("icon_padding_percent") val iconPaddingPercent: Int = 15
)
) {
// Safe getter: reads new enum, falls back to legacy boolean logic if null
val activeColorMode: ColorMode
get() = colorMode ?: if (useAppColors) ColorMode.APP_ICON else ColorMode.CUSTOM
}

@Serializable
data class CallModule(
Expand All @@ -53,17 +62,27 @@ data class CallModule(
@Serializable
data class AppThemeOverride(
@SerialName("highlight_color") val highlightColor: String? = null,
// [NEW] Added Shape and Padding overrides

@Deprecated("Use colorMode instead. Kept for backward compatibility with v0.4.x themes.")
@SerialName("use_app_colors") val useAppColors: Boolean? = null,

// [NEW] Safe migration enum for per-app overrides
@SerialName("color_mode") val colorMode: ColorMode? = null,

// [NEW] Added Shape and Padding overrides
@SerialName("icon_shape_id") val iconShapeId: String? = null,
@SerialName("icon_padding_percent") val iconPaddingPercent: Int? = null,

// [NEW] Added Call Config override
@SerialName("call_config") val callConfig: CallModule? = null,
val actions: Map<String, ActionConfig>? = null,
val progress: ProgressModule? = null,
val navigation: NavigationModule? = 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,
Expand Down Expand Up @@ -113,4 +132,10 @@ data class ThemeResource(val type: ResourceType, val value: String)

enum class ResourceType { PRESET_DRAWABLE, LOCAL_FILE, URI_CONTENT }

enum class ActionButtonMode { ICON, TEXT, BOTH }
enum class ActionButtonMode { ICON, TEXT, BOTH }

enum class ColorMode {
CUSTOM, // Uses selectedColorHex
APP_ICON, // Extracts color from the notification's app icon
MATERIAL_YOU // Extracts color from the system wallpaper (Monet)
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.service.notification.NotificationListenerService
Expand All @@ -26,6 +27,7 @@ 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.NavTranslator
import com.d4viddf.hyperbridge.service.translators.ProgressTranslator
Expand Down Expand Up @@ -54,7 +56,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 serviceScope = CoroutineScope(Dispatchers.Default + Job())

// --- STATE & CONFIG ---
Expand Down Expand Up @@ -89,6 +91,7 @@ class NotificationReaderService : NotificationListenerService() {
private lateinit var standardTranslator: StandardTranslator
private lateinit var mediaTranslator: MediaTranslator
private lateinit var widgetTranslator: WidgetTranslator
private lateinit var liveUpdateTranslator: LiveUpdateTranslator // [NEW]

@RequiresPermission(Manifest.permission.POST_NOTIFICATIONS)
override fun onCreate() {
Expand All @@ -100,12 +103,13 @@ class NotificationReaderService : NotificationListenerService() {
themeRepository = ThemeRepository(this)
rulesEngine = RulesEngine()

// [UPDATED] Pass ThemeRepository to Translators
// Pass ThemeRepository to Translators
callTranslator = CallTranslator(this, themeRepository)
navTranslator = NavTranslator(this, themeRepository)
timerTranslator = TimerTranslator(this, themeRepository)
progressTranslator = ProgressTranslator(this, themeRepository)
standardTranslator = StandardTranslator(this, themeRepository)
liveUpdateTranslator = LiveUpdateTranslator(this, themeRepository) // [NEW]

// Media and Widget translators don't necessarily need the repo yet
mediaTranslator = MediaTranslator(this)
Expand All @@ -118,15 +122,15 @@ class NotificationReaderService : NotificationListenerService() {
serviceScope.launch { preferences.appPriorityListFlow.collectLatest { appPriorityList = it } }
serviceScope.launch { preferences.globalBlockedTermsFlow.collectLatest { globalBlockedTerms = it } }

// [FIX] Listen for Theme Changes and update the Repository
// Listen for Theme Changes and update the Repository
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("") // Handle empty/null in repo logic implies default
themeRepository.activateTheme("")
}
}
}
Expand Down Expand Up @@ -160,7 +164,7 @@ class NotificationReaderService : NotificationListenerService() {
}
}
} else if (intent?.action == ACTION_RELOAD_THEME) {
// [NEW] Force reload current theme from disk
// Force reload current theme from disk
serviceScope.launch {
val themeId = preferences.activeThemeIdFlow.first()
if (themeId != null) {
Expand Down Expand Up @@ -348,13 +352,9 @@ class NotificationReaderService : NotificationListenerService() {
}

// [UPDATED] 4. Theme & Rules Interception
// A. Get Active Theme
val activeTheme = themeRepository.activeTheme.value

// B. Check Interceptor Rules
val ruleMatch = rulesEngine.match(sbn, effectiveTitle, effectiveText, activeTheme)

// C. Determine Type: If rule matched, FORCE that type. Else, use detection.
val type = if (ruleMatch?.targetLayout != null) {
try {
NotificationType.valueOf(ruleMatch.targetLayout)
Expand All @@ -366,7 +366,6 @@ class NotificationReaderService : NotificationListenerService() {
detectNotificationType(sbn)
}

// D. Check if app is enabled for this type (standard config check)
val config = preferences.getAppConfig(sbn.packageName).first()
if (!config.contains(type.name)) return

Expand All @@ -379,14 +378,66 @@ class NotificationReaderService : NotificationListenerService() {

val appIslandConfig = preferences.getAppIslandConfig(sbn.packageName).first()
val globalConfig = preferences.globalConfigFlow.first()

// Merge configs (Island behavior config, NOT theme style)
val finalConfig = appIslandConfig.mergeWith(globalConfig)

val bridgeId = sbn.key.hashCode()
val picKey = "pic_${bridgeId}"

// [CRITICAL UPDATE] Pass 'activeTheme' to all Translators
// ===================================================================
// [NEW LOGIC] NATIVE LIVE UPDATES FALLBACK
// ===================================================================
val useLiveUpdates = getSharedPreferences("hyperbridge_settings", Context.MODE_PRIVATE)
.getBoolean("use_native_live_updates", false)

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)

// Track original notification key for dismissal synchronization
builder.extras.putString(EXTRA_ORIGINAL_KEY, sbn.key)

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
}

NotificationManagerCompat.from(this).notify(bridgeId, notification)

activeTranslations[sbn.key] = bridgeId
reverseTranslations[bridgeId] = sbn.key

activeIslands[key] = ActiveIsland(
id = bridgeId, type = type, postTime = System.currentTimeMillis(),
packageName = sbn.packageName,
title = effectiveTitle,
text = effectiveText,
subText = "LiveUpdate",
lastContentHash = newContentHash
)
return
}
// ===================================================================

// [ORIGINAL LOGIC] Xiaomi Custom Island Injection
val data: HyperIslandData = when (type) {
NotificationType.CALL -> callTranslator.translate(sbn, picKey, finalConfig, activeTheme)
NotificationType.NAVIGATION -> {
Expand All @@ -395,14 +446,13 @@ class NotificationReaderService : NotificationListenerService() {
}
NotificationType.TIMER -> timerTranslator.translate(sbn, picKey, finalConfig, activeTheme)
NotificationType.PROGRESS -> progressTranslator.translate(sbn, effectiveTitle, picKey, finalConfig, activeTheme)
NotificationType.MEDIA -> mediaTranslator.translate(sbn, picKey, finalConfig) // Media usually keeps its own art
NotificationType.MEDIA -> mediaTranslator.translate(sbn, picKey, finalConfig)
else -> standardTranslator.translate(sbn, effectiveTitle, effectiveText, picKey, finalConfig, activeTheme)
}

val newContentHash = data.jsonParam.hashCode()
val previousIsland = activeIslands[key]

if (isUpdate && previousIsland != null && previousIsland.lastContentHash == newContentHash) {
if (isUpdate && previous != null && previous.lastContentHash == newContentHash) {
Log.d(TAG, " ABORTING: Content hash duplicate.")
return
}
Expand Down Expand Up @@ -534,10 +584,19 @@ class NotificationReaderService : NotificationListenerService() {
setSound(null, null); enableVibration(false); setShowBadge(false)
}
manager.createNotificationChannel(notifChannel)

val widgetChannel = NotificationChannel(WIDGET_CHANNEL_ID, "Widgets Overlay", NotificationManager.IMPORTANCE_LOW).apply {
setSound(null, null); enableVibration(false); setShowBadge(false)
}
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)
}
manager.createNotificationChannel(liveUpdateChannel)
}

private fun shouldProcessWidgetUpdate(widgetId: Int, config: WidgetConfig): Boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ class WidgetOverlayService : Service() {
const val WIDGET_CHANNEL_ID = "hyper_bridge_widget_channel"
const val ACTION_TEST_WIDGET = "ACTION_TEST_WIDGET"
const val ACTION_START_MONITORING = "ACTION_START_MONITORING"
const val ACTION_KILL_ALL_WIDGETS = "ACTION_KILL_ALL_WIDGETS"
const val ACTION_KILL_WIDGET = "ACTION_KILL_WIDGET"
}

private val serviceScope = CoroutineScope(Dispatchers.IO + Job())
Expand Down Expand Up @@ -73,12 +75,31 @@ class WidgetOverlayService : Service() {
}
}
}
ACTION_KILL_ALL_WIDGETS -> {
// Instantly kill all active widgets
serviceScope.launch(Dispatchers.IO) {
val savedIds = preferences.savedWidgetIdsFlow.first()
savedIds.forEach { id ->
notificationManager.cancel(9000 + id)
widgetUpdateDebouncer.remove(id)
}
}
}
ACTION_KILL_WIDGET -> {
// NEW: Instantly kill a specific widget
val widgetId = intent.getIntExtra("WIDGET_ID", -1)
if (widgetId != -1) {
notificationManager.cancel(9000 + widgetId)
widgetUpdateDebouncer.remove(widgetId) // Clean up memory
}
}
ACTION_START_MONITORING -> {
// Ensure service is sticky
}
}
return START_STICKY
}

@RequiresPermission(Manifest.permission.POST_NOTIFICATIONS)
private fun startMonitoringWidgets() {
serviceScope.launch {
Expand Down
Loading