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
3 changes: 3 additions & 0 deletions .github/workflows/new-episode-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ jobs:
- name: Install Dependencies
run: npm ci --prefix scripts

- name: Test checker logic
run: npm run test:check-new-episodes --prefix scripts

- name: Authenticate GCP/Firebase
run: |
echo '${{ secrets.FCM_SERVICE_ACCOUNT_KEY }}' > gcp-key.json
Expand Down
5 changes: 3 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ These values are part of the shipping product. Renames or recreations break upgr
| WorkManager | `LegacyWorkerFactory` plus permanent `core.data.*` stubs resolve historical FQCNs |
| Deep links | `boxlore://` and `boxcast://`, plus both HTTPS share-path prefixes |
| BuildConfig | Prefer `BOXLORE_*`; Gradle still dual-reads `BOXCAST_*` fallbacks |
| Episode / media IDs | `rss:` prefixes, negative RSS IDs, mediaId prefixes, and `customCacheKey` schemes stay as implemented |
| Episode / media IDs | `rss:` prefixes, negative RSS IDs, mediaId prefixes, and `customCacheKey` schemes stay as implemented. PI **episode supplements** also use negative episode IDs (generated under an `rss:` feed namespace) but keep `Episode.podcastId` as the Podcast Index show id — they are not `rss:` library rows |
| Playback instance | One UI-scoped `PlaybackRepository`; routes and workers must not construct a second one |
| Object graph order | DB → `PodcastRepository` → `QueueRepository` → `PlaybackRepository` → `QueueManager` → `SmartDownloadManager` |
| Smart Queue refill | Owned by `BoxLorePlaybackService` only |
Expand Down Expand Up @@ -53,7 +53,7 @@ On disk, the folder path matches the Gradle id (`core/playback` → `:core:playb
| `:core:prefs` | DataStore and SharedPreferences façades (`UserPreferencesRepository`, `BoxcastPrefs`) | [`core/prefs/README.md`](core/prefs/README.md) |
| `:core:analytics` | Analytics façade (`AnalyticsHelper`, `Analytics`, `RecordingAnalytics`); PostHog init stays in `:app`. Event names and properties: [`docs/ANALYTICS_EVENT_GLOSSARY.md`](docs/ANALYTICS_EVENT_GLOSSARY.md) | [`core/analytics/README.md`](core/analytics/README.md) |
| `:core:catalog` | Catalog orchestration: `PodcastRepository`, subscriptions, content sections, backup/restore | [`core/catalog/README.md`](core/catalog/README.md) |
| `:core:rss` | RSS fetch/parse, `RssPodcastRepository`, `rss:` / negative IDs | [`core/rss/README.md`](core/rss/README.md) |
| `:core:rss` | RSS fetch/parse, `RssPodcastRepository`, PI episode supplement (`EpisodeSupplementRepository`), `rss:` / negative IDs | [`core/rss/README.md`](core/rss/README.md) |
| `:core:ranking` | Adaptive scoring, LinUCB, feedback, `AdaptiveRankingDatabase`. Behavior detail: [`docs/recommendation-system.md`](docs/recommendation-system.md) | [`core/ranking/README.md`](core/ranking/README.md) |
| `:core:downloads` | `DownloadRepository`, Smart Downloads, related WorkManager workers | [`core/downloads/README.md`](core/downloads/README.md) |
| `:core:playback` | `PlaybackRepository`, queue, Media3 services, smart-queue helpers | [`core/playback/README.md`](core/playback/README.md) |
Expand Down Expand Up @@ -187,6 +187,7 @@ Home, Settings, and Info ViewModels are built through assemblers (`HomeViewModel
| HTTP API | `:core:network` | Separate from RSS |
| Ranking | `:core:ranking` | Own adaptive Room database; personalization detail in [`docs/recommendation-system.md`](docs/recommendation-system.md) |
| RSS catalog | `:core:rss` | Negative / `rss:` IDs; exposed through catalog |
| PI episode supplement | `:core:rss` + `:core:database` | Feed-only extras under PI podcast id; not a subscription |

## Upgrade failsafes

Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@
**[Get started](#get-started)** ·
**[Screenshots](#screenshots)** ·
**[Install](#install)** ·
**[Developers](#for-developers)**
**[Developers](#for-developers)** ·
**[Roadmap](https://github.com/orgs/boxcreate/projects/2)**

<img src="docs/images/m3/divider.svg" width="820" alt=""/>

Expand Down Expand Up @@ -243,7 +244,8 @@ Agent notes: [`AGENTS.md`](AGENTS.md) · architecture: [`ARCHITECTURE.md`](ARCHI

1. **Bugs** — [Issues](https://github.com/boxcreate/boxlore/issues)
2. **Ideas** — [Discussions](https://github.com/boxcreate/boxlore/discussions)
3. **PRs** — Fork, change, open a pull request
3. **Roadmap** — [Projects kanban](https://github.com/orgs/boxcreate/projects/2)
4. **PRs** — Fork, change, open a pull request

## License

Expand Down
8 changes: 6 additions & 2 deletions app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ The application module owns the Android app shell: `BoxLoreApplication`, `MainAc
- On startup, `BoxLoreApplication` configures `LearningEventLog` via `BoxcastPrefs.resolveLearnerLogEnabled`: on by default in debug when unset; **always off in release** unless the user has explicitly persisted an opt-in from the debug screen.
- `AppContainer` constructs the shared graph: database, network, RSS, ranking, catalog, playback, queue, downloads, prefs, and analytics dependencies.
- Install attribution: `AppContainer` wires `InstallReferrerManager.onInstallReferrerResolved` → analytics person properties (`install_channel`). Catalog stays free of `:core:analytics`.
- FCM (`BoxLoreFcmService`) owns notification_received / tap extras (`notification_type`, podcast/episode ids for snake+camel keys). Generic push intents propagate those extras so taps are not always `"push"`.
- FCM (`BoxLoreFcmService`) owns notification_received / tap extras (`notification_type`, podcast/episode ids for snake+camel keys). Generic push intents propagate those extras so taps are not always `"push"`. For `type=new_episode`, opted-in PI shows refresh the publisher feed first (`NewEpisodePushHydration`) so the notification, deep link, and auto-download use the local episode (Podcast Index id or negative supplement id). If the payload has no usable episode id, the tap opens the podcast page.
- Library backup/import analytics (`trackBackupRestoreResult`, import failed) use allowlisted error codes from `LibraryBackupAnalyticsErrors` — never raw exception text.
- `MainActivity` / `BoxLoreAppRoot` own deep-link and session-restore analytics at the shell layer.
- App-shell UI uses the centralized Google Sans Flex weight tokens from `:core:designsystem`.
Expand Down Expand Up @@ -48,6 +48,10 @@ src/main/java/cx/aswin/boxlore/
PushTargetRouteAllowlist.kt
connectivity/
fcm/
BoxLoreFcmService.kt
FcmPayloadParser.kt
NewEpisodeFcmLogic.kt
NewEpisodePushHydration.kt
lifecycle/
surveys/
ui/
Expand Down Expand Up @@ -86,7 +90,7 @@ Routes include onboarding, home, learn, briefing, settings, debug, explore, libr

## Testing notes

- Unit tests live under `app/src/test`, including app container smoke coverage, worker factory mapping, FCM payload parsing (type + snake/camel ids), library backup analytics error codes, push-target route allowlisting, cold-start destination precedence (`StartDestinationResolverTest`), and launch-Subscriptions Back decisions (`LaunchSubscriptionsBackDecisionTest`).
- Unit tests live under `app/src/test`, including app container smoke coverage, worker factory mapping, FCM payload parsing (type + snake/camel ids, feedUrl/guid/enclosure), new-episode route/id helpers, opted-in feed hydration before notify, library backup analytics error codes, push-target route allowlisting, cold-start destination precedence (`StartDestinationResolverTest`), and launch-Subscriptions Back decisions (`LaunchSubscriptionsBackDecisionTest`).
- Navigation and feature UI behavior are covered mainly in feature module tests and Maestro smoke flows.

```bash
Expand Down
16 changes: 15 additions & 1 deletion app/src/main/java/cx/aswin/boxlore/AppContainer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import cx.aswin.boxlore.core.ranking.AdaptiveCandidateScorer
import cx.aswin.boxlore.core.ranking.AdaptiveRankingRepository
import cx.aswin.boxlore.core.ranking.RankingFeedbackRepository
import cx.aswin.boxlore.core.ranking.RankingRuntimeControls
import cx.aswin.boxlore.core.rss.EpisodeSupplementRepository
import cx.aswin.boxlore.core.rss.RssPodcastRepository
import cx.aswin.boxlore.core.rss.ports.DownloadCacheRelinker
import kotlinx.coroutines.CoroutineScope
Expand Down Expand Up @@ -90,6 +91,14 @@ class AppContainer(
RssPodcastRepository.create(appContext, database).also(RssPodcastRepository::install)
}

/**
* PI show feed-only episode cache (not a subscription). Wired into [podcastRepository]
* for episode lookup fallback and into Podcast Info for "Missing episodes?".
*/
val episodeSupplementRepository: EpisodeSupplementRepository by lazy {
EpisodeSupplementRepository.create(database)
}

/** Single install path for adaptive ranking; production callers must not call getInstance. */
override val adaptiveRankingRepository: AdaptiveRankingRepository by lazy {
AdaptiveRankingRepository.create(appContext).also(AdaptiveRankingRepository::install)
Expand Down Expand Up @@ -121,6 +130,7 @@ class AppContainer(
publicKey = publicKey,
context = appContext,
rssRepository = rssPodcastRepository,
episodeSupplementRepository = episodeSupplementRepository,
)
}

Expand Down Expand Up @@ -159,13 +169,17 @@ class AppContainer(
}

override val subscriptionRepository: SubscriptionRepository by lazy {
SubscriptionRepository(database.podcastDao())
SubscriptionRepository(
podcastDao = database.podcastDao(),
episodeSupplementPort = episodeSupplementRepository,
)
}

override val subscriptionForegroundSync: SubscriptionForegroundSync by lazy {
SubscriptionForegroundSync.create(
podcastRepository = podcastRepository,
subscriptionRepository = subscriptionRepository,
episodeSupplementPort = episodeSupplementRepository,
scope = syncScope,
)
}
Expand Down
78 changes: 60 additions & 18 deletions app/src/main/java/cx/aswin/boxlore/fcm/BoxLoreFcmService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -98,17 +98,61 @@ class BoxLoreFcmService : FirebaseMessagingService() {

private fun handleNewEpisodeMessage(data: Map<String, String>) {
val podcastId = data["podcastId"] ?: return
val episodeId = data["episodeId"] ?: return
val podcastTitle = data["podcastTitle"] ?: "New Release"
val episodeTitle = data["episodeTitle"] ?: "New Episode"
val imageUrl = data["image"] ?: data["imageUrl"]
val rawRoute = data["route"] ?: "boxlore://podcast/$podcastId"
val route = if (rawRoute.startsWith("boxlore://episode/")) {
"boxlore://episode/$episodeId?autoplay=false&podcastId=${Uri.encode(podcastId)}&podcastTitle=${Uri.encode(podcastTitle)}"
} else {
rawRoute
CoroutineScope(Dispatchers.IO).launch {
val deps = SharedAppDependenciesHolder.instance
val local =
if (deps != null) {
NewEpisodePushHydration.resolveLocalEpisode(
podcastId = podcastId,
payloadFeedUrl = FcmPayloadParser.feedUrl(data),
payloadEnclosureUrl = FcmPayloadParser.enclosureUrl(data),
payloadGuid = FcmPayloadParser.guid(data),
subscriptionRepository = deps.subscriptionRepository,
episodeSupplementPort = deps.podcastRepository.episodeSupplementRepository,
)
} else {
null
}
val episodeId =
NewEpisodeFcmLogic.usableEpisodeId(local?.id)
?: NewEpisodeFcmLogic.usableEpisodeId(data["episodeId"])
val podcastTitle =
data["podcastTitle"]?.takeIf { it.isNotBlank() }
?: local?.podcastTitle?.takeIf { it.isNotBlank() }
?: "New Release"
val episodeTitle =
local?.title?.takeIf { it.isNotBlank() }
?: data["episodeTitle"]?.takeIf { it.isNotBlank() }
?: "New Episode"
val imageUrl = local?.imageUrl ?: data["image"] ?: data["imageUrl"]
val duration =
NewEpisodeFcmLogic.durationMinutes(local?.duration, data["duration"])
val route = NewEpisodeFcmLogic.route(podcastId, episodeId, podcastTitle)
showNewEpisodeNotification(
podcastId = podcastId,
episodeId = episodeId,
podcastTitle = podcastTitle,
episodeTitle = episodeTitle,
imageUrl = imageUrl,
durationMinutes = duration,
route = route,
)
if (episodeId != null) {
triggerAutoDownload(podcastId, episodeId)
}
triggerSmartDownloadSync()
}
}

private fun showNewEpisodeNotification(
podcastId: String,
episodeId: String?,
podcastTitle: String,
episodeTitle: String,
imageUrl: String?,
durationMinutes: Int,
route: String,
) {
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channelId = "boxlore_new_episodes_v1"
val soundUri = Uri.parse("android.resource://$packageName/raw/boxlore_chime")
Expand All @@ -135,7 +179,7 @@ class BoxLoreFcmService : FirebaseMessagingService() {
putExtra("from_push", true)
putExtra("notification_type", "new_episode")
putExtra("podcast_id", podcastId)
putExtra("episode_id", episodeId)
episodeId?.let { putExtra("episode_id", it) }
putExtra("target_route", route)
}

Expand All @@ -146,8 +190,12 @@ class BoxLoreFcmService : FirebaseMessagingService() {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)

val duration = data["duration"]?.toIntOrNull() ?: 0
val bodyText = if (duration > 0) "\"$episodeTitle\" ($duration mins)" else "\"$episodeTitle\""
val bodyText =
if (durationMinutes > 0) {
"\"$episodeTitle\" ($durationMinutes mins)"
} else {
"\"$episodeTitle\""
}

val notificationBuilder = NotificationCompat.Builder(this, channelId)
.setSmallIcon(cx.aswin.boxlore.R.drawable.ic_notification_custom)
Expand Down Expand Up @@ -180,12 +228,6 @@ class BoxLoreFcmService : FirebaseMessagingService() {
}

notificationManager.notify(podcastId.hashCode(), notificationBuilder.build())

// Trigger the per-podcast Auto-Download check
triggerAutoDownload(podcastId, episodeId)

// Trigger Smart Download sync automatically to fetch new content in background
triggerSmartDownloadSync()
}

private fun triggerAutoDownload(podcastId: String, episodeId: String) {
Expand Down
15 changes: 15 additions & 0 deletions app/src/main/java/cx/aswin/boxlore/fcm/FcmPayloadParser.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ data class ParsedFcmNotification(
val category: String,
val podcastId: String? = null,
val episodeId: String? = null,
val feedUrl: String? = null,
val guid: String? = null,
val enclosureUrl: String? = null,
)

/**
Expand Down Expand Up @@ -48,6 +51,9 @@ object FcmPayloadParser {
category = category,
podcastId = podcastId(data),
episodeId = episodeId(data),
feedUrl = feedUrl(data),
guid = guid(data),
enclosureUrl = enclosureUrl(data),
)
}

Expand All @@ -56,4 +62,13 @@ object FcmPayloadParser {

/** Snake or camel case episode id from FCM data. */
fun episodeId(data: Map<String, String>): String? = data["episode_id"] ?: data["episodeId"]

fun feedUrl(data: Map<String, String>): String? =
data["feedUrl"]?.trim()?.takeIf { it.isNotEmpty() }

fun guid(data: Map<String, String>): String? =
data["guid"]?.trim()?.takeIf { it.isNotEmpty() }

fun enclosureUrl(data: Map<String, String>): String? =
(data["enclosureUrl"] ?: data["enclosure_url"])?.trim()?.takeIf { it.isNotEmpty() }
}
36 changes: 36 additions & 0 deletions app/src/main/java/cx/aswin/boxlore/fcm/NewEpisodeFcmLogic.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package cx.aswin.boxlore.fcm

import android.net.Uri

/** Deep-link and id helpers for `type=new_episode` FCM payloads. */
internal object NewEpisodeFcmLogic {
fun usableEpisodeId(raw: String?): String? {
val id = raw?.trim().orEmpty()
if (id.isEmpty() || id == "0") return null
return id
}

fun route(
podcastId: String,
episodeId: String?,
podcastTitle: String,
): String {
val ep = usableEpisodeId(episodeId)
return if (ep != null) {
"boxlore://episode/$ep?autoplay=false&podcastId=${Uri.encode(podcastId)}" +
"&podcastTitle=${Uri.encode(podcastTitle)}"
} else {
"boxlore://podcast/$podcastId"
}
}

fun durationMinutes(
localDurationSeconds: Int?,
payloadDurationMinutes: String?,
): Int {
if (localDurationSeconds != null && localDurationSeconds > 0) {
return localDurationSeconds / 60
}
return payloadDurationMinutes?.toIntOrNull()?.coerceAtLeast(0) ?: 0
}
}
68 changes: 68 additions & 0 deletions app/src/main/java/cx/aswin/boxlore/fcm/NewEpisodePushHydration.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package cx.aswin.boxlore.fcm

import cx.aswin.boxlore.core.catalog.SubscriptionRepository
import cx.aswin.boxlore.core.domain.ports.EpisodeSupplementPort
import cx.aswin.boxlore.core.model.Episode
import kotlin.coroutines.cancellation.CancellationException

/**
* On a new-episode push, refresh the publisher feed for opted-in PI shows so the
* notification and auto-download use a local episode id (PI or negative supplement).
* Does not opt the show in from FCM.
*/
internal object NewEpisodePushHydration {
suspend fun resolveLocalEpisode(
podcastId: String,
payloadFeedUrl: String?,
payloadEnclosureUrl: String?,
subscriptionRepository: SubscriptionRepository,
episodeSupplementPort: EpisodeSupplementPort?,
payloadGuid: String? = null,
): Episode? {
val port = episodeSupplementPort ?: return null
if (!port.hasDirectFeedOptIn(podcastId)) return null
val entity = subscriptionRepository.getPodcastEntity(podcastId)
val feedUrl =
payloadFeedUrl?.trim().orEmpty().ifEmpty { entity?.feedUrl.orEmpty() }
val guid = payloadGuid?.trim().orEmpty()
val enclosure = payloadEnclosureUrl?.trim().orEmpty()
val tip =
try {
port.resolveNewestTipFromFeed(
EpisodeSupplementPort.NewestTipRequest(
podcastIndexId = podcastId,
feedUrl = feedUrl,
knownEpisodes = listOfNotNull(entity?.latestEpisode),
podcastTitle = entity?.title,
podcastImageUrl = entity?.imageUrl,
podcastGenre = entity?.genre,
podcastArtist = entity?.author,
match = EpisodeSupplementPort.FeedItemMatch(
guid = guid.takeIf { it.isNotEmpty() },
enclosureUrl = enclosure.takeIf { it.isNotEmpty() },
).takeIf { it.guid != null || it.enclosureUrl != null },
),
)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
null
}
if (tip != null) {
subscriptionRepository.updateLatestEpisode(podcastId, tip, markAsNew = true)
return tip
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
if (enclosure.isEmpty()) return null
val cached =
port
.getEpisodesForPodcast(
podcastIndexId = podcastId,
podcastTitle = entity?.title,
podcastImageUrl = entity?.imageUrl,
podcastGenre = entity?.genre,
podcastArtist = entity?.author,
).find { it.audioUrl.trim() == enclosure } ?: return null
subscriptionRepository.updateLatestEpisode(podcastId, cached, markAsNew = true)
return cached
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ internal fun androidx.navigation.NavGraphBuilder.addPodcastDestination(w: NavGra
deps = infoSharedDeps,
subscriptionRepository = subscriptionRepository,
rssRepository = container.rssPodcastRepository,
episodeSupplementPort = container.episodeSupplementRepository,
userPrefs = userPrefs,
routeArgs = cx.aswin.boxlore.feature.info.PodcastInfoRouteArgs(
entryPoint = entryPoint,
Expand Down
Loading
Loading