Skip to content

Commit 498e153

Browse files
committed
android: add Live Update notification with channel, settings toggle, and routing
1 parent d0da597 commit 498e153

6 files changed

Lines changed: 313 additions & 3 deletions

File tree

android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,20 @@ fun AppSettingsScreen(
197197
onCheckedChange = viewModel::setShowIslandPopup,
198198
independent = false
199199
)
200+
201+
HorizontalDivider(
202+
thickness = 1.dp,
203+
color = Color(0x40888888),
204+
modifier = Modifier.padding(horizontal = 12.dp)
205+
)
206+
207+
StyledToggle(
208+
label = stringResource(R.string.show_live_update_notification),
209+
description = stringResource(R.string.show_live_update_notification_description),
210+
checked = state.showLiveUpdateNotification,
211+
onCheckedChange = viewModel::setShowLiveUpdateNotification,
212+
independent = false
213+
)
200214
}
201215

202216
Text(

android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AppSettingsViewModel.kt

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ data class AppSettingsUiState(
3434
val isPremium: Boolean = false,
3535
val connectionSuccessful: Boolean = false,
3636
val showBottomSheetPopup: Boolean = true,
37-
val showIslandPopup: Boolean = true
37+
val showIslandPopup: Boolean = true,
38+
val showLiveUpdateNotification: Boolean = true
3839
)
3940

4041
class AppSettingsViewModel(application: Application) : AndroidViewModel(application) {
@@ -72,6 +73,13 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat
7273
}
7374

7475
private fun loadSettings() {
76+
if (!sharedPreferences.contains("show_live_update_notification")) {
77+
val isUpgrader = sharedPreferences.getBoolean("connection_successful", false)
78+
if (isUpgrader) {
79+
sharedPreferences.edit { putBoolean("show_live_update_notification", false) }
80+
}
81+
}
82+
7583
_uiState.update { currentState ->
7684
currentState.copy(
7785
showPhoneBatteryInWidget = sharedPreferences.getBoolean("show_phone_battery_in_widget", false),
@@ -90,7 +98,8 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat
9098
vendorIdHook = xposedRemotePref.getBoolean("vendor_id_hook", false),
9199
connectionSuccessful = sharedPreferences.getBoolean("connection_successful", false),
92100
showBottomSheetPopup = sharedPreferences.getBoolean("show_bottom_sheet_popup", true),
93-
showIslandPopup = sharedPreferences.getBoolean("show_island_popup", true)
101+
showIslandPopup = sharedPreferences.getBoolean("show_island_popup", true),
102+
showLiveUpdateNotification = sharedPreferences.getBoolean("show_live_update_notification", true)
94103
)
95104
}
96105
}
@@ -190,4 +199,9 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat
190199
sharedPreferences.edit { putBoolean("show_island_popup", enabled) }
191200
_uiState.update { it.copy(showIslandPopup = enabled) }
192201
}
202+
203+
fun setShowLiveUpdateNotification(enabled: Boolean) {
204+
sharedPreferences.edit { putBoolean("show_live_update_notification", enabled) }
205+
_uiState.update { it.copy(showLiveUpdateNotification = enabled) }
206+
}
193207
}

android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager
9191
import me.kavishdevar.librepods.data.AirPodsInstance
9292
import me.kavishdevar.librepods.data.AirPodsModels
9393
import me.kavishdevar.librepods.data.AirPodsNotifications
94+
import me.kavishdevar.librepods.services.notifications.LiveUpdateNotification
9495
import me.kavishdevar.librepods.data.Battery
9596
import me.kavishdevar.librepods.data.BatteryComponent
9697
import me.kavishdevar.librepods.data.BatteryStatus
@@ -1739,6 +1740,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
17391740
notificationManager.createNotificationChannel(disconnectedNotificationChannel)
17401741
notificationManager.createNotificationChannel(connectedNotificationChannel)
17411742
notificationManager.createNotificationChannel(socketFailureChannel)
1743+
LiveUpdateNotification.ensureChannel(this)
17421744

