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

Hook up episodes airing notifications #1928

Merged
merged 5 commits into from
Jul 6, 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
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,17 @@ 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 android.content.Context
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.data.models.Notification
import app.tivi.data.models.NotificationChannel
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
Expand All @@ -36,58 +34,24 @@ class AndroidNotificationManager(
// 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?,
) {
override suspend fun schedule(notification: Notification) {
// 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)
notificationManager.createChannel(notification.channel)

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

// Now decide whether to send the broadcast now, or set an alarm
val windowStartTime = (date - ALARM_WINDOW_LENGTH)
val windowStartTime = (notification.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)
logger.d { "Sending notification now: $notification" }
application.sendBroadcast(
PostNotificationBroadcastReceiver.buildIntent(application, notification.id),
)
} 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]")
}
}
logger.d { "Scheduling notification for $windowStartTime: $notification" }

alarmManager.setWindow(
// type
Expand All @@ -97,7 +61,7 @@ class AndroidNotificationManager(
// windowLengthMillis
ALARM_WINDOW_LENGTH.inWholeMilliseconds,
// operation
PendingIntent.getBroadcast(application, id.hashCode(), intent, PENDING_INTENT_FLAGS),
notification.buildPendingIntent(application),
)
}
}
Expand All @@ -123,6 +87,11 @@ class AndroidNotificationManager(
createNotificationChannel(androidChannel)
}

override suspend fun cancel(notification: Notification) {
alarmManager.cancel(notification.buildPendingIntent(application))
store.removeWithId(notification.id)
}

override suspend fun getPendingNotifications(): List<Notification> {
return store.getPendingNotifications()
}
Expand All @@ -131,7 +100,14 @@ class AndroidNotificationManager(
// 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
}
}

private const val PENDING_INTENT_FLAGS = FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT or FLAG_ONE_SHOT

internal fun Notification.buildPendingIntent(
context: Context,
): PendingIntent {
val intent = PostNotificationBroadcastReceiver.buildIntent(context, id)
return PendingIntent.getBroadcast(context, id.hashCode(), intent, PENDING_INTENT_FLAGS)
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,21 @@ import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.okio.OkioSerializer
import androidx.datastore.core.okio.OkioStorage
import androidx.datastore.dataStoreFile
import app.tivi.core.notifications.proto.PendingNotification
import app.tivi.core.notifications.proto.PendingNotification as PendingNotificationProto
import app.tivi.core.notifications.proto.PendingNotifications as PendingNotificationsProto
import app.tivi.data.models.Notification
import app.tivi.data.models.NotificationChannel
import app.tivi.inject.ApplicationScope
import app.tivi.util.AppCoroutineDispatchers
import kotlin.time.Duration.Companion.days
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.datetime.toJavaInstant
import kotlinx.datetime.toKotlinInstant
import me.tatarka.inject.annotations.Inject
import okio.BufferedSink
Expand Down Expand Up @@ -65,9 +70,19 @@ class PendingNotificationStore(
}

suspend fun getPendingNotifications(): List<Notification> {
return store.data.firstOrNull()?.let { data ->
data.pending.map { it.toNotification() }
} ?: emptyList()
// First we remove any old (more than 1 day in past) pending notifications
store.updateData { data ->
val filtered = data.pending.filter {
val date = it.date?.toKotlinInstant()
date != null && date > (Clock.System.now() - 1.days)
}
data.copy(filtered)
}

return store.data.firstOrNull()
?.pending
?.map(PendingNotification::toNotification)
?: emptyList()
}
}

Expand Down Expand Up @@ -111,7 +126,18 @@ internal fun PendingNotificationProto.toNotification(): Notification {
title = title,
message = message,
deeplinkUrl = deeplink_url,
date = date?.toKotlinInstant(),
date = date?.toKotlinInstant() ?: Instant.DISTANT_PAST,
channel = NotificationChannel.fromId(channel_id),
)
}

