Skip to content
This repository has been archived by the owner on Nov 12, 2024. It is now read-only.

Notifications (Part 1: Infra) #1920

Merged
merged 18 commits into from
Jun 29, 2024
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
3 changes: 0 additions & 3 deletions .idea/codeStyles/Project.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion android-app/app/src/main/kotlin/app/tivi/TiviApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,25 @@ import android.os.StrictMode.ThreadPolicy
import android.os.StrictMode.VmPolicy
import androidx.work.Configuration
import androidx.work.WorkerFactory
import app.tivi.core.notifications.PendingNotificationStore
import app.tivi.core.notifications.PendingNotificationsStoreProvider
import app.tivi.extensions.unsafeLazy
import app.tivi.inject.AndroidApplicationComponent
import app.tivi.inject.create

class TiviApplication :
Application(),
Configuration.Provider {
Configuration.Provider,
PendingNotificationsStoreProvider {
val component: AndroidApplicationComponent by unsafeLazy {
AndroidApplicationComponent.create(this)
}

private lateinit var workerFactory: WorkerFactory

override val pendingNotificationsStore: PendingNotificationStore
get() = component.pendingNotificationsStore

override fun onCreate() {
super.onCreate()
setupStrictMode()
Expand Down
13 changes: 8 additions & 5 deletions android-app/app/src/main/kotlin/app/tivi/home/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,16 @@ import androidx.lifecycle.coroutineScope
import androidx.lifecycle.repeatOnLifecycle
import app.tivi.TiviActivity
import app.tivi.TiviApplication
import app.tivi.core.permissions.bind
import app.tivi.inject.AndroidActivityComponent
import app.tivi.inject.AndroidApplicationComponent
import app.tivi.inject.create
import app.tivi.screens.DiscoverScreen
import app.tivi.settings.TiviPreferences
import com.slack.circuit.backstack.rememberSaveableBackStack
import com.slack.circuit.foundation.rememberCircuitNavigator
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

class MainActivity : TiviActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
Expand All @@ -44,13 +45,15 @@ class MainActivity : TiviActivity() {

lifecycle.coroutineScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
val prefs = withContext(applicationComponent.dispatchers.io) {
applicationComponent.preferences.observeTheme()
}
prefs.collect(::enableEdgeToEdgeForTheme)
applicationComponent.preferences.theme.flow
.flowOn(applicationComponent.dispatchers.io)
.collect(::enableEdgeToEdgeForTheme)
}
}

// Bind the PermissionController
component.permissionsController.bind(this)

setContent {
val backstack = rememberSaveableBackStack(listOf(DiscoverScreen))
val navigator = rememberCircuitNavigator(backstack)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ class HideArtworkInterceptor(
) : Interceptor {
override suspend fun intercept(chain: Interceptor.Chain): ImageResult = withContext(dispatchers.io) {
when {
preferences.value.getDeveloperHideArtwork() && isArtwork(chain.request.data) -> {
preferences.value.developerHideArtwork.get() && isArtwork(chain.request.data) -> {
val size = chain.request.sizeResolver.size()

val placeholder = buildString {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,12 @@ package app.tivi.common.compose
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import app.tivi.settings.Preference
import app.tivi.settings.TiviPreferences

@Composable
fun TiviPreferences.shouldUseDarkColors(): Boolean {
val themePreference = remember { observeTheme() }
.collectAsState(initial = TiviPreferences.Theme.SYSTEM)
val themePreference = theme.flow.collectAsState(initial = TiviPreferences.Theme.SYSTEM)

return when (themePreference.value) {
TiviPreferences.Theme.LIGHT -> false
Expand All @@ -23,7 +22,8 @@ fun TiviPreferences.shouldUseDarkColors(): Boolean {

@Composable
fun TiviPreferences.shouldUseDynamicColors(): Boolean {
return remember { observeUseDynamicColors() }
.collectAsState(initial = true)
.value
return useDynamicColors.flow.collectAsState(initial = true).value
}

@Composable
inline fun <T> Preference<T>.collectAsState() = flow.collectAsState(defaultValue)
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,9 @@ val EnTiviStrings = TiviStrings(
settingsDynamicColorTitle = "Dynamic colors",
settingsIgnoreSpecialsTitle = "Ignore specials",
settingsIgnoreSpecialsSummary = "Automatically ignore specials",
settingsNotificationsCategoryTitle = "Notifications",
settingsNotificationsAiringEpisodesTitle = "Airing episodes",
settingsNotificationsAiringEpisodesSummary = "Notify when episodes for followed shows are airing",
settingsOpenSource = "Open source licenses",
settingsOpenSourceSummary = "Tivi 💞 open source",
settingsPrivacyCategoryTitle = "Privacy",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,9 @@ data class TiviStrings(
val settingsDynamicColorTitle: String,
val settingsIgnoreSpecialsTitle: String,
val settingsIgnoreSpecialsSummary: String,
val settingsNotificationsCategoryTitle: String,
val settingsNotificationsAiringEpisodesTitle: String,
val settingsNotificationsAiringEpisodesSummary: String,
val settingsOpenSource: String,
val settingsOpenSourceSummary: String,
val settingsPrivacyCategoryTitle: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ object LicensesScreen : TiviScreen(name = "LicensesScreen()")
@Parcelize
object DevSettingsScreen : TiviScreen(name = "DevelopmentSettings()")

@Parcelize
object DevNotificationsScreen : TiviScreen("DevNotificationsScreen()")

@Parcelize
object DevLogScreen : TiviScreen(name = "DevelopmentLog()")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,7 @@ class AnalyticsInitializer(
) : AppInitializer {
override fun initialize() {
scope.launch {
preferences.value
.observeReportAnalytics()
preferences.value.reportAnalytics.flow
.flowOn(dispatchers.io)
.collect { enabled -> analytics.setEnabled(enabled) }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class CrashReportingInitializer(
) : AppInitializer {
override fun initialize() {
scope.launch {
preferences.value.observeReportAppCrashes()
preferences.value.reportAppCrashes.flow
.flowOn(dispatchers.io)
.collect(action::invoke)
}
Expand Down
28 changes: 28 additions & 0 deletions core/notifications-protos/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Copyright 2024, Christopher Banes and the Tivi project contributors
// SPDX-License-Identifier: Apache-2.0


plugins {
id("app.tivi.android.library")
id("app.tivi.kotlin.multiplatform")
alias(libs.plugins.wire)
}

kotlin {
sourceSets {
commonMain {
dependencies {
api(projects.core.base)
api(libs.wire.runtime)
}
}
}
}

wire {
kotlin {}
}

android {
namespace = "app.tivi.core.notifications.proto"
}
18 changes: 18 additions & 0 deletions core/notifications-protos/src/commonMain/proto/pending.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
syntax = "proto3";

import "google/protobuf/timestamp.proto";

package app.tivi.core.notifications.proto;

message PendingNotification {
string id = 1;
string title = 2;
string message = 3;
string channel_id = 4;
optional string deeplink_url = 5;
optional google.protobuf.Timestamp date = 6;
}

message PendingNotifications {
repeated PendingNotification pending = 1;
}
33 changes: 33 additions & 0 deletions core/notifications/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Copyright 2024, Christopher Banes and the Tivi project contributors
// SPDX-License-Identifier: Apache-2.0


plugins {
id("app.tivi.android.library")
id("app.tivi.kotlin.multiplatform")
}

kotlin {
sourceSets {
commonMain {
dependencies {
api(projects.core.base)
api(projects.core.permissions)
implementation(projects.common.ui.resources.strings)
api(libs.kotlinx.datetime)
}
}

androidMain {
dependencies {
implementation(libs.androidx.core)
implementation(libs.androidx.datastore)
implementation(projects.core.notificationsProtos)
}
}
}
}

android {
namespace = "app.tivi.core.notifications"
}
16 changes: 16 additions & 0 deletions core/notifications/src/androidMain/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?><!-- Copyright 2024, Christopher Banes and the Tivi project contributors -->
<!-- SPDX-License-Identifier: Apache-2.0 -->

<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<application>

<receiver
android:name="app.tivi.core.notifications.PostNotificationBroadcastReceiver"
android:exported="false" />

</application>

</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// Copyright 2024, Christopher Banes and the Tivi project contributors
// SPDX-License-Identifier: Apache-2.0

package app.tivi.core.notifications

import android.app.AlarmManager
import android.app.Application
import android.app.PendingIntent
import android.app.PendingIntent.FLAG_IMMUTABLE
import android.app.PendingIntent.FLAG_ONE_SHOT
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.NotificationManagerCompat.IMPORTANCE_DEFAULT
import androidx.core.content.getSystemService
import app.tivi.common.ui.resources.EnTiviStrings
import app.tivi.core.notifications.proto.PendingNotification as PendingNotificationsProto
import app.tivi.util.Logger
import kotlin.time.Duration.Companion.minutes
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.datetime.toJavaInstant
import me.tatarka.inject.annotations.Inject

@Inject
class AndroidNotificationManager(
private val application: Application,
private val logger: Logger,
private val store: PendingNotificationStore,
) : NotificationManager {
private val notificationManager by lazy { NotificationManagerCompat.from(application) }
private val alarmManager by lazy { application.getSystemService<AlarmManager>()!! }

// TODO: this should use the system strings
private val strings = EnTiviStrings

override suspend fun schedule(
id: String,
title: String,
message: String,
channel: NotificationChannel,
date: Instant,
deeplinkUrl: String?,
) {
// We create the channel now ahead of time. We want to limit the amount of work
// in the broadcast receiver
notificationManager.createChannel(channel)

val intent = PostNotificationBroadcastReceiver.buildIntent(application, id)

// Save the pending notification
store.add(
PendingNotificationsProto(
id = id,
title = title,
message = message,
channel_id = channel.id,
deeplink_url = deeplinkUrl,
date = date.toJavaInstant(),
),
)

// Now decide whether to send the broadcast now, or set an alarm
val windowStartTime = (date - ALARM_WINDOW_LENGTH)
if (windowStartTime <= Clock.System.now()) {
// If the window start time is in the past, just send it now
logger.d {
buildString {
append("Sending notification now. ")
append("title:[$title], ")
append("message:[$message], ")
append("id:[$id], ")
append("channel:[$channel]")
}
}
application.sendBroadcast(intent)
} else {
logger.d {
buildString {
append("Scheduling notification. ")
append("title:[$title], ")
append("message:[$message], ")
append("id:[$id], ")
append("channel:[$channel], ")
append("windowStartTime:[$windowStartTime], ")
append("window:[$ALARM_WINDOW_LENGTH]")
}
}

alarmManager.setWindow(
// type
AlarmManager.RTC_WAKEUP,
// windowStartMillis. We minus our defined window from the provide date/time
windowStartTime.toEpochMilliseconds(),
// windowLengthMillis
ALARM_WINDOW_LENGTH.inWholeMilliseconds,
// operation
PendingIntent.getBroadcast(application, id.hashCode(), intent, PENDING_INTENT_FLAGS),
)
}
}

private fun NotificationManagerCompat.createChannel(channel: NotificationChannel) {
val androidChannel = NotificationChannelCompat.Builder(channel.id, IMPORTANCE_DEFAULT)
.apply {
when (channel) {
NotificationChannel.EPISODES_AIRING -> {
setName(strings.settingsNotificationsAiringEpisodesTitle)
setDescription(strings.settingsNotificationsAiringEpisodesSummary)
setVibrationEnabled(true)
}

NotificationChannel.DEVELOPER -> {
setName("Developer testing")
setVibrationEnabled(true)
}
}
}
.build()

createNotificationChannel(androidChannel)
}

override suspend fun getPendingNotifications(): List<PendingNotification> {
return store.getPendingNotifications()
}

private companion object {
// We request 10 mins as Android S can choose to apply a minimum of 10 mins anyway
// Being earlier is better than being late
val ALARM_WINDOW_LENGTH = 10.minutes

const val PENDING_INTENT_FLAGS = FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT or FLAG_ONE_SHOT
}
}
Loading
Loading