Skip to content
Open
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
2 changes: 2 additions & 0 deletions app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ Routes include onboarding, home, learn, briefing, settings, debug, explore, libr

`StartupWorkGate` is process-scoped and releases optional cloud coordinator initialization, periodic sync scheduling, Smart Downloads scheduling/catch-up, foreground subscription sync and legacy RSS repair after the initial screen commits. Optional initialization uses a suspending mutex so ready does not release a burst of initializers. Home signals after loaded local content and two frames; other initial routes release after their first two frames. A headless worker/widget launch releases at the first idle main queue; `MainActivity` identifies a UI launch before creating content. If the first Activity stops before content is ready, background startup also releases; configuration recreation waits for the replacement UI. Recreated roots reuse the same gate. Repeated signals and cancelled initializers are safe (`StartupWorkGateTest`). Adaptive ranking telemetry also waits for readiness. Appearance flows use restored fast-cache values while background hydration fills missing DataStore keys; existing choices are never overwritten. Play update checks begin after the first two Compose frames. Playback/session restoration and preference identity remain owned by the existing composition root.

- Release runtime dependency snapshots include Jsoup through `:core:catalog`, which owns shared show-notes resource and chapter parsing. No direct parser-library dependency is added here.

## Threading / lifecycle

- `AppContainer` is created once from `BoxLoreApplication.onCreate` and is application-scoped.
Expand Down
1 change: 1 addition & 0 deletions app/dependencies/releaseRuntimeClasspath.txt
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@ org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3
org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.7.3
org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3
org.jetbrains:annotations:23.0.0
org.jsoup:jsoup:1.23.2
org.jspecify:jspecify:1.0.0
sh.calvin.reorderable:reorderable-android:2.5.1
sh.calvin.reorderable:reorderable:2.5.1
15 changes: 15 additions & 0 deletions core/catalog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ Failed consent writes keep activation off, and local notification flags prevent

- The two-request concurrency cap applies to publisher-feed refreshes. Podcast Index chunk sync for not-ready shows and missing-feed-URL recovery retain their independent six-request limit.

- `shownotes/ShowNotesParser` interprets publisher HTML with Jsoup without fetching pages. It preserves meaningful anchor labels, resolves relative URLs only with a known base, classifies real domain boundaries and routes, filters infrastructure/media links, and deduplicates tracking variants while retaining case-sensitive paths, query values, and fragments. `EpisodeLinkTitles` removes destination-platform labels (including aliases and generic actions) so they cannot override a URL's profile handle. Social handles use platform-specific profile routes; login, sharing, and other reserved routes are not usernames. Description chapters require at least two valid timestamp/title lines; generated seek links never rewrite existing anchors, attributes, code, or prose clock times. `ChapterRepository` delegates description parsing to this shared parser.
- `crosspromo/CrossPromotionDetector` requires explicit feed-exchange wording or corroborated introduction/description evidence; Bonus metadata and missing episode numbering do not establish a promotion. A named introduction can be corroborated by a matching listening/following invitation even in long sentences with weekday schedules, without relying on short duration or Trailer metadata. Self-show comparison uses normalized whole names (including `and`/`&`) rather than substrings. `CrossPromotionResolver` retains the full extracted name, excludes the host, rejects ambiguous matches, and prefers an unambiguous Apple Podcasts destination. A lone unnamed Apple link must resolve to a matching title before it can be used; unrelated links fall back to search. Cancellation propagates and transient search failures remain retryable. Its bounded cache expires empty searches after one minute and successful matches after one hour.

## Internal structure

```text
Expand All @@ -58,6 +61,14 @@ src/main/java/cx/aswin/boxlore/core/catalog/
EpisodeMapper.kt
SubscriptionRepository.kt
ChapterRepository.kt
shownotes/
ShowNotesParser.kt
EpisodeLinkTitles.kt
DescriptionChapters.kt
EpisodeLinkClassifier.kt
crosspromo/
CrossPromotionDetector.kt
CrossPromotionResolver.kt
TranscriptRepository.kt
SharedAppDependencies.kt
SubscriptionForegroundSync.kt
Expand Down Expand Up @@ -89,6 +100,8 @@ Main Kotlin files should remain below 1000 lines; extracted helpers keep reposit
- Libraries: Retrofit, OkHttp, Gson, coroutines, DataStore, Firebase Database, Firebase Messaging, and Install Referrer.
- Reverse-edge rule: catalog must not depend on playback, downloads, designsystem, analytics, or feature modules.

- Show-notes HTML parsing uses Jsoup; the library stays inside `:core:catalog` and shared values remain in `:core:model`.

## Threading / lifecycle

- Production repositories are application-scoped through `AppContainer` and `SharedAppDependenciesHolder`.
Expand All @@ -114,6 +127,8 @@ Main Kotlin files should remain below 1000 lines; extracted helpers keep reposit
./gradlew :core:catalog:testDebugUnitTest --tests 'cx.aswin.boxlore.core.catalog.PodcastRepositoryCatalogTest'
```