17431745
val notificationSettingsIntent =
17441746
Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply {
@@ -2030,11 +2032,25 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
20302032
if (!::socket.isInitialized) {
20312033
return
20322034
}
2035+
2036+
val liveEnabled = sharedPreferences.getBoolean("show_live_update_notification", true)
2037+
val resolvedName = airpodsName ?: config.deviceName ?: "AirPods"
2038+
20332039
if (connected && (config.bleOnlyMode || socket.isConnected)) {
2040+
if (liveEnabled && batteryList != null) {
2041+
LiveUpdateNotification.update(this, resolvedName, batteryList)
2042+
LiveUpdateNotification.checkLowBattery(this, batteryList)
2043+
notificationManager.cancel(1)
2044+
notificationManager.cancel(2)
2045+
return
2046+
}
2047+
2048+
LiveUpdateNotification.cancelAll(this)
2049+
20342050
val updatedNotificationBuilder =
20352051
NotificationCompat.Builder(this, "airpods_connection_status")
20362052
.setSmallIcon(R.drawable.airpods)
2037-
.setContentTitle(airpodsName ?: config.deviceName).setContentText(
2053+
.setContentTitle(resolvedName).setContentText(
20382054
"""${
20392055
batteryList?.find { it.component == BatteryComponent.LEFT }?.let {
20402056
if (it.status != BatteryStatus.DISCONNECTED) {
@@ -2078,6 +2094,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
20782094
notificationManager.cancel(1)
20792095
} else if (!connected) {
20802096
notificationManager.cancel(2)
2097+
LiveUpdateNotification.cancelAll(this)
2098+
LiveUpdateNotification.resetState()
20812099
} else if (!config.bleOnlyMode && !socket.isConnected) {
20822100
showSocketConnectionFailureNotification("Socket created, but not connected. Check logs")
20832101
}
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
2+
3+
package me.kavishdevar.librepods.services.notifications
4+
5+
import android.app.NotificationChannel
6+
import android.app.NotificationManager
7+
import android.app.PendingIntent
8+
import android.content.Context
9+
import android.content.Intent
10+
import androidx.core.app.NotificationCompat
11+
import androidx.core.graphics.drawable.IconCompat
12+
import me.kavishdevar.librepods.MainActivity
13+
import me.kavishdevar.librepods.R
14+
import me.kavishdevar.librepods.data.Battery
15+
import me.kavishdevar.librepods.data.BatteryComponent
16+
import me.kavishdevar.librepods.data.BatteryStatus
17+
18+
object LiveUpdateNotification {
19+
const val CHANNEL_ID = "airpods_live_update"
20+
const val NOTIF_ID_MAIN = 4
21+
const val NOTIF_ID_LOW_LEFT = 5
22+
const val NOTIF_ID_LOW_RIGHT = 6
23+
const val NOTIF_ID_LOW_CASE = 7
24+
const val NOTIF_ID_CASE_OPEN = 8
25+
26+
private val state = LiveUpdateState()
27+
28+
fun resetState() {
29+
state.reset()
30+
}
31+
32+
fun ensureChannel(context: Context) {
33+
val nm = context.getSystemService(NotificationManager::class.java)
34+
if (nm.getNotificationChannel(CHANNEL_ID) != null) return
35+
val channel = NotificationChannel(
36+
CHANNEL_ID,
37+
context.getString(R.string.live_update_channel_name),
38+
NotificationManager.IMPORTANCE_HIGH
39+
).apply {
40+
description = context.getString(R.string.live_update_channel_description)
41+
enableVibration(false)
42+
setSound(null, null)
43+
}
44+
nm.createNotificationChannel(channel)
45+
}
46+
47+
fun show(
48+
context: Context,
49+
airpodsName: String,
50+
batteryList: List<Battery>,
51+
headsUp: Boolean
52+
) {
53+
val nm = context.getSystemService(NotificationManager::class.java)
54+
val builder = buildMain(context, airpodsName, batteryList, headsUp)
55+
nm.notify(NOTIF_ID_MAIN, builder.build())
56+
}
57+
58+
fun update(
59+
context: Context,
60+
airpodsName: String,
61+
batteryList: List<Battery>
62+
) {
63+
val nm = context.getSystemService(NotificationManager::class.java)
64+
val builder = buildMain(context, airpodsName, batteryList, headsUp = false)
65+
nm.notify(NOTIF_ID_MAIN, builder.build())
66+
}
67+
68+
fun checkLowBattery(context: Context, batteryList: List<Battery>) {
69+
val nm = context.getSystemService(NotificationManager::class.java)
70+
val pi = mainActivityPendingIntent(context)
71+
72+
batteryList.forEach { battery ->
73+
if (battery.status == BatteryStatus.DISCONNECTED) return@forEach
74+
if (!state.shouldFireLowBattery(battery.component, battery.level)) return@forEach
75+
76+
val (titleRes, notifId) = when (battery.component) {
77+
BatteryComponent.LEFT -> R.string.live_update_low_battery_left to NOTIF_ID_LOW_LEFT
78+
BatteryComponent.RIGHT -> R.string.live_update_low_battery_right to NOTIF_ID_LOW_RIGHT
79+
BatteryComponent.CASE -> R.string.live_update_low_battery_case to NOTIF_ID_LOW_CASE
80+
else -> return@forEach
81+
}
82+
83+
val notif = NotificationCompat.Builder(context, CHANNEL_ID)
84+
.setSmallIcon(R.drawable.airpods)
85+
.setContentTitle(context.getString(titleRes, battery.level))
86+
.setContentText(context.getString(R.string.live_update_low_battery_body))
87+
.setContentIntent(pi)
88+
.setPriority(NotificationCompat.PRIORITY_HIGH)
89+
.setAutoCancel(true)
90+
.setOnlyAlertOnce(false)
91+
.build()
92+
nm.notify(notifId, notif)
93+
}
94+
}
95+
96+
fun showCaseOpenReminder(context: Context, batteryList: List<Battery>) {
97+
val caseBattery = batteryList.firstOrNull { it.component == BatteryComponent.CASE }
98+
?: return
99+
if (caseBattery.status == BatteryStatus.DISCONNECTED) return
100+
if (!state.shouldFireCaseOpenReminder(caseBattery.level)) return
101+
102+
val nm = context.getSystemService(NotificationManager::class.java)
103+
val notif = NotificationCompat.Builder(context, CHANNEL_ID)
104+
.setSmallIcon(R.drawable.airpods)
105+
.setContentTitle(context.getString(R.string.live_update_case_open_title))
106+
.setContentText(context.getString(R.string.live_update_case_open_body, caseBattery.level))
107+
.setContentIntent(mainActivityPendingIntent(context))
108+
.setPriority(NotificationCompat.PRIORITY_HIGH)
109+
.setAutoCancel(true)
110+
.setOnlyAlertOnce(false)
111+
.build()
112+
nm.notify(NOTIF_ID_CASE_OPEN, notif)
113+
}
114+
115+
fun resetCaseOpenReminderOnLidClose() {
116+
state.resetCaseOpenReminder()
117+
}
118+
119+
fun headsUpOnListeningModeChange(
120+
context: Context,
121+
airpodsName: String,
122+
batteryList: List<Battery>,
123+
newMode: Byte
124+
) {
125+
if (!state.shouldFireListeningModeChange(newMode)) return
126+
show(context, airpodsName, batteryList, headsUp = true)
127+
}
128+
129+
fun cancelAll(context: Context) {
130+
val nm = context.getSystemService(NotificationManager::class.java)
131+
nm.cancel(NOTIF_ID_MAIN)
132+
nm.cancel(NOTIF_ID_LOW_LEFT)
133+
nm.cancel(NOTIF_ID_LOW_RIGHT)
134+
nm.cancel(NOTIF_ID_LOW_CASE)
135+
nm.cancel(NOTIF_ID_CASE_OPEN)
136+
}
137+
138+
private fun buildMain(
139+
context: Context,
140+
airpodsName: String,
141+
batteryList: List<Battery>,
142+
headsUp: Boolean
143+
): NotificationCompat.Builder {
144+
val left = batteryList.firstOrNull { it.component == BatteryComponent.LEFT }
145+
val right = batteryList.firstOrNull { it.component == BatteryComponent.RIGHT }
146+
val case = batteryList.firstOrNull { it.component == BatteryComponent.CASE }
147+
148+
val lowestEar = listOfNotNull(left, right)
149+
.filter { it.status != BatteryStatus.DISCONNECTED }
150+
.minByOrNull { it.level }
151+
?.level ?: 0
152+
153+
val expandedBody = buildString {
154+
left?.takeIf { it.status != BatteryStatus.DISCONNECTED }?.let {
155+
append("L ")
156+
if (it.status == BatteryStatus.CHARGING) append("")
157+
append("${it.level}%")
158+
}
159+
right?.takeIf { it.status != BatteryStatus.DISCONNECTED }?.let {
160+
if (isNotEmpty()) append(" · ")
161+
append("R ")
162+
if (it.status == BatteryStatus.CHARGING) append("")
163+
append("${it.level}%")
164+
}
165+
case?.takeIf { it.status != BatteryStatus.DISCONNECTED }?.let {
166+
if (isNotEmpty()) append(" · ")
167+
append("Case ")
168+
if (it.status == BatteryStatus.CHARGING) append("")
169+
append("${it.level}%")
170+
}
171+
}
172+
173+
val progressStyle = NotificationCompat.ProgressStyle()
174+
.setProgress(lowestEar)
175+
.setProgressTrackerIcon(
176+
IconCompat.createWithResource(context, R.drawable.airpods)
177+
)
178+
179+
return NotificationCompat.Builder(context, CHANNEL_ID)
180+
.setSmallIcon(R.drawable.airpods)
181+
.setContentTitle(airpodsName)
182+
.setContentText(if (expandedBody.isNotEmpty()) expandedBody else "$lowestEar%")
183+
.setStyle(progressStyle)
184+
.setOngoing(true)
185+
.setContentIntent(mainActivityPendingIntent(context))
186+
.setPriority(if (headsUp) NotificationCompat.PRIORITY_HIGH else NotificationCompat.PRIORITY_DEFAULT)
187+
.setOnlyAlertOnce(!headsUp)
188+
.setRequestPromotedOngoing(true)
189+
}
190+
191+
private fun mainActivityPendingIntent(context: Context): PendingIntent {
192+
return PendingIntent.getActivity(
193+
context,
194+
0,
195+
Intent(context, MainActivity::class.java),
196+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
197+
)
198+
}
199+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package me.kavishdevar.librepods.services.notifications
2+
3+
import me.kavishdevar.librepods.data.BatteryComponent
4+
5+
class LiveUpdateState {
6+
private val lowBatteryFired = mutableMapOf(
7+
BatteryComponent.LEFT to false,
8+
BatteryComponent.RIGHT to false,
9+
BatteryComponent.CASE to false
10+
)
11+
12+
var lastListeningMode: Byte? = null
13+
var caseOpenReminderFired = false
14+
15+
fun shouldFireLowBattery(component: Int, level: Int): Boolean {
16+
val previouslyFired = lowBatteryFired[component] ?: false
17+
return when {
18+
level <= 15 && !previouslyFired -> {
19+
lowBatteryFired[component] = true
20+
true
21+
}
22+
level > 30 && previouslyFired -> {
23+
lowBatteryFired[component] = false
24+
false
25+
}
26+
else -> false
27+
}
28+
}
29+
30+
fun resetCaseOpenReminder() {
31+
caseOpenReminderFired = false
32+
}
33+
34+
fun shouldFireCaseOpenReminder(caseLevel: Int): Boolean {
35+
if (caseLevel < 30 && !caseOpenReminderFired) {
36+
caseOpenReminderFired = true
37+
return true
38+
}
39+
return false
40+
}
41+
42+
fun shouldFireListeningModeChange(newMode: Byte): Boolean {
43+
if (lastListeningMode != newMode) {
44+
lastListeningMode = newMode
45+
return true
46+
}
47+
return false
48+
}
49+
50+
fun reset() {
51+
lowBatteryFired.keys.toList().forEach { lowBatteryFired[it] = false }
52+
lastListeningMode = null
53+
caseOpenReminderFired = false
54+
}
55+
}

android/app/src/main/res/values/strings.xml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,16 @@
145145
<string name="show_bottom_sheet_popup_description">Show the iOS-style modal popup at the bottom when AirPods connect.</string>
146146
<string name="show_island_popup">Dynamic Island popup</string>
147147
<string name="show_island_popup_description">Show the Dynamic Island-style popup at the top for connection and takeover events.</string>
148+
<string name="show_live_update_notification">Live Update notification</string>
149+
<string name="show_live_update_notification_description">Persistent notification with battery and connection state. Pops up on connection, takeover, listening-mode change, and low battery. When off, legacy notification is shown.</string>
150+
<string name="live_update_low_battery_left">Left AirPod %1$d%%</string>
151+
<string name="live_update_low_battery_right">Right AirPod %1$d%%</string>
152+
<string name="live_update_low_battery_case">AirPods Case %1$d%%</string>
153+
<string name="live_update_low_battery_body">Low battery</string>
154+
<string name="live_update_case_open_title">Case battery low</string>
155+
<string name="live_update_case_open_body">Charge soon — currently %1$d%%</string>
156+
<string name="live_update_channel_name">AirPods Live Update</string>
157+
<string name="live_update_channel_description">Persistent battery and connection state notification with heads-up on important events.</string>
148158
<string name="conversational_awareness_volume">Conversational Awareness Volume</string>
149159
<string name="quick_settings_tile">Quick Settings Tile</string>
150160
<string name="open_dialog_for_controlling">Open dialog for controlling</string>

0 commit comments

Comments
 (0)