diff --git a/androidApp/src/main/java/dev/johnoreilly/confetti/MainActivity.kt b/androidApp/src/main/java/dev/johnoreilly/confetti/MainActivity.kt
index 683e2a0f0..45121c0b7 100644
--- a/androidApp/src/main/java/dev/johnoreilly/confetti/MainActivity.kt
+++ b/androidApp/src/main/java/dev/johnoreilly/confetti/MainActivity.kt
@@ -42,12 +42,6 @@ class MainActivity : ComponentActivity() {
val initialConferenceId = uri?.extractConferenceIdOrNull()
val rootComponentContext = defaultComponentContext(discardSavedState = initialConferenceId != null)
-// val settingsComponent = SettingsComponent(
-// componentContext = rootComponentContext.childContext("settings"),
-// appSettings = appSettings,
-// authentication = authentication,
-// )
-
val appComponent = DefaultAppComponent(
componentContext = rootComponentContext.childContext("app"),
initialConferenceId = initialConferenceId,
@@ -65,16 +59,6 @@ class MainActivity : ComponentActivity() {
appComponent
} ?: return
-// // Update the theme settings
-// lifecycleScope.launch {
-// lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
-// appComponent.second.userEditableSettings.collect {
-// userEditableSettings = it
-// }
-// }
-// }
-//
-
setContent {
App(component = appComponent)
}
@@ -88,9 +72,8 @@ class MainActivity : ComponentActivity() {
val path = path ?: return null
if (path.firstOrNull() != '/') return null
val parts = path.substring(1).split('/')
- if (parts.size != 2) return null
if (parts[0] != "conference") return null
- val conferenceId = parts[1]
+ val conferenceId = parts.getOrNull(1) ?: return null
if (!conferenceId.all { it.isLetterOrDigit() }) return null
return conferenceId
}
diff --git a/shared/src/androidMain/AndroidManifest.xml b/shared/src/androidMain/AndroidManifest.xml
index fdd2360d8..1f14db83a 100644
--- a/shared/src/androidMain/AndroidManifest.xml
+++ b/shared/src/androidMain/AndroidManifest.xml
@@ -1,4 +1,8 @@
+
+
+
+
diff --git a/shared/src/androidMain/kotlin/dev/johnoreilly/confetti/notifications/NotificationReceiver.kt b/shared/src/androidMain/kotlin/dev/johnoreilly/confetti/notifications/NotificationReceiver.kt
new file mode 100644
index 000000000..d6b8ba71e
--- /dev/null
+++ b/shared/src/androidMain/kotlin/dev/johnoreilly/confetti/notifications/NotificationReceiver.kt
@@ -0,0 +1,49 @@
+package dev.johnoreilly.confetti.notifications
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import androidx.core.app.NotificationManagerCompat
+import dev.johnoreilly.confetti.ConfettiRepository
+import dev.johnoreilly.confetti.auth.Authentication
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.EmptyCoroutineContext
+
+class NotificationReceiver: BroadcastReceiver(), KoinComponent {
+ private val repository: ConfettiRepository by inject()
+ private val appScope: CoroutineScope by inject()
+ private val authentication: Authentication by inject()
+ private val notificationManager: NotificationManagerCompat by inject()
+
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action == "REMOVE_BOOKMARK") {
+ doAsync {
+ removeBookmark(intent)
+ val notificationId = intent.getIntExtra("notificationId", -1)
+ if (notificationId != -1) {
+ notificationManager.cancel(notificationId)
+ }
+ }
+ }
+ }
+
+ private suspend fun removeBookmark(intent: Intent?) {
+ val conference = intent?.getStringExtra("conferenceId") ?: return
+ val sessionId = intent.getStringExtra("sessionId") ?: return
+ val user = authentication.currentUser.value ?: return
+
+ repository.removeBookmark(conference, user.uid, user, sessionId)
+ }
+
+ private fun BroadcastReceiver.doAsync(
+ coroutineContext: CoroutineContext = EmptyCoroutineContext,
+ block: suspend CoroutineScope.() -> Unit
+ ){
+ val pendingResult = goAsync()
+ appScope.launch(coroutineContext) { block() }.invokeOnCompletion { pendingResult.finish() }
+ }
+}
\ No newline at end of file
diff --git a/shared/src/androidMain/kotlin/dev/johnoreilly/confetti/notifications/SessionNotificationBuilder.kt b/shared/src/androidMain/kotlin/dev/johnoreilly/confetti/notifications/SessionNotificationBuilder.kt
new file mode 100644
index 000000000..d0e63bfd0
--- /dev/null
+++ b/shared/src/androidMain/kotlin/dev/johnoreilly/confetti/notifications/SessionNotificationBuilder.kt
@@ -0,0 +1,77 @@
+package dev.johnoreilly.confetti.notifications
+
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.graphics.BitmapFactory
+import androidx.core.app.NotificationChannelCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.net.toUri
+import dev.johnoreilly.confetti.fragment.SessionDetails
+import dev.johnoreilly.confetti.shared.R
+import dev.johnoreilly.confetti.work.SessionNotificationSender.Companion.CHANNEL_ID
+import dev.johnoreilly.confetti.work.SessionNotificationSender.Companion.GROUP
+
+class SessionNotificationBuilder(
+ private val context: Context,
+) {
+ fun createNotification(session: SessionDetails, conferenceId: String, notificationId: Int): NotificationCompat.Builder {
+ val largeIcon = BitmapFactory.decodeResource(
+ context.resources,
+ R.mipmap.ic_launcher_round
+ )
+
+ return NotificationCompat
+ .Builder(context, CHANNEL_ID)
+ .setSmallIcon(R.mipmap.ic_launcher_round)
+ .setLargeIcon(largeIcon)
+ .setContentTitle(session.title)
+ .setContentText("Starts at ${session.startsAt.time} in ${session.room?.name.orEmpty()}")
+ .setGroup(GROUP)
+ .setAutoCancel(false)
+ .setLocalOnly(false)
+ .setContentIntent(openSessionIntent(session, conferenceId, notificationId))
+ .addAction(unbookmarkAction(conferenceId, session.id, notificationId))
+ .extend(
+ NotificationCompat.WearableExtender()
+ .setBridgeTag("session:reminder")
+ )
+ }
+
+ private fun openSessionIntent(session: SessionDetails, conferenceId: String, notificationId: Int): PendingIntent? {
+ return PendingIntent.getActivity(
+ context,
+ notificationId,
+ Intent(Intent.ACTION_VIEW, "https://confetti-app.dev/conference/${conferenceId}/session/${session.id}".toUri()),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ }
+
+ private fun unbookmarkAction(conferenceId: String, sessionId: String, notificationId: Int): NotificationCompat.Action {
+ val unbookmarkIntent = PendingIntent.getBroadcast(
+ context,
+ notificationId,
+ Intent(context, NotificationReceiver::class.java).apply {
+ action = "REMOVE_BOOKMARK"
+ putExtra("conferenceId", conferenceId)
+ putExtra("sessionId", sessionId)
+ putExtra("notificationId", notificationId)
+ },
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ return NotificationCompat.Action.Builder(null, "Remove Bookmark", unbookmarkIntent)
+ .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_ARCHIVE)
+ .build()
+ }
+
+ fun createChannel(): NotificationChannelCompat.Builder {
+ val name = "Upcoming sessions"
+ val importance = NotificationManager.IMPORTANCE_DEFAULT
+ return NotificationChannelCompat.Builder(CHANNEL_ID, importance)
+ .setName(name)
+ .setDescription("Session reminders for upcoming sessions")
+ .setShowBadge(true)
+ }
+}
\ No newline at end of file
diff --git a/shared/src/androidMain/kotlin/dev/johnoreilly/confetti/notifications/SummaryNotificationBuilder.kt b/shared/src/androidMain/kotlin/dev/johnoreilly/confetti/notifications/SummaryNotificationBuilder.kt
new file mode 100644
index 000000000..368d686ba
--- /dev/null
+++ b/shared/src/androidMain/kotlin/dev/johnoreilly/confetti/notifications/SummaryNotificationBuilder.kt
@@ -0,0 +1,42 @@
+package dev.johnoreilly.confetti.notifications
+
+import android.content.Context
+import android.graphics.BitmapFactory
+import androidx.core.app.NotificationCompat
+import dev.johnoreilly.confetti.fragment.SessionDetails
+import dev.johnoreilly.confetti.shared.R
+import dev.johnoreilly.confetti.work.SessionNotificationSender.Companion.CHANNEL_ID
+import dev.johnoreilly.confetti.work.SessionNotificationSender.Companion.GROUP
+
+class SummaryNotificationBuilder(
+ private val context: Context,
+) {
+
+ fun createSummaryNotification(sessions: List, notificationId: Int): NotificationCompat.Builder {
+ val largeIcon = BitmapFactory.decodeResource(
+ context.resources,
+ R.mipmap.ic_launcher_round
+ )
+
+ // Apply scope function is failing with an error:
+ // InboxStyle.apply can only be called from within the same library group prefix.
+ val style = NotificationCompat.InboxStyle()
+ .setBigContentTitle("${sessions.count()} upcoming sessions")
+
+ // We only show up to a limited number of sessions to avoid pollute the user notifications.
+ for (session in sessions.take(4)) {
+ style.addLine(session.title)
+ }
+
+ return NotificationCompat
+ .Builder(context, CHANNEL_ID)
+ .setSmallIcon(R.mipmap.ic_launcher_round)
+ .setLargeIcon(largeIcon)
+ .setGroup(GROUP)
+ .setGroupSummary(true)
+ .setAutoCancel(true)
+ .setLocalOnly(false)
+ .setStyle(style)
+ .extend(NotificationCompat.WearableExtender().setBridgeTag("session:summary"))
+ }
+}
\ No newline at end of file
diff --git a/shared/src/androidMain/kotlin/dev/johnoreilly/confetti/work/SessionNotificationSender.kt b/shared/src/androidMain/kotlin/dev/johnoreilly/confetti/work/SessionNotificationSender.kt
index 83962d49b..d6dca53ff 100644
--- a/shared/src/androidMain/kotlin/dev/johnoreilly/confetti/work/SessionNotificationSender.kt
+++ b/shared/src/androidMain/kotlin/dev/johnoreilly/confetti/work/SessionNotificationSender.kt
@@ -1,22 +1,19 @@
package dev.johnoreilly.confetti.work
import android.app.Notification
-import android.app.NotificationChannel
-import android.app.NotificationManager
import android.content.Context
-import android.graphics.BitmapFactory
import android.os.Build
import android.util.Log
-import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.apollographql.cache.normalized.FetchPolicy
import dev.johnoreilly.confetti.ConfettiRepository
import dev.johnoreilly.confetti.auth.Authentication
-import dev.johnoreilly.confetti.fragment.SessionDetails
-import dev.johnoreilly.confetti.shared.R
+import dev.johnoreilly.confetti.notifications.SessionNotificationBuilder
+import dev.johnoreilly.confetti.notifications.SummaryNotificationBuilder
import dev.johnoreilly.confetti.utils.DateService
import dev.johnoreilly.confetti.work.NotificationSender.Selector
import kotlinx.coroutines.flow.first
+import kotlin.random.Random
class SessionNotificationSender(
private val context: Context,
@@ -25,14 +22,14 @@ class SessionNotificationSender(
private val notificationManager: NotificationManagerCompat,
private val authentication: Authentication,
): NotificationSender {
+ private val sessionNotificationBuilder = SessionNotificationBuilder(context)
+ private val summaryNotificationBuilder = SummaryNotificationBuilder(context)
override suspend fun sendNotification(selector: Selector) {
val notificationsEnabled = notificationManager.areNotificationsEnabled()
- println("notificationsEnabled")
-
if (!notificationsEnabled) {
-// return
+ return
}
// If there is no signed-in user, skip.
@@ -88,12 +85,13 @@ class SessionNotificationSender(
// If there are multiple notifications, we create a summary to group them.
if (upcomingSessions.count() > 1) {
- sendNotification(SUMMARY_ID, createSummaryNotification(upcomingSessions))
+ sendNotification(SUMMARY_ID, summaryNotificationBuilder.createSummaryNotification(upcomingSessions, SUMMARY_ID).build())
}
// We reverse the sessions to show early sessions first.
- for ((id, session) in upcomingSessions.reversed().withIndex()) {
- sendNotification(id, createNotification(session))
+ for (session in upcomingSessions.reversed()) {
+ val notificationId = Random.nextInt(Integer.MAX_VALUE / 2, Integer.MAX_VALUE)
+ sendNotification(notificationId, sessionNotificationBuilder.createNotification(session, conferenceId, notificationId).build())
}
}
@@ -101,61 +99,7 @@ class SessionNotificationSender(
// Channels are only available on Android O+.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
- val name = "Upcoming sessions"
- val importance = NotificationManager.IMPORTANCE_DEFAULT
- val channel = NotificationChannel(CHANNEL_ID, name, importance).apply {
- description = ""
- }
-
- notificationManager.createNotificationChannel(channel)
- }
-
- private fun createNotification(session: SessionDetails): Notification {
- val largeIcon = BitmapFactory.decodeResource(
- context.resources,
- R.mipmap.ic_launcher_round
- )
-
- return NotificationCompat
- .Builder(context, CHANNEL_ID)
- .setSmallIcon(R.mipmap.ic_launcher_round)
- .setLargeIcon(largeIcon)
- .setContentTitle(session.title)
- .setContentText("Starts at ${session.startsAt.time} in ${session.room?.name.orEmpty()}")
- .setGroup(GROUP)
- .setAutoCancel(true)
- .setLocalOnly(false)
- .extend(NotificationCompat.WearableExtender().setBridgeTag("session:reminder"))
- .build()
- }
-
- private fun createSummaryNotification(sessions: List): Notification {
- val largeIcon = BitmapFactory.decodeResource(
- context.resources,
- R.mipmap.ic_launcher_round
- )
-
- // Apply scope function is failing with an error:
- // InboxStyle.apply can only be called from within the same library group prefix.
- val style = NotificationCompat.InboxStyle()
- .setBigContentTitle("${sessions.count()} upcoming sessions")
-
- // We only show up to a limited number of sessions to avoid pollute the user notifications.
- for (session in sessions.take(4)) {
- style.addLine(session.title)
- }
-
- return NotificationCompat
- .Builder(context, CHANNEL_ID)
- .setSmallIcon(R.mipmap.ic_launcher_round)
- .setLargeIcon(largeIcon)
- .setGroup(GROUP)
- .setGroupSummary(true)
- .setAutoCancel(true)
- .setLocalOnly(false)
- .setStyle(style)
- .extend(NotificationCompat.WearableExtender().setBridgeTag("session:summary"))
- .build()
+ notificationManager.createNotificationChannel(sessionNotificationBuilder.createChannel().build())
}
private fun sendNotification(id: Int, notification: Notification) {
@@ -167,8 +111,8 @@ class SessionNotificationSender(
}
companion object {
- private val CHANNEL_ID = "SessionNotification"
- private val GROUP = "dev.johnoreilly.confetti.SESSIONS_ALERT"
- private val SUMMARY_ID = 0
+ internal val CHANNEL_ID = "SessionNotification"
+ internal val GROUP = "dev.johnoreilly.confetti.SESSIONS_ALERT"
+ private val SUMMARY_ID = 10
}
}