- `ShowNotesParserTest`, `CrossPromotionDetectorTest`, and `CrossPromotionResolverTest` cover malformed/relative anchors, platform-label/handle separation, reserved profile routes, destination purpose and spoof domains, URL deduplication, safe timestamp linking, chapter boundaries, weekday-scheduled full-length introductions, false promotions and shorter-name prefixes, full-title matching, ambiguous/host matches, verified unnamed Apple links, retry, cancellation, and cache expiry.

## CI relevance

- `unit-tests.yml` runs catalog JVM tests.
Expand Down
1 change: 1 addition & 0 deletions core/catalog/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ dependencies {

// JSON Streaming
implementation(libs.gson)
implementation(libs.jsoup)
implementation(libs.okhttp)
// Firebase (database and messaging — SubscriptionRepository uses firebase.database + messaging)
implementation(platform(libs.firebase.bom))
Expand Down
1 change: 1 addition & 0 deletions core/catalog/dependencies/releaseRuntimeClasspath.txt
Original file line number Diff line number Diff line change
Expand Up @@ -124,4 +124,5 @@ org.jetbrains.kotlinx:kotlinx-serialization-core:1.6.3
org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.6.3
org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3
org.jetbrains:annotations:23.0.0
org.jsoup:jsoup:1.23.2
org.jspecify:jspecify:1.0.0
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cx.aswin.boxlore.core.catalog
import cx.aswin.boxlore.core.model.Chapter
import cx.aswin.boxlore.core.model.Episode
import java.net.URL
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
Expand Down Expand Up @@ -61,6 +62,8 @@ object ChapterRepository {
cache[normalizedUrl] = chapters
}
chapters
} catch (cancelled: CancellationException) {
throw cancelled
} catch (e: Exception) {
android.util.Log.w("ChapterRepo", "Failed to fetch chapters: $normalizedUrl", e)
emptyList()
Expand Down Expand Up @@ -141,72 +144,8 @@ object ChapterRepository {
* Parses chapter timestamps from the episode description.
* Supports both hh:mm:ss and mm:ss formats, and detects timestamps at either start or end of lines.
*/
fun parseChaptersFromDescription(htmlDescription: String?): List<Chapter> {
if (htmlDescription.isNullOrEmpty()) return emptyList()

try {
// Replace HTML paragraph, line break, and list tags with newlines
val cleanText = htmlDescription
.replace("(?i)<p>".toRegex(), "\n")
.replace("(?i)</p>".toRegex(), "\n")
.replace("(?i)<br\\s*/?>".toRegex(), "\n")
.replace("(?i)<li>".toRegex(), "\n")
.replace("<[^>]+>".toRegex(), "") // Strip all other HTML tags

val lines = cleanText.split("\n")
val chapters = mutableListOf<Chapter>()

// Regex to find timestamps (e.g. 12:34 or 1:12:34)
val timestampRegex = """\b(?:(\d{1,2}):)?(\d{1,2}):(\d{2})\b""".toRegex()

for (line in lines) {
val trimmed = line.trim()
val match = timestampRegex.find(trimmed) ?: continue

val hours = match.groups[1]?.value?.toIntOrNull() ?: 0
val minutes = match.groups[2]?.value?.toIntOrNull() ?: 0
val seconds = match.groups[3]?.value?.toIntOrNull() ?: 0

if (minutes >= 60 || seconds >= 60) continue

// Determine whether the title is before or after the timestamp
val matchStart = match.range.first
val matchEnd = match.range.last + 1

val rawTitle = if (matchStart > trimmed.length - matchEnd) {
trimmed.substring(0, matchStart)
} else {
trimmed.substring(matchEnd)
}

// Clean up any remaining braces, hyphens, colons, or whitespace around the title
val cleanTitle = rawTitle.trim()
.replace("^[^\\p{L}\\p{N}]+".toRegex(), "") // Strip leading non-alphanumeric chars
.replace("[^\\p{L}\\p{N}]+$".toRegex(), "") // Strip trailing non-alphanumeric chars
.trim()

if (cleanTitle.isNotEmpty()) {
val startTime = hours * 3600.0 + minutes * 60.0 + seconds.toDouble()
chapters.add(
Chapter(
startTime = startTime,
title = cleanTitle
)
)
}
}

// Safeguard: only return if we find at least 2 valid chapters (avoid random standalone timestamp false positives)
return if (chapters.size >= 2) {
chapters.sortedBy { it.startTime }
} else {
emptyList()
}
} catch (e: Exception) {
android.util.Log.w("ChapterRepo", "Failed to parse chapters from description", e)
return emptyList()
}
}
fun parseChaptersFromDescription(htmlDescription: String?): List<Chapter> =
cx.aswin.boxlore.core.catalog.shownotes.ShowNotesParser.parse(htmlDescription).chapters

private fun JSONObject.optStringOrNull(name: String, fallback: String? = null): String? {
if (isNull(name)) return fallback
Expand Down
Loading
Loading