Skip to content

Commit 9344783

Browse files
authored
Add developer setting button to send notifications (#1554)
* Testing notifications
1 parent 48fd864 commit 9344783

File tree

11 files changed

+111
-53
lines changed

11 files changed

+111
-53
lines changed

androidApp/src/main/AndroidManifest.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
xmlns:tools="http://schemas.android.com/tools">
44

55
<uses-permission android:name="android.permission.INTERNET" />
6+
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
67

78
<!--
89
<uses-permission android:name="androidx.car.app.MAP_TEMPLATES"/>

gradle/libs.versions.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ protolayout = "1.3.0-alpha10"
4343
robolectric = "4.14.1"
4444
room = "2.6.1"
4545
tiles-tooling-preview = "1.5.0-alpha10"
46+
wear = "1.3.0"
47+
wearPhoneInteractions = "1.1.0"
4648
work-runtime-ktx = "2.10.0"
4749
spring = "3.4.4"
4850
generativeai = "0.9.0-1.0.1-wasm"
@@ -69,6 +71,8 @@ androidx-protolayout-material = { module = "androidx.wear.protolayout:protolayou
6971
androidx-protolayout-material3 = { module = "androidx.wear.protolayout:protolayout-material3", version = "1.0.0-alpha27" }
7072
androidx-protolayout-proto = { module = "androidx.wear.protolayout:protolayout-proto", version.ref = "protolayout" }
7173
androidx-tiles-tooling-preview = { module = "androidx.wear.tiles:tiles-tooling-preview", version.ref = "tiles-tooling-preview" }
74+
androidx-wear = { module = "androidx.wear:wear", version.ref = "wear" }
75+
androidx-wear-phone-interactions = { module = "androidx.wear:wear-phone-interactions", version.ref = "wearPhoneInteractions" }
7276
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "work-runtime-ktx" }
7377
androidx-work-testing = { module = "androidx.work:work-testing", version.ref = "work-runtime-ktx" }
7478
lifecyle-runtime = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "composeLifecyleRuntime" }

shared/src/androidMain/kotlin/dev/johnoreilly/confetti/di/KoinAndroid.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import dev.johnoreilly.confetti.settings.WearSettingsSerializer
3434
import dev.johnoreilly.confetti.shared.BuildConfig
3535
import dev.johnoreilly.confetti.utils.AndroidDateService
3636
import dev.johnoreilly.confetti.utils.DateService
37+
import dev.johnoreilly.confetti.work.NotificationSender
3738
import dev.johnoreilly.confetti.work.RefreshWorker
3839
import dev.johnoreilly.confetti.work.SessionNotificationSender
3940
import dev.johnoreilly.confetti.work.SessionNotificationWorker
@@ -116,6 +117,9 @@ actual fun platformModule() = module {
116117
registerSerializer(WearSettingsSerializer)
117118
}
118119
}
120+
single<NotificationSender> {
121+
get<SessionNotificationSender>()
122+
}
119123
}
120124

121125
val Context.settingsStore by preferencesDataStore("settings")

shared/src/androidMain/kotlin/dev/johnoreilly/confetti/work/SessionNotificationSender.kt

Lines changed: 17 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -15,25 +15,26 @@ import dev.johnoreilly.confetti.auth.Authentication
1515
import dev.johnoreilly.confetti.fragment.SessionDetails
1616
import dev.johnoreilly.confetti.shared.R
1717
import dev.johnoreilly.confetti.utils.DateService
18-
import dev.johnoreilly.confetti.utils.nowInstant
18+
import dev.johnoreilly.confetti.work.NotificationSender.Selector
1919
import kotlinx.coroutines.flow.first
20-
import kotlinx.datetime.DateTimeUnit
21-
import kotlinx.datetime.LocalDateTime
22-
import kotlinx.datetime.TimeZone
23-
import kotlinx.datetime.toInstant
24-
import kotlinx.datetime.toLocalDateTime
25-
import kotlinx.datetime.until
26-
import kotlin.time.Duration.Companion.minutes
2720