internal fun Notification.toPendingNotification(): PendingNotificationProto {
return PendingNotificationProto(
id = id,
title = title,
message = message,
deeplink_url = deeplinkUrl,
date = date.toJavaInstant(),
channel_id = channel.id,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,12 @@
package app.tivi.core.notifications

import app.tivi.data.models.Notification
import app.tivi.data.models.NotificationChannel
import kotlinx.datetime.Instant

interface NotificationManager {

suspend fun schedule(
id: String,
title: String,
message: String,
channel: NotificationChannel,
date: Instant,
deeplinkUrl: String? = null,
)
suspend fun schedule(notification: Notification) = Unit

suspend fun getPendingNotifications(): List<Notification>
suspend fun cancel(notification: Notification) = Unit

suspend fun getPendingNotifications(): List<Notification> = emptyList()
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,42 +25,34 @@ class IosNotificationManager(
private val logger: Logger,
) : NotificationManager {

override suspend fun schedule(
id: String,
title: String,
message: String,
channel: NotificationChannel,
date: Instant,
deeplinkUrl: String?,
) {
override suspend fun schedule(notification: Notification) {
val trigger = UNCalendarNotificationTrigger.triggerWithDateMatchingComponents(
dateComponents = date.toLocalDateTime(TimeZone.currentSystemDefault()).toNSDateComponents(),
dateComponents = notification.date
.toLocalDateTime(TimeZone.currentSystemDefault())
.toNSDateComponents(),
repeats = false,
)

val userInfo = buildMap {
if (deeplinkUrl != null) {
put(USER_INFO_DEEPLINK, deeplinkUrl)
if (notification.deeplinkUrl != null) {
put(USER_INFO_DEEPLINK, notification.deeplinkUrl)
}
}

val content = UNMutableNotificationContent().apply {
setTitle(title)
setBody(message)
setCategoryIdentifier(channel.id)
setTitle(notification.title)
setBody(notification.message)
setCategoryIdentifier(notification.channel.id)
setUserInfo(userInfo as Map<Any?, *>)
}

val request = UNNotificationRequest.requestWithIdentifier(id, content, trigger)
val request = UNNotificationRequest.requestWithIdentifier(notification.id, content, trigger)

logger.d {
buildString {
append("Scheduling notification. ")
append("title:[$title], ")
append("message:[$message], ")
append("id:[$id], ")
append("channel:[$channel], ")
append("trigger:[${trigger.nextTriggerDate()}]")
append(notification)
append(", trigger:[${trigger.nextTriggerDate()}]")
}
}

Expand Down Expand Up @@ -97,7 +89,8 @@ class IosNotificationManager(
channel = NotificationChannel.fromId(content.categoryIdentifier),
date = (request.trigger as? UNCalendarNotificationTrigger)
?.nextTriggerDate()
?.toKotlinInstant(),
?.toKotlinInstant()
?: Instant.DISTANT_PAST,
deeplinkUrl = content.userInfo[USER_INFO_DEEPLINK]?.toString(),
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,4 @@

package app.tivi.core.notifications

import app.tivi.data.models.Notification
import app.tivi.data.models.NotificationChannel
import kotlinx.datetime.Instant

internal object EmptyNotificationManager : NotificationManager {
override suspend fun schedule(
id: String,
title: String,
message: String,
channel: NotificationChannel,
date: Instant,
deeplinkUrl: String?,
) {
// no-op
}

override suspend fun getPendingNotifications(): List<Notification> = emptyList()
}
internal object EmptyNotificationManager : NotificationManager
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ class SqlDelightEpisodesDao(
.mapToOneOrNull(dispatchers.io)
}

override fun upcomingEpisodesFromFollowedShows(limit: Instant): List<Episode> {
return db.episodesQueries.upcomingEpisodes(limit.toString(), ::Episode).executeAsList()
}

private fun mapperForEpisodeWithSeason(
id: Long,
season_id: Long,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ INNER JOIN episodes ON shows_next_to_watch.episode_id = episodes.id
INNER JOIN seasons ON shows_next_to_watch.season_id = seasons.id
WHERE shows_next_to_watch.show_id = :showId;

upcomingEpisodes:
SELECT episodes.* FROM episodes
INNER JOIN seasons ON seasons.id = episodes.season_id
INNER JOIN shows ON shows.id = seasons.show_id
INNER JOIN myshows_entries ON myshows_entries.show_id = shows.id
WHERE seasons.number >= 1
AND datetime(episodes.first_aired) > date()
AND datetime(episodes.first_aired) < datetime(:limit);

deleteAll:
DELETE FROM episodes;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import app.tivi.data.compoundmodels.EpisodeWithSeason
import app.tivi.data.models.Episode
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.datetime.Instant

interface EpisodesDao : EntityDao<Episode> {

Expand All @@ -31,4 +32,6 @@ interface EpisodesDao : EntityDao<Episode> {
fun observeShowIdForEpisodeId(episodeId: Long): Flow<Long>

fun observeNextEpisodeToWatch(showId: Long): Flow<EpisodeWithSeason?> = emptyFlow()

fun upcomingEpisodesFromFollowedShows(limit: Instant): List<Episode>
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ package app.tivi.data.episodes

import app.tivi.data.compoundmodels.EpisodeWithSeason
import app.tivi.data.compoundmodels.SeasonWithEpisodesAndWatches
import app.tivi.data.compoundmodels.ShowSeasonEpisode
import app.tivi.data.daos.EpisodesDao
import app.tivi.data.daos.SeasonsDao
import app.tivi.data.daos.TiviShowDao
import app.tivi.data.db.DatabaseTransactionRunner
import app.tivi.data.episodes.datasource.EpisodeWatchesDataSource
import app.tivi.data.models.ActionDate
Expand Down Expand Up @@ -43,6 +45,7 @@ class SeasonsEpisodesRepository(
private val transactionRunner: DatabaseTransactionRunner,
private val seasonsDao: SeasonsDao,
private val episodesDao: EpisodesDao,
private val showDao: TiviShowDao,
private val showSeasonsLastRequestStore: ShowSeasonsLastRequestStore,
private val tmdbSeasonsDataSource: TmdbSeasonsEpisodesDataSource,
private val traktSeasonsDataSource: TraktSeasonsEpisodesDataSource,
Expand Down Expand Up @@ -82,6 +85,15 @@ class SeasonsEpisodesRepository(
return seasonsDao.seasonWithId(seasonId)
}

fun getUpcomingEpisodesFromFollowedShows(limit: Instant): List<ShowSeasonEpisode> {
return episodesDao.upcomingEpisodesFromFollowedShows(limit)
.mapNotNull { episode ->
val season = seasonsDao.seasonWithId(episode.seasonId) ?: return@mapNotNull null
val show = showDao.getShowWithId(season.showId) ?: return@mapNotNull null
ShowSeasonEpisode(show, season, episode)
}
}

fun observeEpisodeWatches(episodeId: Long): Flow<List<EpisodeWatchEntry>> {
return episodeWatchStore.observeEpisodeWatches(episodeId)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright 2018, Google LLC, Christopher Banes and the Tivi project contributors
// SPDX-License-Identifier: Apache-2.0

package app.tivi.data.compoundmodels

import app.tivi.data.models.Episode
import app.tivi.data.models.Season
import app.tivi.data.models.TiviShow

data class ShowSeasonEpisode(
val show: TiviShow,
val season: Season,
val episode: Episode,
)
Loading
Loading