Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
0a1104d
feat(settings): add dedicated feedback screen with sticky drafts and …
ashwkun Sep 26, 2026
4064578
fix(settings): enhance bottom clearance, scrub API URLs, and replace …
ashwkun Sep 26, 2026
f5323aa
feat(settings): add category-specific supporting flavors for email input
ashwkun Sep 26, 2026
1675118
fix(settings): expand log preview dialog and layout action buttons ho…
ashwkun Sep 26, 2026
d8acf90
feat(settings): refine feedback success screen with category flavors …
ashwkun Sep 26, 2026
2d1a572
feat(feedback): send full diagnostics and sanitized logs to feedback …
ashwkun Sep 26, 2026
2911165
style(feedback): improve card and row icon alignment in success view
ashwkun Sep 26, 2026
8088130
fix(feedback): add bottom padding to success screen to clear player a…
ashwkun Sep 26, 2026
cb5b3b0
fix(playback): only emit android auto analytics for auto and automoti…
ashwkun Sep 26, 2026
27c1a63
fix(feedback): clarify automatic log inclusion and replace export but…
ashwkun Sep 26, 2026
ac34cb0
docs(settings): document FeedbackSuccessView in README
ashwkun Sep 26, 2026
3758944
feat(auth): cascade delete cloud sync data on account deletion
ashwkun Sep 26, 2026
562faf9
feat(settings): link account deletion and data retention info in sign…
ashwkun Sep 26, 2026
0d37220
feat(settings): extract cloud sync and backups into dedicated setting…
ashwkun Sep 26, 2026
add27a8
feat(settings): overhaul avatar with dual spotlights, genre moods, an…
ashwkun Sep 26, 2026
5be2db4
feat(settings): add physical mood accessories and environmental stagi…
ashwkun Sep 26, 2026
35c1d28
fix(settings): intensify avatar backdrop glow and add contour outline…
ashwkun Sep 26, 2026
a552feb
feat(settings): refine avatar body shading, cute comedy state, and re…
ashwkun Sep 26, 2026
8819974
fix(settings): resolve lint permissions, regex complexity, and sonar …
ashwkun Sep 26, 2026
81883a0
fix(settings): resolve remaining sonar smells, redact basic auth, and…
ashwkun Sep 26, 2026
cfd9699
fix(settings): redact short key-value credentials in logcat output
ashwkun Sep 26, 2026
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
1 change: 1 addition & 0 deletions app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ The application module owns the Android app shell: `BoxLoreApplication`, `MainAc
- NPS survey UI (`surveys/`) applies `NpsSurveyBranching` after each answer so detractor / passive / promoter open-text paths end instead of falling through into the next score band (PostHog open-question `end` branching is not reliably persisted via API).
- `BoxLoreNavHost` owns app route registration and delegates screen bodies to feature modules; stack-slide transitions follow the visible Home → Explore → Library → Lore order, and Home’s first committed content frame unlocks the floating Lore launch animation.
- `NavGraphPodcastEpisodeDestinations` registers episode and podcast detail routes; episode full-path routes define `episodeDescription` as nullable with a default null value to safely handle empty, omitted, or special-character descriptions without argument bundle verification failures.
- `NavGraphSettingsDestinations` registers the settings hub, category pages, download settings, and the dedicated `"feedback"` full-page destination wired to `:feature:settings:feedback:FeedbackScreen`.
- Cold-start destination precedence (see `StartDestinationResolver`): incomplete onboarding → offline Downloads (no deep link) → Appearance **Open app to** Subscriptions or Downloads (no deep link) → Home. Deep links and push `target_route` still win over Open app to. When cold start opens Subscriptions or Downloads via that pref, `openedToLandingOnLaunch` makes Back navigate to Home (not Library hub); Library-tile entry still pops to Library. Offline-forced Downloads without that pref still exits on Back. That Home navigation is owned by `PredictiveBackWrapper` (NavHost has nothing to pop), so peek scale must restore after the gesture — see `:core:designsystem`.
- Process latest-episode sync (`SubscriptionForegroundSync`) and the versioned legacy RSS repair are started from `BoxLoreAppRoot` after onboarding. Repair is enabled only for upgraded installs running `0.0.18` through `0.0.30`, except `0.0.20`, starts only while online with at least one eligible pure `rss:` subscription, and admits at most one pass per app process. A fresh install on an allowed version writes the completed marker without inspecting or changing catalog rows, so updating a fresh `0.0.18` install to `0.0.19` cannot enable repair later. Unsupported versions and invalid package metadata fail closed. App foreground/screen-on events do not request another pass. While an eligible pass is active, the shell shows a persistent top popup with the boxlore loader; it cannot be dismissed and disappears when the pass settles. Returning to the app still requests normal subscription refresh (`SubscriptionResumeRefreshLogic` skips the first `ON_START` so Home keeps its 2s first-paint delay). Subscriptions-first launches still fetch because Library calls `requestRefresh` as soon as that screen is visible. Identified FCM payloads (guid/enclosure) that do not match a local episode no longer fall back to an unrelated newest tip.
- Tab destinations in `NavGraphTabDestinations` wire Home / Explore / Learn / Library / player entry points to feature screens. Home’s Offline Mix “View all downloads” action navigates directly to Library Downloads through this shell-owned wiring. Lore history deep-links use `entryPoint=learn` (canonical glossary; legacy `learn_history` still normalizes to `learn`). Explore and Subscriptions apply Appearance **Default tabs** when the route does not already pick a tab (genre Explore, `tab=trending` / `for_you`, and `library/subscriptions?tab=0|1` still win).
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/java/cx/aswin/boxlore/AppContainer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,9 @@ class AppContainer(
override fun getPendingEmail(): String? = prefs.getPendingAuthEmail()
override fun setPendingEmail(email: String?) = prefs.setPendingAuthEmail(email)
},
onPreDeleteAccount = {
userSyncCoordinator.deleteCloudSyncData().getOrThrow()
},
)
}.getOrElse {
object : cx.aswin.boxlore.core.auth.AuthRepository {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import kotlinx.coroutines.launch
internal fun androidx.navigation.NavGraphBuilder.addSettingsDestination(w: NavGraphWiring) {
addMainSettingsRoute(w)
addDownloadSettingsRoutes(w)
addFeedbackRoute(w)
}

private fun androidx.navigation.NavGraphBuilder.addMainSettingsRoute(w: NavGraphWiring) {
Expand Down Expand Up @@ -98,6 +99,7 @@ private fun androidx.navigation.NavGraphBuilder.addMainSettingsRoute(w: NavGraph
appInstanceId = appInstanceId,
initialPage = settingsPage,
isOnboarding = isFromOnboarding,
onSendFeedback = { navController.navigate("feedback") },
),
regionSettings = RegionSettings(
currentRegion = settingsState.currentRegion,
Expand Down Expand Up @@ -303,6 +305,19 @@ private fun androidx.navigation.NavGraphBuilder.addDownloadSettingsRoutes(w: Nav
}
}

private fun androidx.navigation.NavGraphBuilder.addFeedbackRoute(w: NavGraphWiring) {
val navController = w.navController
val container = w.container

composable("feedback") {
cx.aswin.boxlore.feature.settings.feedback.FeedbackScreen(
podcastRepository = container.podcastRepository,
boxcastPrefs = container.boxcastPrefs,
onBack = { navController.popBackStack() },
)
}
}

private suspend fun runLibraryExport(
application: android.app.Application,
uri: android.net.Uri,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ internal fun androidx.navigation.NavGraphBuilder.addHomeDestination(w: NavGraphW
onNavigateToSettings = { navController.navigate("settings?page=hub") },
onNavigateToPlayStoreReview = { launchInAppReview(context) },
onSubmitFeedback = onSubmitFeedback,
onFeedbackClick = { w.actions.onShowFeedbackSheet() },
onNavigateToDebug = { navController.navigate("debug") },
onImportClick = {
opmlCallbacks.onSourceChange("home_import_banner")
Expand Down
41 changes: 1 addition & 40 deletions app/src/main/java/cx/aswin/boxlore/ui/BoxLoreAppRoot.kt
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,6 @@ import cx.aswin.boxlore.core.playback.stopAndClearQueue
import cx.aswin.boxlore.core.prefs.PrefsFileMigrator
import cx.aswin.boxlore.fcm.FcmTopicHelper
import cx.aswin.boxlore.feature.home.ModeSwitchState
import cx.aswin.boxlore.feature.home.components.FeedbackSheet
import cx.aswin.boxlore.feature.onboarding.generateRecommendationsFromOpml
import cx.aswin.boxlore.feature.onboarding.markOnboardingCompletedSilent
import cx.aswin.boxlore.feature.player.v2.PlayerSheetActions
Expand Down Expand Up @@ -183,7 +182,6 @@ fun BoxLoreAppRoot(
val smartDownloadManager = container.smartDownloadManager
val installReferrerManager = container.installReferrerManager

var showFeedbackSheet by remember { mutableStateOf(false) }
val onSubmitFeedback: suspend (String, String, String, String) -> Boolean =
remember(podcastRepository) {
{ category, message, version, email ->
Expand Down Expand Up @@ -631,7 +629,7 @@ fun BoxLoreAppRoot(
NavHostActions(
onLoreQueueConflictEpisode = { loreQueueConflictEpisode = it },
queueLoreEpisode = queueLoreEpisode,
onShowFeedbackSheet = { showFeedbackSheet = true },
onShowFeedbackSheet = { navController.navigate("feedback") },
onSubmitFeedback = onSubmitFeedback,
),
settingsState =
Expand Down Expand Up @@ -910,43 +908,6 @@ fun BoxLoreAppRoot(
},
),
)

if (showFeedbackSheet) {
val versionStr =
remember {
try {
activity.packageManager.getPackageInfo(activity.packageName, 0).versionName
?: "unknown"
} catch (_: Exception) {
"unknown"
}
}
FeedbackSheet(
appVersion = versionStr,
onSubmit = onSubmitFeedback,
onRateInstead = {
showFeedbackSheet = false
try {
activity.startActivity(
android.content.Intent(
android.content.Intent.ACTION_VIEW,
android.net.Uri.parse("market://details?id=${activity.packageName}"),
),
)
} catch (_: Exception) {
activity.startActivity(
android.content.Intent(
android.content.Intent.ACTION_VIEW,
android.net.Uri.parse(
"https://play.google.com/store/apps/details?id=${activity.packageName}",
),
),
)
}
},
onDismissRequest = { showFeedbackSheet = false },
)
}
}
}
}
1 change: 1 addition & 0 deletions core/analytics/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ src/main/java/cx/aswin/boxlore/core/analytics/

- Unit tests live under `src/test/java/cx/aswin/boxlore/core/analytics`.
- Use `RecordingAnalytics` when testing classes that accept `Analytics`.
- Use `AnalyticsHelper.installRecordingSink(recorder)` when testing classes or callbacks that interact directly with the singleton `AnalyticsHelper`.
- `GlossaryAllEventsEmissionTest` covers every `emission:` inventory row; `LifecycleSdkMappingTest` / `InstallChannelAttributionTest` assert no dual open/install emits.
- Architecture guards in `:core:testing` enforce CSV ↔ allowlist ↔ inventory parity.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ object AnalyticsHelper : Analytics {

@Volatile private var activePauseReason: String = "user_voluntary"

@androidx.annotation.VisibleForTesting
fun installRecordingSink(recorder: MutableList<Pair<String, Map<String, Any>>>): () -> Unit =
AnalyticsEmit.installRecordingSink(recorder)

fun setSeekSource(source: String) {
activeSeekSource = source
}
Expand Down
2 changes: 1 addition & 1 deletion core/auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Provides authentication abstractions and implementations (Firebase).

- `AuthRepository` (authentication operations, Google, Magic Link, Email/Password, `sendEmailVerification()`, `reloadUser()`)
- `AuthException`, `RecentLoginRequiredException`
- `FirebaseAuthRepository`
- `FirebaseAuthRepository` (supports optional `onPreDeleteAccount` callback invoked before Firebase account deletion)

## Internal structure

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class FirebaseAuthRepository(
private val pendingEmailStore: PendingEmailStore? = null,
private val magicLinkUrl: String = MAGIC_LINK_DEFAULT_URL,
private val packageName: String = PACKAGE_NAME_DEFAULT,
private val onPreDeleteAccount: (suspend () -> Unit)? = null,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
) : AuthRepository {

private val _currentUser = MutableStateFlow(auth.currentUser.toBoxLoreUser())
Expand Down Expand Up @@ -98,6 +99,7 @@ class FirebaseAuthRepository(
override suspend fun deleteAccount(): Result<Unit> = runCatching {
val user = auth.currentUser ?: error("No authenticated user to delete")
try {
onPreDeleteAccount?.invoke()
user.delete().awaitTask()
pendingEmailStore?.setPendingEmail(null)
} catch (e: Exception) {
Expand Down Expand Up @@ -126,6 +128,7 @@ class FirebaseAuthRepository(
"recent-login",
"requires-recent-login",
"credential_too_old",
"ERROR_REQUIRES_RECENT_LOGIN",
)
}
}
Expand Down
4 changes: 2 additions & 2 deletions core/catalog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Owns catalog orchestration: Podcast Index access through `PodcastRepository`, su

## Public API

- `PodcastRepository` coordinates Podcast Index calls, recommendation endpoints, curated vibes, home bootstrap, similar episodes (with content languages), content catalog/v3, and RSS delegation. Canonical `PodcastEntity.toPodcast()` mapper standardizes entity conversion across data and feature modules, with module-boundary isolation between `:core:catalog` and `:core:rss`. Subscribed PI shows whose local catalog is **ready** (`LocalEpisodeCatalogPort.isReady`) serve list/search/`getEpisode` from Room only. Unsubscribe stamps a 14-day TTL; that catalog stays on disk but is **not** ready, so unsubscribed reads keep today’s PI pages plus cached extras merge. True `rss:` rows are unchanged. `getEpisodes` uses a bounded window (200), not `getAllNewest`. `loadPiEpisodesForBaseline` remains for **first** catalog persist rematch only. `invalidateEpisodesCache` drops the 5-minute PI page cache for a feed (Podcast Info pull-to-refresh). In-memory caching and pagination safety are managed by `PodcastRepositoryMemoryPolicy` (`clampPageLimit` caps requests to 100, and `TimedLruCache` bounds cache entries with LRU and TTL eviction to prevent OutOfMemoryError).
- `PodcastRepository` coordinates Podcast Index calls, recommendation endpoints, curated vibes, home bootstrap, similar episodes (with content languages), content catalog/v3, user feedback submission (`submitFeedback` transmitting categorized feedback, repro steps, optional email, and diagnostics to proxy), and RSS delegation. Canonical `PodcastEntity.toPodcast()` mapper standardizes entity conversion across data and feature modules, with module-boundary isolation between `:core:catalog` and `:core:rss`. Subscribed PI shows whose local catalog is **ready** (`LocalEpisodeCatalogPort.isReady`) serve list/search/`getEpisode` from Room only. Unsubscribe stamps a 14-day TTL; that catalog stays on disk but is **not** ready, so unsubscribed reads keep today’s PI pages plus cached extras merge. True `rss:` rows are unchanged. `getEpisodes` uses a bounded window (200), not `getAllNewest`. `loadPiEpisodesForBaseline` remains for **first** catalog persist rematch only. `invalidateEpisodesCache` drops the 5-minute PI page cache for a feed (Podcast Info pull-to-refresh). In-memory caching and pagination safety are managed by `PodcastRepositoryMemoryPolicy` (`clampPageLimit` caps requests to 100, and `TimedLruCache` bounds cache entries with LRU and TTL eviction to prevent OutOfMemoryError).
- `syncSubscriptions` (`POST /sync`) is PI-only for shows that are **not** catalog-ready and **not** still on extras opt-in. Ready ids contribute the Room newest tip; opted-in not-ready ids contribute the cached extras tip. Live feed freshen stays on `SubscriptionForegroundSync`. `getEpisodeWindow` is the bounded Smart Queue / Auto entry (never `getAllNewest`).
- Show search: legacy `searchPodcasts` / `searchPodcastsWithCorrection` keep calling `GET /search` (hybrid). New clients use `searchPodcastsTypeahead` (`GET /search/typeahead` / Meili) and `searchPodcastsGrouped` (parallel typeahead + hybrid → Matches / Also found). Concept search uses `searchSemanticGrouped` / `searchEpisodesSemantic` (`GET /search/semantic`: one CF embed → Qdrant podcasts + episodes; additive `feeds` + legacy `items`).
- `logic.mergeShowSearchResults` dedupes Meili vs hybrid by id / itunes / feed URL.
Expand All @@ -22,7 +22,7 @@ Owns catalog orchestration: Podcast Index access through `PodcastRepository`, su
- `InstallReferrerManager` parses Play Install Referrer deep links and exposes optional `onInstallReferrerResolved` (channel + raw referrer). `:app` wires that callback into analytics; catalog must not depend on `:core:analytics`.
- `RoomLocalCatalog` implements `LocalCatalogPort`; its metadata enrichment transaction updates only an existing subscribed row, so a late Podcast Info response cannot create or restore a subscription after unsubscribe. `RoomEpisodeOfflineLookup` implements `EpisodeOfflineLookupPort`.
- `ports.ListeningHistoryBackupPort`, `ports.SmartDownloadSyncPort`, `ports.QueueSyncPort`, `ports.ActivePlaybackSyncPort`, and `ports.PodcastNotificationSyncPort` keep backup, download, notification topic bindings, and playback seams cleanly decoupled without introducing forbidden `:core:catalog` to `:core:playback` dependency cycles.
- `sync.UserSyncCoordinator` orchestrates Boxlore Realtime Cloud Sync: batch-bounded dirty push with multi-column optimistic concurrency flag clearing, delta pull, account switch local data purge and active playback session teardown via `activePlaybackSyncPort.stopAndClearActiveSession()`, and coordination across `SubscriptionSyncResolver` (LWW subscription and tombstone resolution, RSS ingestion), `HistorySyncResolver` (active playing episode shield, progress LWW, independent like status LWW), and `QueueSyncResolver` (monotonic versioning, 3-way tombstone merge, head-position retention of active episode, and 5-tier metadata hydration). Results are communicated via `sync.SyncResult`, with reactive UI state modelled via `sync.CloudSyncUiStatus`.
- `sync.UserSyncCoordinator` orchestrates Boxlore Realtime Cloud Sync: batch-bounded dirty push with multi-column optimistic concurrency flag clearing, delta pull, cloud account deletion (`deleteCloudSyncData`), account switch local data purge and active playback session teardown via `activePlaybackSyncPort.stopAndClearActiveSession()`, and coordination across `SubscriptionSyncResolver` (LWW subscription and tombstone resolution, RSS ingestion), `HistorySyncResolver` (active playing episode shield, progress LWW, independent like status LWW), and `QueueSyncResolver` (monotonic versioning, 3-way tombstone merge, head-position retention of active episode, and 5-tier metadata hydration). Results are communicated via `sync.SyncResult`, with reactive UI state modelled via `sync.CloudSyncUiStatus`.
- `FolderRepository` and `RoomFolderRepository` manage library subscription folders: creating iconless or icon-assigned folders with optional `showPodcastGrid` 1×1 cover style, updating display sizes, restoring folders from JSON backups (`restoreFolder`), auto-organizing subscribed shows into genre folders (with selectable display sizes and cover styles, while preserving custom and existing folders), auto-syncing subscribed shows matching `linkedGenre` (with canonical genre token matching and Tech/Technology synonym normalization), moving shows between folders on genre customization, adding/removing podcasts from folders, and cascading folder deletions without unsubscribing shows.
- `:core:rss`, `:core:domain`, `:core:database`, and `:core:prefs` are re-exported where existing public signatures require those types.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -679,11 +679,24 @@ class PodcastRepository(
}
}

suspend fun submitFeedback(category: String, message: String, appVersion: String, email: String? = null,): Boolean = withContext(ioDispatcher) {
suspend fun submitFeedback(
category: String,
message: String,
appVersion: String,
email: String? = null,
diagnostics: String? = null,
logs: String? = null,
): Boolean = withContext(ioDispatcher) {
try {
val request =
cx.aswin.boxlore.core.network.model
.FeedbackRequest(category, message, appVersion, email)
cx.aswin.boxlore.core.network.model.FeedbackRequest(
category = category,
message = message,
appVersion = appVersion,
email = email,
diagnostics = diagnostics,
logs = logs,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
val response = api.submitFeedback(publicKey, request).execute()
response.isSuccessful && response.body()?.success == true
} catch (e: Exception) {
Expand Down
Loading
Loading