Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
}
Expand All @@ -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
}
Expand Down
4 changes: 4 additions & 0 deletions shared/src/androidMain/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<application>
<receiver android:name="dev.johnoreilly.confetti.notifications.NotificationReceiver"/>
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -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() }
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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<SessionDetails>, 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"))
}
}
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -88,74 +85,21 @@ 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())
}
}

private fun createNotificationChannel() {
// 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<SessionDetails>): 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) {
Expand All @@ -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
}
}
Loading