Skip to content

Commit 9db74cd

Browse files
authored
Add support for notification actions. (#1573)
* Add support for notification actions. Remove Bookmark and Content Intent. * Fixes
1 parent 2524f40 commit 9db74cd

File tree

6 files changed

+187
-88
lines changed

6 files changed

+187
-88
lines changed

androidApp/src/main/java/dev/johnoreilly/confetti/MainActivity.kt

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,6 @@ class MainActivity : ComponentActivity() {
4242
val initialConferenceId = uri?.extractConferenceIdOrNull()
4343
val rootComponentContext = defaultComponentContext(discardSavedState = initialConferenceId != null)
4444

45-
// val settingsComponent = SettingsComponent(
46-
// componentContext = rootComponentContext.childContext("settings"),
47-
// appSettings = appSettings,
48-
// authentication = authentication,
49-
// )
50-
5145
val appComponent = DefaultAppComponent(
5246
componentContext = rootComponentContext.childContext("app"),
5347
initialConferenceId = initialConferenceId,
@@ -65,16 +59,6 @@ class MainActivity : ComponentActivity() {
6559
appComponent
6660
} ?: return
6761

68-
// // Update the theme settings
69-
// lifecycleScope.launch {
70-
// lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
71-
// appComponent.second.userEditableSettings.collect {
72-
// userEditableSettings = it
73-
// }
74-
// }
75-
// }
76-
//
77-
7862
setContent {
7963
App(component = appComponent)
8064
}
@@ -88,9 +72,8 @@ class MainActivity : ComponentActivity() {
8872
val path = path ?: return null
8973
if (path.firstOrNull() != '/') return null
9074
val parts = path.substring(1).split('/')
91-
if (parts.size != 2) return null
9275
if (parts[0] != "conference") return null
93-
val conferenceId = parts[1]
76+
val conferenceId = parts.getOrNull(1) ?: return null
9477
if (!conferenceId.all { it.isLetterOrDigit() }) return null
9578
return conferenceId
9679
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
<?xml version="1.0" encoding="utf-8"?>
22
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
33
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
4+
5+
<application>
6+
<receiver android:name="dev.johnoreilly.confetti.notifications.NotificationReceiver"/>
7+
</application>
48
</manifest>
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package dev.johnoreilly.confetti.notifications
2+
3+
import android.content.BroadcastReceiver
4+
import android.content.Context
5+
import android.content.Intent
6+
import androidx.core.app.NotificationManagerCompat
7+
import dev.johnoreilly.confetti.ConfettiRepository
8+
import dev.johnoreilly.confetti.auth.Authentication
9+
import kotlinx.coroutines.CoroutineScope
10+
import kotlinx.coroutines.launch
11+
import org.koin.core.component.KoinComponent
12+
import org.koin.core.component.inject
13+
import kotlin.coroutines.CoroutineContext
14+
import kotlin.coroutines.EmptyCoroutineContext
15+
16+
class NotificationReceiver: BroadcastReceiver(), KoinComponent {
17+
private val repository: ConfettiRepository by inject()
18+
private val appScope: CoroutineScope by inject()
19+
private val authentication: Authentication by inject()
20+
private val notificationManager: NotificationManagerCompat by inject()
21+
22+
override fun onReceive(context: Context, intent: Intent) {
23+
if (intent.action == "REMOVE_BOOKMARK") {
24+
doAsync {
25+
removeBookmark(intent)
26+
val notificationId = intent.getIntExtra("notificationId", -1)
27+
if (notificationId != -1) {
28+
notificationManager.cancel(notificationId)
29+
}
30+
}
31+
}
32+
}
33+
34+
private suspend fun removeBookmark(intent: Intent?) {
35+
val conference = intent?.getStringExtra("conferenceId") ?: return
36+
val sessionId = intent.getStringExtra("sessionId") ?: return
37+
val user = authentication.currentUser.value ?: return
38+
39+
repository.removeBookmark(conference, user.uid, user, sessionId)
40+
}
41+
42+
private fun BroadcastReceiver.doAsync(
43+
coroutineContext: CoroutineContext = EmptyCoroutineContext,
44+
block: suspend CoroutineScope.() -> Unit
45+
){
46+
val pendingResult = goAsync()
47+
appScope.launch(coroutineContext) { block() }.invokeOnCompletion { pendingResult.finish() }
48+
}
49+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package dev.johnoreilly.confetti.notifications
2+
3+
import android.app.NotificationManager
4+
import android.app.PendingIntent
5+
import android.content.Context
6+
import android.content.Intent
7+
import android.graphics.BitmapFactory
8+
import androidx.core.app.NotificationChannelCompat
9+
import androidx.core.app.NotificationCompat
10+
import androidx.core.net.toUri
11+
import dev.johnoreilly.confetti.fragment.SessionDetails
12+
import dev.johnoreilly.confetti.shared.R
13+
import dev.johnoreilly.confetti.work.SessionNotificationSender.Companion.CHANNEL_ID
14+
import dev.johnoreilly.confetti.work.SessionNotificationSender.Companion.GROUP
15+
16+
class SessionNotificationBuilder(
17+
private val context: Context,
18+
) {
19+
fun createNotification(session: SessionDetails, conferenceId: String, notificationId: Int): NotificationCompat.Builder {
20+
val largeIcon = BitmapFactory.decodeResource(
21+
context.resources,
22+
R.mipmap.ic_launcher_round
23+
)
24+
25+
return NotificationCompat
26+
.Builder(context, CHANNEL_ID)
27+
.setSmallIcon(R.mipmap.ic_launcher_round)
28+
.setLargeIcon(largeIcon)
29+
.setContentTitle(session.title)
30+
.setContentText("Starts at ${session.startsAt.time} in ${session.room?.name.orEmpty()}")
31+
.setGroup(GROUP)
32+
.setAutoCancel(false)
33+
.setLocalOnly(false)
34+
.setContentIntent(openSessionIntent(session, conferenceId, notificationId))
35+
.addAction(unbookmarkAction(conferenceId, session.id, notificationId))
36+
.extend(
37+
NotificationCompat.WearableExtender()
38+
.setBridgeTag("session:reminder")
39+
)
40+
}
41+
42+
private fun openSessionIntent(session: SessionDetails, conferenceId: String, notificationId: Int): PendingIntent? {
43+
return PendingIntent.getActivity(
44+
context,
45+
notificationId,
46+
Intent(Intent.ACTION_VIEW, "https://confetti-app.dev/conference/${conferenceId}/session/${session.id}".toUri()),
47+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
48+
)
49+
}
50+
51+
private fun unbookmarkAction(conferenceId: String, sessionId: String, notificationId: Int): NotificationCompat.Action {
52+
val unbookmarkIntent = PendingIntent.getBroadcast(
53+
context,
54+
notificationId,
55+
Intent(context, NotificationReceiver::class.java).apply {
56+
action = "REMOVE_BOOKMARK"
57+
putExtra("conferenceId", conferenceId)
58+
putExtra("sessionId", sessionId)
59+
putExtra("notificationId", notificationId)
60+
},
61+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
62+
)
63+
64+
return NotificationCompat.Action.Builder(null, "Remove Bookmark", unbookmarkIntent)
65+
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_ARCHIVE)
66+
.build()
67+
}
68+
69+
fun createChannel(): NotificationChannelCompat.Builder {
70+
val name = "Upcoming sessions"
71+
val importance = NotificationManager.IMPORTANCE_DEFAULT
72+
return NotificationChannelCompat.Builder(CHANNEL_ID, importance)
73+
.setName(name)
74+
.setDescription("Session reminders for upcoming sessions")
75+
.setShowBadge(true)
76+
}
77+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package dev.johnoreilly.confetti.notifications
2+
3+
import android.content.Context
4+
import android.graphics.BitmapFactory
5+
import androidx.core.app.NotificationCompat
6+
import dev.johnoreilly.confetti.fragment.SessionDetails
7+
import dev.johnoreilly.confetti.shared.R
8+
import dev.johnoreilly.confetti.work.SessionNotificationSender.Companion.CHANNEL_ID
9+
import dev.johnoreilly.confetti.work.SessionNotificationSender.Companion.GROUP
10+
11+
class SummaryNotificationBuilder(
12+
private val context: Context,
13+
) {
14+
15+
fun createSummaryNotification(sessions: List<SessionDetails>, notificationId: Int): NotificationCompat.Builder {
16+
val largeIcon = BitmapFactory.decodeResource(
17+
context.resources,
18+
R.mipmap.ic_launcher_round
19+
)
20+
21+
// Apply scope function is failing with an error:
22+
// InboxStyle.apply can only be called from within the same library group prefix.
23+
val style = NotificationCompat.InboxStyle()
24+
.setBigContentTitle("${sessions.count()} upcoming sessions")
25+
26+
// We only show up to a limited number of sessions to avoid pollute the user notifications.
27+
for (session in sessions.take(4)) {
28+
style.addLine(session.title)
29+
}
30+
31+
return NotificationCompat
32+
.Builder(context, CHANNEL_ID)
33+
.setSmallIcon(R.mipmap.ic_launcher_round)
34+
.setLargeIcon(largeIcon)
35+
.setGroup(GROUP)
36+
.setGroupSummary(true)
37+
.setAutoCancel(true)
38+
.setLocalOnly(false)
39+
.setStyle(style)
40+
.extend(NotificationCompat.WearableExtender().setBridgeTag("session:summary"))
41+
}
42+
}
Lines changed: 14 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,19 @@
11
package dev.johnoreilly.confetti.work
22

33
import android.app.Notification
4-
import android.app.NotificationChannel
5-
import android.app.NotificationManager
64
import android.content.Context
7-
import android.graphics.BitmapFactory
85
import android.os.Build
96
import android.util.Log
10-
import androidx.core.app.NotificationCompat
117
import androidx.core.app.NotificationManagerCompat
128
import com.apollographql.cache.normalized.FetchPolicy
139
import dev.johnoreilly.confetti.ConfettiRepository
1410
import dev.johnoreilly.confetti.auth.Authentication
15-
import dev.johnoreilly.confetti.fragment.SessionDetails
16-
import dev.johnoreilly.confetti.shared.R
11+
import dev.johnoreilly.confetti.notifications.SessionNotificationBuilder
12+
import dev.johnoreilly.confetti.notifications.SummaryNotificationBuilder
1713
import dev.johnoreilly.confetti.utils.DateService
1814
import dev.johnoreilly.confetti.work.NotificationSender.Selector
1915
import kotlinx.coroutines.flow.first
16+
import kotlin.random.Random
2017

2118
class SessionNotificationSender(
2219
private val context: Context,
@@ -25,14 +22,14 @@ class SessionNotificationSender(
2522
private val notificationManager: NotificationManagerCompat,
2623
private val authentication: Authentication,
2724
): NotificationSender {
25+
private val sessionNotificationBuilder = SessionNotificationBuilder(context)
26+
private val summaryNotificationBuilder = SummaryNotificationBuilder(context)
2827

2928
override suspend fun sendNotification(selector: Selector) {
3029
val notificationsEnabled = notificationManager.areNotificationsEnabled()
3130

32-
println("notificationsEnabled")
33-
3431
if (!notificationsEnabled) {
35-
// return
32+
return
3633
}
3734

3835
// If there is no signed-in user, skip.
@@ -88,74 +85,21 @@ class SessionNotificationSender(
8885

8986
// If there are multiple notifications, we create a summary to group them.
9087
if (upcomingSessions.count() > 1) {
91-
sendNotification(SUMMARY_ID, createSummaryNotification(upcomingSessions))
88+
sendNotification(SUMMARY_ID, summaryNotificationBuilder.createSummaryNotification(upcomingSessions, SUMMARY_ID).build())
9289
}
9390

9491
// We reverse the sessions to show early sessions first.
95-
for ((id, session) in upcomingSessions.reversed().withIndex()) {
96-
sendNotification(id, createNotification(session))
92+
for (session in upcomingSessions.reversed()) {
93+
val notificationId = Random.nextInt(Integer.MAX_VALUE / 2, Integer.MAX_VALUE)
94+
sendNotification(notificationId, sessionNotificationBuilder.createNotification(session, conferenceId, notificationId).build())
9795
}
9896
}
9997

10098
private fun createNotificationChannel() {
10199
// Channels are only available on Android O+.
102100
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
103101

104-
val name = "Upcoming sessions"
105-
val importance = NotificationManager.IMPORTANCE_DEFAULT
106-
val channel = NotificationChannel(CHANNEL_ID, name, importance).apply {
107-
description = ""
108-
}
109-
110-
notificationManager.createNotificationChannel(channel)
111-
}
112-
113-
private fun createNotification(session: SessionDetails): Notification {
114-
val largeIcon = BitmapFactory.decodeResource(
115-
context.resources,
116-
R.mipmap.ic_launcher_round
117-
)
118-
119-
return NotificationCompat
120-
.Builder(context, CHANNEL_ID)
121-
.setSmallIcon(R.mipmap.ic_launcher_round)
122-
.setLargeIcon(largeIcon)
123-
.setContentTitle(session.title)
124-
.setContentText("Starts at ${session.startsAt.time} in ${session.room?.name.orEmpty()}")
125-
.setGroup(GROUP)
126-
.setAutoCancel(true)
127-
.setLocalOnly(false)
128-
.extend(NotificationCompat.WearableExtender().setBridgeTag("session:reminder"))
129-
.build()
130-
}
131-
132-
private fun createSummaryNotification(sessions: List<SessionDetails>): Notification {
133-
val largeIcon = BitmapFactory.decodeResource(
134-
context.resources,
135-
R.mipmap.ic_launcher_round
136-
)
137-
138-
// Apply scope function is failing with an error:
139-
// InboxStyle.apply can only be called from within the same library group prefix.
140-
val style = NotificationCompat.InboxStyle()
141-
.setBigContentTitle("${sessions.count()} upcoming sessions")
142-
143-
// We only show up to a limited number of sessions to avoid pollute the user notifications.
144-
for (session in sessions.take(4)) {
145-
style.addLine(session.title)
146-
}
147-
148-
return NotificationCompat
149-
.Builder(context, CHANNEL_ID)
150-
.setSmallIcon(R.mipmap.ic_launcher_round)
151-
.setLargeIcon(largeIcon)
152-
.setGroup(GROUP)
153-
.setGroupSummary(true)
154-
.setAutoCancel(true)
155-
.setLocalOnly(false)
156-
.setStyle(style)
157-
.extend(NotificationCompat.WearableExtender().setBridgeTag("session:summary"))
158-
.build()
102+
notificationManager.createNotificationChannel(sessionNotificationBuilder.createChannel().build())
159103
}
160104

161105
private fun sendNotification(id: Int, notification: Notification) {
@@ -167,8 +111,8 @@ class SessionNotificationSender(
167111
}
168112

169113
companion object {
170-
private val CHANNEL_ID = "SessionNotification"
171-
private val GROUP = "dev.johnoreilly.confetti.SESSIONS_ALERT"
172-
private val SUMMARY_ID = 0
114+
internal val CHANNEL_ID = "SessionNotification"
115+
internal val GROUP = "dev.johnoreilly.confetti.SESSIONS_ALERT"
116+
private val SUMMARY_ID = 10
173117
}
174118
}

0 commit comments

Comments
 (0)