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 } }