2821
class SessionNotificationSender(
2922
private val context: Context,
3023
private val repository: ConfettiRepository,
3124
private val dateService: DateService,
3225
private val notificationManager: NotificationManagerCompat,
3326
private val authentication: Authentication,
34-
) {
27+
): NotificationSender {
28+
29+
override suspend fun sendNotification(selector: Selector) {
30+
val notificationsEnabled = notificationManager.areNotificationsEnabled()
31+
32+
println("notificationsEnabled")
33+
34+
if (!notificationsEnabled) {
35+
// return
36+
}
3537

36-
suspend fun sendNotification() {
3738
// If there is no signed-in user, skip.
3839
val user = authentication.currentUser.value ?: return
3940

@@ -58,11 +59,6 @@ class SessionNotificationSender(
5859
return
5960
}
6061

61-
// If current date is not in the conference range, skip.
62-
if (sessions.none { session -> session.startsAt.date == dateService.now().date }) {
63-
return
64-
}
65-
6662
val bookmarks = repository.bookmarks(
6763
conference = conferenceId,
6864
uid = user.uid,
@@ -78,12 +74,9 @@ class SessionNotificationSender(
7874
bookmarks.contains(session.id)
7975
}
8076

81-
val sessionsTimeZone = TimeZone.of(sessionsResponse.data?.config?.timezone.orEmpty())
82-
val timeZonedNow = dateService.nowInstant()
77+
val now = dateService.now()
8378
val upcomingSessions = bookmarkedSessions.filter { session ->
84-
val timeZonedStartsAt = session.startsAt.toInstant(sessionsTimeZone)
85-
val untilInMinutes = timeZonedNow.until(timeZonedStartsAt, DateTimeUnit.MINUTE)
86-
untilInMinutes in 0..INTERVAL.inWholeMinutes
79+
selector.matches(now, session)
8780
}
8881

8982
// If there are no bookmarked upcoming sessions, skip.
@@ -104,16 +97,6 @@ class SessionNotificationSender(
10497
}
10598
}
10699

107-
/**
108-
* Creates an interval from [DateService.now] up to [INTERVAL], with the device time zone.
109-
*/
110-
private fun createIntervalRangeFromNow(): ClosedRange<LocalDateTime> {
111-
val timeZone = TimeZone.currentSystemDefault()
112-
val now = dateService.now()
113-
val future = (now.toInstant(timeZone) + INTERVAL).toLocalDateTime(timeZone)
114-
return now..future
115-
}
116-
117100
private fun createNotificationChannel() {
118101
// Channels are only available on Android O+.
119102
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
@@ -141,6 +124,8 @@ class SessionNotificationSender(
141124
.setContentText("Starts at ${session.startsAt.time} in ${session.room?.name.orEmpty()}")
142125
.setGroup(GROUP)
143126
.setAutoCancel(true)
127+
.setLocalOnly(false)
128+
.extend(NotificationCompat.WearableExtender().setBridgeTag("session:reminder"))
144129
.build()
145130
}
146131

@@ -167,7 +152,9 @@ class SessionNotificationSender(
167152
.setGroup(GROUP)
168153
.setGroupSummary(true)
169154
.setAutoCancel(true)
155+
.setLocalOnly(false)
170156
.setStyle(style)
157+
.extend(NotificationCompat.WearableExtender().setBridgeTag("session:summary"))
171158
.build()
172159
}
173160

@@ -183,8 +170,5 @@ class SessionNotificationSender(
183170
private val CHANNEL_ID = "SessionNotification"
184171
private val GROUP = "dev.johnoreilly.confetti.SESSIONS_ALERT"
185172
private val SUMMARY_ID = 0
186-
187-
// Minimum interval for work manager: MIN_PERIODIC_INTERVAL_MILLIS
188-
val INTERVAL = 15.minutes
189173
}
190174
}

shared/src/androidMain/kotlin/dev/johnoreilly/confetti/work/SessionNotificationWorker.kt

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,12 @@ import android.content.Context
44
import androidx.work.Constraints
55
import androidx.work.CoroutineWorker
66
import androidx.work.ExistingPeriodicWorkPolicy
7-
import androidx.work.ExistingWorkPolicy
8-
import androidx.work.OneTimeWorkRequestBuilder
97
import androidx.work.PeriodicWorkRequestBuilder
108
import androidx.work.WorkManager
119
import androidx.work.WorkerParameters
12-
import dev.johnoreilly.confetti.work.SessionNotificationSender.Companion.INTERVAL
1310
import org.koin.core.component.KoinComponent
1411
import org.koin.core.component.inject
12+
import kotlin.time.Duration.Companion.minutes
1513
import kotlin.time.toJavaDuration
1614

1715
// Using a custom constructor has caused a crash inside Koin.
@@ -29,7 +27,7 @@ class SessionNotificationWorker(
2927
}
3028

3129
return try {
32-
notifier.sendNotification()
30+
notifier.sendNotification(Selector)
3331
Result.success()
3432
} catch (e: Throwable) {
3533
Result.retry()
@@ -40,20 +38,9 @@ class SessionNotificationWorker(
4038

4139
private val TAG = SessionNotificationWorker::class.java.simpleName
4240

43-
fun startOneTimeWorkRequest(workManager: WorkManager) {
44-
val workRequest = OneTimeWorkRequestBuilder<SessionNotificationWorker>()
45-
.build()
46-
47-
workManager.enqueueUniqueWork(
48-
TAG,
49-
ExistingWorkPolicy.REPLACE,
50-
workRequest,
51-
)
52-
}
53-
5441
fun startPeriodicWorkRequest(workManager: WorkManager) {
5542
val workRequest =
56-
PeriodicWorkRequestBuilder<SessionNotificationWorker>(INTERVAL.toJavaDuration())
43+
PeriodicWorkRequestBuilder<SessionNotificationWorker>(Selector.within.toJavaDuration())
5744
.setConstraints(Constraints.Builder().setRequiresBatteryNotLow(true).build())
5845
.build()
5946

@@ -67,5 +54,8 @@ class SessionNotificationWorker(
6754
fun cancelWorkRequest(workManager: WorkManager) {
6855
workManager.cancelUniqueWork(TAG)
6956
}
57+
58+
// Minimum interval for work manager: MIN_PERIODIC_INTERVAL_MILLIS
59+
val Selector = NotificationSender.Today(15.minutes)
7060
}
7161
}

shared/src/commonMain/kotlin/dev/johnoreilly/confetti/decompose/AppComponent.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import dev.johnoreilly.confetti.ConfettiRepository
1212
import dev.johnoreilly.confetti.auth.Authentication
1313
import dev.johnoreilly.confetti.auth.User
1414
import dev.johnoreilly.confetti.decompose.AppComponent.Child
15+
import dev.johnoreilly.confetti.work.NotificationSender
1516
import kotlinx.coroutines.flow.distinctUntilChanged
1617
import kotlinx.coroutines.flow.map
1718
import kotlinx.coroutines.launch
@@ -44,13 +45,15 @@ class DefaultAppComponent(
4445
val appSettings: AppSettings by inject()
4546
private val repository: ConfettiRepository by inject()
4647
private val navigation = StackNavigation<Config>()
48+
private val notificationSender: NotificationSender? by inject()
4749

4850
private var user: User? = null
4951

5052
private val defaultSettingsComponent = DefaultSettingsComponent(
5153
componentContext = componentContext,
5254
appSettings = appSettings,
53-
authentication = authentication
55+
authentication = authentication,
56+
notificationSender = notificationSender,
5457
)
5558

5659

shared/src/commonMain/kotlin/dev/johnoreilly/confetti/decompose/SettingsComponent.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package dev.johnoreilly.confetti.decompose
33
import com.arkivanov.decompose.ComponentContext
44
import dev.johnoreilly.confetti.AppSettings
55
import dev.johnoreilly.confetti.auth.Authentication
6+
import dev.johnoreilly.confetti.work.NotificationSender
67
import kotlinx.coroutines.flow.SharingStarted
78
import kotlinx.coroutines.flow.StateFlow
89
import kotlinx.coroutines.flow.combine
@@ -40,12 +41,15 @@ interface SettingsComponent {
4041
fun updateDarkThemeConfig(darkThemeConfig: DarkThemeConfig)
4142
fun updateUseExperimentalFeatures(value: Boolean)
4243
fun enableDeveloperMode()
44+
fun sendNotifications()
45+
val supportsNotifications: Boolean
4346
}
4447

4548
class DefaultSettingsComponent(
4649
componentContext: ComponentContext,
4750
private val appSettings: AppSettings,
4851
private val authentication: Authentication,
52+
private val notificationSender: NotificationSender?,
4953
) : SettingsComponent, ComponentContext by componentContext {
5054

5155
private val coroutineScope = coroutineScope()
@@ -99,6 +103,15 @@ class DefaultSettingsComponent(
99103
}
100104
}
101105

106+
override fun sendNotifications() {
107+
coroutineScope.launch {
108+
notificationSender?.sendNotification(NotificationSender.AllFuture)
109+
}
110+
}
111+
112+
override val supportsNotifications: Boolean
113+
get() = notificationSender != null
114+
102115
companion object {
103116
const val darkThemeConfigKey = "darkThemeConfigKey"
104117
}

shared/src/commonMain/kotlin/dev/johnoreilly/confetti/ui/settings/SettingsUI.kt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import androidx.compose.foundation.selection.selectable
1616
import androidx.compose.foundation.selection.selectableGroup
1717
import androidx.compose.material.icons.Icons
1818
import androidx.compose.material.icons.automirrored.filled.ArrowBack
19+
import androidx.compose.material3.Button
1920
import androidx.compose.material3.CenterAlignedTopAppBar
2021
import androidx.compose.material3.ExperimentalMaterial3Api
2122
import androidx.compose.material3.HorizontalDivider
@@ -69,6 +70,8 @@ fun SettingsUI(
6970
onChangeUseExperimentalFeatures = component::updateUseExperimentalFeatures,
7071
developerSettings = developerSettings,
7172
onEnableDeveloperMode = component::enableDeveloperMode,
73+
onSendNotifications = component::sendNotifications,
74+
supportsNotifications = component.supportsNotifications,
7275
popBack = popBack
7376
)
7477
}
@@ -80,6 +83,8 @@ fun SettingsUI(
8083
onChangeDarkThemeConfig: (darkThemeConfig: DarkThemeConfig) -> Unit,
8184
developerSettings: DeveloperSettings?,
8285
onEnableDeveloperMode: () -> Unit,
86+
onSendNotifications: () -> Unit,
87+
supportsNotifications: Boolean,
8388
popBack: () -> Unit
8489
) {
8590
val scrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
@@ -139,6 +144,9 @@ fun SettingsUI(
139144
maxLines = 1,
140145
overflow = TextOverflow.Ellipsis
141146
)
147+
Button(onClick = onSendNotifications, enabled = supportsNotifications) {
148+
Text("Send Notifications")
149+
}
142150
}
143151
}
144152
}
@@ -167,7 +175,8 @@ fun SettingsUI(
167175
Modifier.fillMaxWidth(),
168176
horizontalAlignment = Alignment.CenterHorizontally,
169177
) {
170-
//Text("Version: ${BuildConfig.VERSION_NAME}")
178+
// Keep version so developer mode is accessible
179+
Text("Version: ")
171180
}
172181
}
173182
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package dev.johnoreilly.confetti.work
2+
3+
import dev.johnoreilly.confetti.fragment.SessionDetails
4+
import kotlinx.datetime.DateTimeUnit
5+
import kotlinx.datetime.LocalDateTime
6+
import kotlinx.datetime.UtcOffset
7+
import kotlinx.datetime.toInstant
8+
import kotlinx.datetime.until
9+
import kotlin.time.Duration
10+
import kotlin.time.Duration.Companion.minutes
11+
12+
interface NotificationSender {
13+
sealed interface Selector {
14+
fun matches(now: LocalDateTime, session: SessionDetails): Boolean
15+
}
16+
data class Today(val within: Duration = 15.minutes): Selector {
17+
override fun matches(
18+
now: LocalDateTime,
19+
session: SessionDetails
20+
): Boolean {
21+
if (session.startsAt.date != now.date)
22+
return false
23+
24+
val compareInAnyTz = UtcOffset(0)
25+
val until = session.startsAt.toInstant(compareInAnyTz).until(now.toInstant(compareInAnyTz), DateTimeUnit.MINUTE)
26+
return until in 0..within.inWholeMinutes
27+
}
28+
}
29+
30+
data object AllFuture: Selector {
31+
override fun matches(
32+
now: LocalDateTime,
33+
session: SessionDetails
34+
): Boolean {
35+
return session.startsAt > now
36+
}
37+
}
38+
39+
suspend fun sendNotification(selector: Selector = Today())
40+
}

wearApp/build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,9 @@ dependencies {
219219

220220
implementation(libs.androidx.protolayout.material)
221221

222+
implementation(libs.androidx.wear)
223+
implementation(libs.androidx.wear.phone.interactions)
224+
222225
coreLibraryDesugaring(libs.desugar)
223226

224227
debugImplementation(libs.compose.ui.manifest)

0 commit comments

Comments
 (0)