From 93953655b5ff1199ffee7183d788251d7073e6a0 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Sun, 1 Nov 2026 19:03:24 +0530 Subject: [PATCH 1/9] fix(analytics): ask for notifications on intent and fix misreported events Notifications are requested only from a show's bell and the import card. The review prompt counts episodes finished since install, uses each milestone once and waits two days after install or a library import. Analytics fixes: phone plays no longer labelled as Android Auto, Explore impressions split from taps, review prompts no longer trigger the NPS survey, set-once person properties go through the SDK's set-once slot, playback events keep only allowed entry details, queue clears and batch adds report what the listener did, Echo text is capped, and settings report only real changes. --- app/README.md | 2 + .../NavGraphSettingsDestinations.kt | 17 +- .../navigation/NavGraphTabDestinations.kt | 12 +- .../boxlore/navigation/NavGraphWiring.kt | 16 +- .../surveys/BoxcastPostHogSurveysDelegate.kt | 5 +- .../cx/aswin/boxlore/ui/BoxLoreAppRoot.kt | 24 +-- .../FeatureAnnouncementOverlay.kt | 1 + .../announcement/InAppAnnouncementDialog.kt | 16 +- .../ImportNotificationPermissionCard.kt | 6 +- app/src/main/res/xml/backup_rules.xml | 2 + .../main/res/xml/data_extraction_rules.xml | 4 + core/analytics/README.md | 10 +- .../aswin/boxlore/core/analytics/Analytics.kt | 6 +- .../boxlore/core/analytics/AnalyticsEmit.kt | 22 ++ .../core/analytics/AnalyticsGlossary.kt | 10 +- .../boxlore/core/analytics/AnalyticsHelper.kt | 61 +++--- .../boxlore/core/analytics/AnnouncementRef.kt | 7 + .../analytics/DiscoveryAnalyticsTracks.kt | 60 +++--- .../core/analytics/EntryPointContext.kt | 28 +++ .../core/analytics/EpisodeRecipeIds.kt | 4 +- .../analytics/GrowthSessionAnalyticsTracks.kt | 15 +- .../analytics/OnboardingAnalyticsTracks.kt | 93 ++++----- .../core/analytics/PlaybackAnalyticsTracks.kt | 6 +- .../analytics/QueueContentAnalyticsTracks.kt | 11 +- .../core/analytics/RecordingAnalytics.kt | 17 +- .../analytics/AnalyticsHelperStateTest.kt | 35 ++-- .../analytics/AnalyticsTracksFacadeTest.kt | 8 +- .../GlossaryAllEventsEmissionTest.kt | 16 +- .../core/analytics/PersonSetOnceTest.kt | 60 ++++++ .../analytics/RecordingAnalyticsExtraTest.kt | 14 +- .../core/analytics/RecordingAnalyticsTest.kt | 74 ++++++- core/catalog/README.md | 1 + .../catalog/EngagementPromptCoordinator.kt | 6 +- .../core/catalog/InstallReferrerManager.kt | 2 + .../catalog/InstallReferrerManagerTest.kt | 10 + core/playback/README.md | 3 + .../core/playback/CastMediaItemConverter.kt | 2 - .../boxlore/core/playback/FirstPlayClaim.kt | 56 +++++ .../core/playback/PlaybackQueueCoordinator.kt | 3 +- .../core/playback/PlaybackTelemetrySession.kt | 8 +- .../boxlore/core/playback/QueueRepository.kt | 13 +- .../service/BoxLorePlaybackService.kt | 5 + .../service/auto/AutoBrowseLibraryCallback.kt | 33 ++- .../service/auto/AutoVoiceSearchHandler.kt | 54 ++--- .../playback/service/auto/CarPlayLabel.kt | 12 ++ .../playback/CastMediaItemConverterTest.kt | 5 + .../core/playback/FirstPlayClaimTest.kt | 55 +++++ .../core/playback/QueueRepositoryTest.kt | 22 ++ .../service/auto/AutoEpisodeAliasListsTest.kt | 2 +- .../playback/service/auto/CarPlayLabelTest.kt | 30 +++ core/prefs/README.md | 1 + .../core/prefs/EngagementPreferences.kt | 61 ++++-- .../core/prefs/EngagementPromptConstants.kt | 3 + .../core/prefs/UserPreferencesRepository.kt | 8 +- .../core/prefs/ReviewPromptPreferencesTest.kt | 135 ++++++++++++ .../prefs/UserPreferencesRepositoryTest.kt | 48 ----- docs/ANALYTICS_EVENT_GLOSSARY.md | 27 ++- docs/analytics/event_glossary.csv | 20 +- docs/analytics/glossary_emission_coverage.csv | 4 + feature/explore/README.md | 1 + feature/home/README.md | 1 + .../feature/home/HomeListeningHistoryItem.kt | 10 + .../aswin/boxlore/feature/home/HomeScreen.kt | 7 +- .../boxlore/feature/home/HomeViewModel.kt | 13 +- .../feature/home/HomeViewModelLoadData.kt | 17 +- .../feature/home/components/ReviewSheet.kt | 2 + .../feature/home/logic/HomeUiAssemblyLogic.kt | 5 +- .../home/HomeListeningHistoryItemTest.kt | 23 +++ feature/info/README.md | 1 + .../feature/info/PodcastInfoEpisodeActions.kt | 25 ++- .../feature/info/PodcastInfoScreenParts.kt | 10 +- feature/onboarding/README.md | 1 + .../onboarding/OnboardingViewModelAi.kt | 1 - feature/settings/README.md | 1 + .../feature/settings/SettingsScreen.kt | 193 ++++++++++-------- .../downloads/AutoDownloadSettingsActions.kt | 9 + .../downloads/AutoDownloadSettingsScreen.kt | 48 ++++- .../downloads/SmartDownloadsSettingsScreen.kt | 9 + .../settings/feedback/FeedbackScreen.kt | 2 + .../settings/feedback/FeedbackViewModel.kt | 8 + .../settings/AppearanceActionsTrackedTest.kt | 37 +++- .../AutoDownloadSettingsActionsTest.kt | 18 ++ release-notes/upcoming.md | 2 + 83 files changed, 1213 insertions(+), 522 deletions(-) create mode 100644 core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/AnnouncementRef.kt create mode 100644 core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/EntryPointContext.kt create mode 100644 core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/PersonSetOnceTest.kt create mode 100644 core/playback/src/main/java/cx/aswin/boxlore/core/playback/FirstPlayClaim.kt create mode 100644 core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/CarPlayLabel.kt create mode 100644 core/playback/src/test/java/cx/aswin/boxlore/core/playback/FirstPlayClaimTest.kt create mode 100644 core/playback/src/test/java/cx/aswin/boxlore/core/playback/service/auto/CarPlayLabelTest.kt create mode 100644 core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/ReviewPromptPreferencesTest.kt diff --git a/app/README.md b/app/README.md index 2d144e334..ab15b3da8 100644 --- a/app/README.md +++ b/app/README.md @@ -219,6 +219,8 @@ Episode push artwork keeps its six-second total deadline and uses 700ms per rout ## Testing notes +- Notifications are asked for only where the listener shows intent: a show's bell (`feature/info`) and the import card (`ImportNotificationPermissionCard`); Home no longer asks on every visit. `notification_permission_requested` and `_decided` carry the `surface` (`show_bell`, `library_import`). Every library import that reaches Success calls `markLibraryImported`, starting the review prompt's quiet days. In-app alerts send `feature_announcement_action` viewed, dismissed and acted with the alert's `announcement_id` (its timestamp), title, presentation and whether it draws an action button; the Android Auto overlay's Continue now sends dismissed. `launchInAppReview` sends `store_review_opened`. `BackgroundCheckBackupRulesTest` reads the backup rules; `boxlore_install_milestones.xml` is excluded beside `boxlore_listen_note.xml`. + - `EpisodeShowLinksTest` opens every episode link on the real route and arguments: `?show=` from the share page and `&show=` share links on each share address, with and without `t`, a clip or `autoplay`, links the app builds, older links without a show, and the notifier's route. `WebAppLinksManifestTest` also checks every episode share pattern, with and without the show, is on a claimed host and path. `FcmPayloadParserTest`, `NewEpisodePushHydrationTest` and `NewEpisodeFcmLogicTest` cover the push fields, GUID/enclosure before catalog key and recipe ID, and the recipe-ID route. `EpisodeShareRegistrationWorkerTest` covers the unique name (unchanged by the audio address a GUID request carries), constraints, backoff, the attempt cap and invalid input. `UpgradeRepairWorkerTest` covers the repair's permanent unique name, constraints and backoff, KEEP across launches, no work for a fresh install or `rss:`-only follows, result mapping with the per-launch cap, errors counted as retries, and a run of the real repair to the end; `AppContainerSmokeTest` checks the container hands workers one repair and settles a fresh install. `PodcastNotificationRegistrationWorkerTest` covers its unique name per show (none for `rss:` shows, a newer request replacing the waiting one, a request returning once the work is saved), constraints, backoff, the attempt cap, errors counted as retries and work without a show. `NewEpisodeDeliveryWorkerTest` covers delivery names with and without a recipe ID or catalog key. `EpisodeShowLinksTest` also opens install-referrer routes with and without a show, and `DeepLinkAutoplayPlanTest` covers autoplay and seek for a link that resolves to the phone's own ID and a stale page for another link. - Tablet context details use their native Back toolbar and keep docked playback in the browsing pane. The context player can be minimized independently. Fullscreen media requests share an app-owned window host across docked/context presentations, restoring the originating presentation when closed. diff --git a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt index 5ece5077c..79ab15870 100644 --- a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt +++ b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt @@ -254,9 +254,12 @@ private fun buildPlaybackSettings(w: NavGraphWiring): PlaybackSettings { val userPrefs = w.userPrefs val settingsState = w.settingsState - fun trackAndPersistPlaybackDuration(eventName: String, value: Long, persist: suspend (Long) -> Unit) { - cx.aswin.boxlore.core.analytics.AnalyticsHelper - .trackSettingsInteraction(eventName, value.toString()) + // Reported only when the value changes, so re-picking the current length sends nothing. + fun trackAndPersistPlaybackDuration(eventName: String, value: Long, previous: Long, persist: suspend (Long) -> Unit) { + if (value != previous) { + cx.aswin.boxlore.core.analytics.AnalyticsHelper + .trackSettingsInteraction(eventName, value.toString()) + } scope.launch { persist(value) } } @@ -276,16 +279,16 @@ private fun buildPlaybackSettings(w: NavGraphWiring): PlaybackSettings { actions = PlaybackActions( onSetSkipBehavior = { behavior -> scope.launch { userPrefs.setSkipBehavior(behavior) } }, onSetSkipBeginningMs = { value -> - trackAndPersistPlaybackDuration("skip_beginning_changed", value, userPrefs::setSkipBeginningMs) + trackAndPersistPlaybackDuration("skip_beginning_changed", value, settingsState.skipBeginningMs, userPrefs::setSkipBeginningMs) }, onSetSkipEndingMs = { value -> - trackAndPersistPlaybackDuration("skip_ending_changed", value, userPrefs::setSkipEndingMs) + trackAndPersistPlaybackDuration("skip_ending_changed", value, settingsState.skipEndingMs, userPrefs::setSkipEndingMs) }, onSetSeekBackwardMs = { value -> - trackAndPersistPlaybackDuration("seek_backward_changed", value, userPrefs::setSeekBackwardMs) + trackAndPersistPlaybackDuration("seek_backward_changed", value, settingsState.seekBackwardMs, userPrefs::setSeekBackwardMs) }, onSetSeekForwardMs = { value -> - trackAndPersistPlaybackDuration("seek_forward_changed", value, userPrefs::setSeekForwardMs) + trackAndPersistPlaybackDuration("seek_forward_changed", value, settingsState.seekForwardMs, userPrefs::setSeekForwardMs) }, onSetHideCompletedInHome = { hide -> scope.launch { userPrefs.setHideCompletedInHome(hide) } }, onSetHideCompletedInSubs = { hide -> scope.launch { userPrefs.setHideCompletedInSubs(hide) } }, diff --git a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphTabDestinations.kt b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphTabDestinations.kt index aa3e42283..7a8c87e2d 100644 --- a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphTabDestinations.kt +++ b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphTabDestinations.kt @@ -1,6 +1,5 @@ package cx.aswin.boxlore.navigation -import android.Manifest import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -59,22 +58,13 @@ internal fun androidx.navigation.NavGraphBuilder.addHomeDestination(w: NavGraphW val context = w.context val opmlCallbacks = w.opmlCallbacks val onboardingViewModel = w.session.onboardingViewModel - val showFeatureDialog = w.session.showFeatureDialog - val permissionLauncher = w.session.permissionLauncher val onSubmitFeedback = w.actions.onSubmitFeedback // ----------------------------------------------------------------------- // Home // ----------------------------------------------------------------------- composable("home") { - LaunchedEffect(showFeatureDialog) { - if (shouldRequestNotificationPermission(showFeatureDialog, context)) { - cx.aswin.boxlore.core.analytics.AnalyticsHelper - .trackNotificationPermissionRequested() - cx.aswin.boxlore.core.prefs.BoxcastPrefs(context).setHasRequestedNotificationPermission(true) - permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) - } - } + // Notifications are asked for only where the listener shows intent: a show's bell and the import card. HomeRoute( podcastRepository = podcastRepository, playbackRepository = playbackRepository, diff --git a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphWiring.kt b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphWiring.kt index 28e216f5d..89a79ddac 100644 --- a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphWiring.kt +++ b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphWiring.kt @@ -1,17 +1,12 @@ package cx.aswin.boxlore.navigation -import android.Manifest import android.annotation.SuppressLint -import android.content.pm.PackageManager -import android.os.Build -import androidx.activity.compose.ManagedActivityResultLauncher import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally -import androidx.core.content.ContextCompat import androidx.navigation.NavController import androidx.navigation.NavHostController import cx.aswin.boxlore.BoxLoreApplication @@ -190,9 +185,7 @@ data class NavHostSession( val openedToLandingOnLaunch: androidx.compose.runtime.MutableState, val currentEpisode: Episode?, val miniPlayerPadding: androidx.compose.ui.unit.Dp, - val showFeatureDialog: Boolean, val hasSeenMarkPlayedTip: Boolean, - val permissionLauncher: ManagedActivityResultLauncher, val appInstanceId: String?, ) @@ -398,15 +391,10 @@ internal fun entryPointBundle(entryPoint: String?, vibeId: String = "", carousel @androidx.compose.runtime.Composable internal fun miniPlayerBottomPadding(isPlayerVisible: Boolean): androidx.compose.ui.unit.Dp = appBottomChromeContentPadding(isMiniPlayerVisible = isPlayerVisible) -internal fun shouldRequestNotificationPermission(showFeatureDialog: Boolean, context: android.content.Context,): Boolean = !showFeatureDialog && - Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && - ContextCompat.checkSelfPermission( - context, - Manifest.permission.POST_NOTIFICATIONS, - ) != PackageManager.PERMISSION_GRANTED - internal fun launchInAppReview(context: android.content.Context) { val activity = context as? androidx.activity.ComponentActivity ?: return + cx.aswin.boxlore.core.analytics.AnalyticsHelper + .trackStoreReviewOpened(method = "in_app_review", source = "review_prompt") val reviewManager = com.google.android.play.core.review.ReviewManagerFactory.create(activity) reviewManager.requestReviewFlow().addOnCompleteListener { task -> if (task.isSuccessful) { diff --git a/app/src/main/java/cx/aswin/boxlore/surveys/BoxcastPostHogSurveysDelegate.kt b/app/src/main/java/cx/aswin/boxlore/surveys/BoxcastPostHogSurveysDelegate.kt index 21440702a..01b262771 100644 --- a/app/src/main/java/cx/aswin/boxlore/surveys/BoxcastPostHogSurveysDelegate.kt +++ b/app/src/main/java/cx/aswin/boxlore/surveys/BoxcastPostHogSurveysDelegate.kt @@ -32,11 +32,8 @@ class BoxcastPostHogSurveysDelegate( activityProvider = activityProvider, userPrefs = userPrefs, onSurveyDisplayed = { + // PostHog records the display itself ("survey shown"); a copy here counted it twice. engagementCoordinator.onSurveyDisplayed() - AnalyticsHelper.trackEngagementPromptShown( - promptType = "nps_survey", - source = "posthog", - ) }, onFirstRatingSubmitted = { score -> engagementCoordinator.onNpsRatingSubmitted(score) diff --git a/app/src/main/java/cx/aswin/boxlore/ui/BoxLoreAppRoot.kt b/app/src/main/java/cx/aswin/boxlore/ui/BoxLoreAppRoot.kt index d1bef8067..bb00cd655 100644 --- a/app/src/main/java/cx/aswin/boxlore/ui/BoxLoreAppRoot.kt +++ b/app/src/main/java/cx/aswin/boxlore/ui/BoxLoreAppRoot.kt @@ -3,8 +3,6 @@ package cx.aswin.boxlore.ui import android.net.Uri import android.util.Log import androidx.activity.ComponentActivity -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -443,7 +441,6 @@ fun BoxLoreAppRoot( FontRoundness.axisValue(fontRoundnessKey) } val hasSeenMarkPlayedTip by userPrefs.hasSeenMarkPlayedTip.collectAsState(initial = true) - val hasLoggedFirstPlay by userPrefs.hasLoggedFirstPlay.collectAsState(initial = true) val activeAnnouncement by userPrefs.activeAnnouncementStream.collectAsState(initial = null) val dismissedFeatureVersion by userPrefs.dismissedFeatureVersion.collectAsState(initial = "") val legacyRssRepairInProgress by container.legacyRssRepair.inProgress.collectAsStateWithLifecycle() @@ -478,13 +475,6 @@ fun BoxLoreAppRoot( var appInstanceId by remember { mutableStateOf(null) } var isInitialHomeContentReady by remember { mutableStateOf(false) } - val permissionLauncher = - rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission(), - ) { isGranted -> - AnalyticsHelper.trackNotificationPermissionDecided(isGranted) - } - LaunchedEffect(Unit) { FcmTopicHelper.subscribeDefaultTopics(activity) } @@ -492,6 +482,11 @@ fun BoxLoreAppRoot( var opmlImportState by remember { mutableStateOf(OpmlImportState.Idle) } var opmlImportSource by remember { mutableStateOf("welcome_screen") } var importTriggerKey by remember { mutableStateOf(0L) } + // Every import path ends in Success; Home's review prompt waits a couple of days after one. + val importSucceeded = opmlImportState is OpmlImportState.Success + LaunchedEffect(importSucceeded) { + if (importSucceeded) userPrefs.markLibraryImported() + } fun performJsonImport(uri: android.net.Uri) { scope.launch { @@ -529,13 +524,6 @@ fun BoxLoreAppRoot( } } - LaunchedEffect(isPlaying, hasLoggedFirstPlay) { - if (isPlaying && !hasLoggedFirstPlay) { - AnalyticsHelper.trackFirstEpisodePlayed() - userPrefs.markFirstPlayLogged() - } - } - val featureAnnouncementId = "android_auto_1_3_6" var activeFeatureFlag by remember { mutableStateOf(null) } LaunchedEffect(dismissedFeatureVersion) { @@ -659,9 +647,7 @@ fun BoxLoreAppRoot( } else { miniPlayerPadding }, - showFeatureDialog = showFeatureDialog, hasSeenMarkPlayedTip = hasSeenMarkPlayedTip, - permissionLauncher = permissionLauncher, appInstanceId = appInstanceId, ), opmlCallbacks = diff --git a/app/src/main/java/cx/aswin/boxlore/ui/announcement/FeatureAnnouncementOverlay.kt b/app/src/main/java/cx/aswin/boxlore/ui/announcement/FeatureAnnouncementOverlay.kt index a4f20d413..b6b3d97b5 100644 --- a/app/src/main/java/cx/aswin/boxlore/ui/announcement/FeatureAnnouncementOverlay.kt +++ b/app/src/main/java/cx/aswin/boxlore/ui/announcement/FeatureAnnouncementOverlay.kt @@ -131,6 +131,7 @@ fun FeatureAnnouncementOverlay(featureAnnouncementId: String, userPrefs: UserPre Box(modifier = Modifier.graphicsLayer { alpha = phase3.value }) { FilledTonalButton( onClick = { + AnalyticsHelper.trackFeatureAnnouncementDismissed(featureAnnouncementId) scope.launch { userPrefs.dismissFeatureAnnouncement(featureAnnouncementId) } }, shape = CircleShape, diff --git a/app/src/main/java/cx/aswin/boxlore/ui/announcement/InAppAnnouncementDialog.kt b/app/src/main/java/cx/aswin/boxlore/ui/announcement/InAppAnnouncementDialog.kt index f8c91ffc7..f2df77a4a 100644 --- a/app/src/main/java/cx/aswin/boxlore/ui/announcement/InAppAnnouncementDialog.kt +++ b/app/src/main/java/cx/aswin/boxlore/ui/announcement/InAppAnnouncementDialog.kt @@ -47,6 +47,7 @@ import coil.compose.AsyncImage import cx.aswin.boxlore.BuildConfig import cx.aswin.boxlore.R import cx.aswin.boxlore.core.analytics.AnalyticsHelper +import cx.aswin.boxlore.core.analytics.AnnouncementRef import cx.aswin.boxlore.core.designsystem.analytics.TrackScreenView import cx.aswin.boxlore.core.designsystem.components.BoxloreDialog as Dialog import cx.aswin.boxlore.core.prefs.UserPreferencesRepository.Announcement @@ -61,12 +62,21 @@ fun InAppAnnouncementDialog(announcement: Announcement, onDismiss: () -> Unit, o val actions = remember(announcement) { announcementActions(announcement) } val fullscreen = announcement.presentation == "fullscreen" val colors = announcementColors(MaterialTheme.colorScheme, announcement.tone) + val ref = remember(announcement) { AnnouncementRef(announcement.timestamp.toString(), announcement.title, announcement.presentation) } + val hasImage = !announcement.imageUrl.isNullOrBlank() + // Whether the alert shows a button that does something, from the buttons it actually draws. + val hasAction = actions.isNotEmpty() val dismiss = { - AnalyticsHelper.trackInAppAnnouncementDismissed(announcement.category, !announcement.imageUrl.isNullOrBlank(), announcement.showActionInApp) + AnalyticsHelper.trackInAppAnnouncementDismissed(announcement.category, hasImage, hasAction, ref) onDismiss() } + val act: (String) -> Unit = { route -> + val kind = actions.firstOrNull { it.route == route }?.kind ?: AnnouncementActionKind.CUSTOM + AnalyticsHelper.trackInAppAnnouncementAction(announcement.category, hasImage, kind.name.lowercase(), ref) + onAction(route) + } LaunchedEffect(announcement.timestamp) { - AnalyticsHelper.trackInAppAnnouncementViewed(announcement.category, !announcement.imageUrl.isNullOrBlank(), announcement.showActionInApp && !announcement.route.isNullOrBlank()) + AnalyticsHelper.trackInAppAnnouncementViewed(announcement.category, hasImage, hasAction, ref) } MaterialTheme(colorScheme = colors) { Dialog(onDismissRequest = dismiss, properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false)) { @@ -95,7 +105,7 @@ fun InAppAnnouncementDialog(announcement: Announcement, onDismiss: () -> Unit, o Text(announcement.title, style = if (fullscreen) MaterialTheme.typography.headlineMedium else MaterialTheme.typography.headlineSmall) ReleaseNotesMarkdown(announcement.body, stripPrLinks = false) } - AnnouncementFooter(actions, dismiss, onAction) + AnnouncementFooter(actions, dismiss, act) } } } diff --git a/app/src/main/java/cx/aswin/boxlore/ui/libraryimport/ImportNotificationPermissionCard.kt b/app/src/main/java/cx/aswin/boxlore/ui/libraryimport/ImportNotificationPermissionCard.kt index 043dc0511..5801129d9 100644 --- a/app/src/main/java/cx/aswin/boxlore/ui/libraryimport/ImportNotificationPermissionCard.kt +++ b/app/src/main/java/cx/aswin/boxlore/ui/libraryimport/ImportNotificationPermissionCard.kt @@ -94,7 +94,7 @@ internal fun ImportNotificationPermissionCard( onResult = { granted -> promptAttempted = true cx.aswin.boxlore.core.prefs.BoxcastPrefs(context).setHasRequestedNotificationPermission(true) - AnalyticsHelper.trackNotificationPermissionDecided(granted) + AnalyticsHelper.trackNotificationPermissionDecided(granted, IMPORT_SURFACE) onPermissionChanged(areAppNotificationsEnabled(context)) }, ) @@ -127,6 +127,7 @@ internal fun ImportNotificationPermissionCard( Button( onClick = { if (canRequestRuntimePermission) { + AnalyticsHelper.trackNotificationPermissionRequested(IMPORT_SURFACE) launcher.launch(Manifest.permission.POST_NOTIFICATIONS) } else { onOpenSettings() @@ -142,3 +143,6 @@ internal fun ImportNotificationPermissionCard( } } } + +/** `surface` on the permission events: the import card. */ +private const val IMPORT_SURFACE = "library_import" diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml index 7a0f9d9bd..66853ba67 100644 --- a/app/src/main/res/xml/backup_rules.xml +++ b/app/src/main/res/xml/backup_rules.xml @@ -20,6 +20,8 @@ + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index a5d234e8d..8af2b5be8 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -15,6 +15,8 @@ + + @@ -35,6 +37,8 @@ + + diff --git a/core/analytics/README.md b/core/analytics/README.md index 590d434a0..9a90a950b 100644 --- a/core/analytics/README.md +++ b/core/analytics/README.md @@ -57,6 +57,8 @@ src/main/java/cx/aswin/boxlore/core/analytics/ SupportAnalyticsTracks.kt # Support events + support-world flag read FeedAnalyticsTracks.kt # feed_fetch_result / feed_url_corrected ErrorReporter.kt + EntryPointContext.kt # the entry details allowed onto playback events + AnnouncementRef.kt # which in-app alert an announcement event is about PendingEntryPoint.kt RecordingAnalytics.kt SessionAggregator.kt @@ -86,7 +88,12 @@ src/main/java/cx/aswin/boxlore/core/analytics/ - Opens / backgrounds / install **counts** come from PostHog SDK autocapture (`Application Opened`, `Application Backgrounded`, `Application Installed`) with `captureApplicationLifecycleEvents = true`. - Do **not** emit glossary `app_open`, `app_background`, or volume `install_attributed`. -- Install channel: `$set_once` person `install_channel` only (`trackInstallChannelAttributed`). +- Install channel: set-once person `install_channel` only (`trackInstallChannelAttributed`). Set-once properties go through `AnalyticsEmit.personSetOnce` (the SDK's set-once slot); nested as `$set_once` under `personSet` they landed as one person property named `$set_once`, which hid `install_channel` from every person before this fix. +- Setup route: `onboarding_method` and `user_intent` follow the latest route (`$set`); `first_onboarding_method`, `first_user_intent` and `initial_podcasts_subscribed` are set once, so a later import from Home doesn't overwrite how the listener first set up. +- Feedback: `feedback_submitted` is only the NPS survey's trigger (`nps_eligible`, `nps_manual`), because the survey shows on any of them. A Home review prompt is `engagement_prompt_shown`, a hand-off to Play `store_review_opened` (`in_app_review` or `store_page`), and an accepted feedback form `feedback_sent` (categories only, never the text or email). +- Explore: the For You rail being shown is `explore_surface_impression`; `explore_recommendation_tapped` is taps only. Home's category chips are `home_surface_tapped` (`category_filter`). +- Playback events take only `EntryPointContext.ALLOWED_KEYS` from a play's context, and none replaces a field the event set; media item extras (artwork links, Android Auto keys, the stream address) never reach analytics. +- Echo's chat sends what the listener typed capped at 500 characters, and never the reply text. - Deep links: custom `deep_link_opened` (`captureDeepLinks = false`). - Weekly Pulse KPIs stay on **Application Opened** / **Application Installed**. - Coverage inventory: [`docs/analytics/glossary_emission_coverage.csv`](../../docs/analytics/glossary_emission_coverage.csv) (`emission` | `sdk_backed` | `person_props_only`). @@ -100,6 +107,7 @@ src/main/java/cx/aswin/boxlore/core/analytics/ - `ScreenViewsTest` covers the screen trail, `screen_viewed`, every leaving summary going out as `screen_left`, and the screens added to plays. `PlayerSessionAggregatorTest` covers the transcript counter (regression: it read a name the player never logged). - `ListenAnalyticsTest` covers redacted error messages and error-tracking copies (a nested playlist error with a signed URL), the listen record's fields, the qualified threshold, the car as a client surface, context not overwriting the record, `playback_interrupted`, the listen fields on `playback_started`, and errors reaching error tracking. - `AnalyticsPolicyTest` covers the default levels, sampling by bucket, the switch payload (base, per version, bad input), the remembered switch and its version check, bucket persistence through `Store` (a saved bucket is reused; a missing or out-of-range one is picked and saved), `AnalyticsEmit` dropping and tagging, and `AnalyticsBeforeSend` tagging (replacing another build's tags), dropping SDK events and keeping a queued event's sample share, the identity events that ignore the switch, and an explicit flag reload applying the switch; a source scan keeps every `PostHog.reloadFeatureFlags` call inside `AnalyticsHelper`. `AnalyticsBudgetTest` checks that every glossary event has a level matching `DEFAULT_LEVELS` and a monthly estimate, and that estimated monthly events per active install stay within the budget (225; about 212 with the listening record and page views; `screen_viewed`'s 20 a month is an estimate to re-measure). The budget counts glossary events only, not SDK identity, flag, survey or exception events. +- `PersonSetOnceTest` covers the install channel and the first setup route going through the set-once slot. `AnalyticsHelperStateTest` checks review prompts never send the survey trigger; `RecordingAnalyticsTest` covers the entry-detail allowlist, the capped Echo text and the queue and Library entry points. - `GlossaryAllEventsEmissionTest` covers every `emission:` inventory row; `LifecycleSdkMappingTest` / `InstallChannelAttributionTest` assert no dual open/install emits. - Architecture guards in `:core:testing` enforce CSV ↔ allowlist ↔ inventory parity. diff --git a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/Analytics.kt b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/Analytics.kt index 646d54c9b..6fa865af1 100644 --- a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/Analytics.kt +++ b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/Analytics.kt @@ -23,14 +23,14 @@ interface Analytics { fun trackAdaptiveRankingStatus(statuses: List) - fun trackEngagementPromptShown(promptType: String, source: String, completedEpisodes: Int? = null,) + /** An engagement prompt (a review prompt) appeared: `engagement_prompt_shown`. */ + fun trackEngagementPromptShown(promptType: String, source: String, completedEpisodes: Int? = null, npsScore: Int? = null,) + /** The NPS survey's trigger: `feedback_submitted`, which the survey's display condition keys off. */ fun trackSurveyNpsEligible(completedEpisodes: Int?, triggerContext: String,) fun trackSurveyNpsManualTrigger(source: String) - fun trackPromoterReviewHandoff(npsScore: Int?) - /** The first play on this install, with the episode playing when known. */ fun trackFirstEpisodePlayed(episodeId: String? = null, podcastId: String? = null,) diff --git a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/AnalyticsEmit.kt b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/AnalyticsEmit.kt index aaaea9f00..272a041b7 100644 --- a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/AnalyticsEmit.kt +++ b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/AnalyticsEmit.kt @@ -25,6 +25,17 @@ internal object AnalyticsEmit { } } + /** + * Person properties PostHog keeps from the first time they arrive. They go in the SDK's own + * set-once slot: nested under [personSetSink] they'd land as one property named `$set_once`. + */ + @Volatile + var personSetOnceSink: (Map) -> Unit = { userProperties -> + PostHogStartup.runWhenReady { + PostHog.capture(event = AnalyticsGlossary.PERSON_SET_EVENT, userPropertiesSetOnce = userProperties) + } + } + /** PostHog error tracking (`$exception`); receives [AnalyticsRedaction] copies only. */ @Volatile var exceptionSink: (Throwable, Map) -> Unit = { error, properties -> @@ -42,15 +53,18 @@ internal object AnalyticsEmit { fun installRecordingSink(recorder: MutableList>>, applyLevels: Boolean = false,): () -> Unit { val previousEvent = eventSink val previousPerson = personSetSink + val previousPersonOnce = personSetOnceSink val previousException = exceptionSink val previousLevels = this.applyLevels eventSink = { event, props -> recorder.add(event to props) } personSetSink = { props -> recorder.add(AnalyticsGlossary.PERSON_SET_EVENT to props) } + personSetOnceSink = { props -> recorder.add(AnalyticsGlossary.PERSON_SET_EVENT to mapOf(SET_ONCE_KEY to props)) } exceptionSink = { _, props -> recorder.add(EXCEPTION_EVENT to props) } this.applyLevels = applyLevels return { eventSink = previousEvent personSetSink = previousPerson + personSetOnceSink = previousPersonOnce exceptionSink = previousException this.applyLevels = previousLevels } @@ -69,6 +83,11 @@ internal object AnalyticsEmit { personSetSink(properties) } + /** Sets person properties only where the person doesn't have them yet. */ + fun personSetOnce(properties: Map) { + if (properties.isNotEmpty()) personSetOnceSink(properties) + } + /** Sends a redacted copy of [error] to error tracking: no links or signed tokens in any message. */ fun exception(error: Throwable, properties: Map = emptyMap(),) { exceptionSink(AnalyticsRedaction.throwable(error), properties) @@ -76,4 +95,7 @@ internal object AnalyticsEmit { /** How a recording sink names error-tracking captures. */ const val EXCEPTION_EVENT = "\$exception" + + /** How a recording sink shows [personSetOnce] properties. */ + const val SET_ONCE_KEY = "\$set_once" } diff --git a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/AnalyticsGlossary.kt b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/AnalyticsGlossary.kt index 8d3b99e62..e90f4c0c4 100644 --- a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/AnalyticsGlossary.kt +++ b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/AnalyticsGlossary.kt @@ -64,6 +64,7 @@ object AnalyticsGlossary { "learn_screen_viewed", "explore_screen_viewed", "explore_recommendation_tapped", + "explore_surface_impression", "queue_modified", "episode_liked_toggled", "episode_mark_played", @@ -75,6 +76,9 @@ object AnalyticsGlossary { "share_content", "backup_restore_result", "feedback_submitted", + "engagement_prompt_shown", + "store_review_opened", + "feedback_sent", "library_destination_viewed", "podcast_detail_viewed", "episode_detail_viewed", @@ -126,7 +130,7 @@ object AnalyticsGlossary { */ const val RECIPE_ID_PROPERTY = "recipe_id" - /** Recipe IDs of the episodes on an episode surface, on `home_surface_impression` (P4). */ + /** Recipe IDs of the episodes on an episode surface, on `home_surface_impression` and `explore_surface_impression` (P4). */ const val RECIPE_IDS_PROPERTY = "recipe_ids" /** @@ -258,6 +262,10 @@ object AnalyticsGlossary { "podcast_detail", "episode_detail", "queue", + // The next episode started by itself, or the listener skipped to it, without a new entry point. + "queue_auto_advance", + "queue_skip", + "library_latest_episodes", "downloads", "history", "liked", diff --git a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/AnalyticsHelper.kt b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/AnalyticsHelper.kt index ae50ad455..b04b3111c 100644 --- a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/AnalyticsHelper.kt +++ b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/AnalyticsHelper.kt @@ -245,9 +245,10 @@ object AnalyticsHelper : Analytics { /** Records the [Episode.recipeId] of each of [episodes] that has one, for later events. Phase P4 PR 18. */ fun rememberEpisodeRecipeIds(episodes: Iterable) = EpisodeRecipeIds.remember(episodes.filter { it.recipeId != null }.associate { it.id to it.recipeId }) - // ── Feedback / NPS (glossary: feedback_submitted) ─────────────── - // PostHog survey display conditions should key off feedback_submitted - // after the PR7 dashboard rebuild (see glossary checklist). + // ── Feedback / NPS ────────────────────────────────────────────── + // feedback_submitted is only the NPS survey's trigger (its display condition keys off it); prompts + // that appear are engagement_prompt_shown, hand-offs to Play store_review_opened, and a sent + // feedback form feedback_sent. /** Deferred automatic trigger, fired on app open once the user hits the eligibility milestone. */ override fun trackSurveyNpsEligible(completedEpisodes: Int?, triggerContext: String,) { @@ -287,29 +288,35 @@ object AnalyticsHelper : Analytics { } } - /** Tracks when a proactive engagement modal (NPS, review, etc.) is shown. */ - override fun trackEngagementPromptShown(promptType: String, source: String, completedEpisodes: Int?,) { + /** + * A review prompt appeared (`milestone_review`, or `promoter_review` for an NPS promoter, with + * [npsScore]). Not `feedback_submitted`, which would also make the person eligible for the survey. + */ + override fun trackEngagementPromptShown(promptType: String, source: String, completedEpisodes: Int?, npsScore: Int?,) { AnalyticsEmit.event( - "feedback_submitted", + "engagement_prompt_shown", buildMap { - put("feedback_type", promptType) + put("prompt_type", promptType) put("source", source) completedEpisodes?.let { put("completed_episodes", it) } + npsScore?.let { put("nps_score", it) } }, ) } - /** Fired when a promoter (NPS 8+) is routed to the Play Store review sheet on a later open. */ - override fun trackPromoterReviewHandoff(npsScore: Int?) { + /** + * The listener was sent to Play to rate boxlore: [method] `in_app_review` (Play's review sheet was + * requested; Play doesn't say whether it appeared) or `store_page`, from [source]. + */ + fun trackStoreReviewOpened(method: String, source: String,) { + AnalyticsEmit.event("store_review_opened", mapOf("method" to method, "source" to source)) + } + + /** The in-app feedback form was sent and the server accepted it: categories only, never the text. */ + fun trackFeedbackSent(category: String, withEmail: Boolean, withDiagnostics: Boolean,) { AnalyticsEmit.event( - "feedback_submitted", - buildMap { - put("feedback_type", "promoter_review_handoff") - npsScore?.let { - put("score", it) - put("nps_score", it) - } - }, + "feedback_sent", + mapOf("category" to category, "with_email" to withEmail, "with_diagnostics" to withDiagnostics), ) } @@ -378,14 +385,12 @@ object AnalyticsHelper : Analytics { fun trackOnboardingAiResponseReceived( turnNumber: Int, - assistantMessage: String, optionsCount: Int, optionsList: List, durationSeconds: Float, detectedIntent: String? = null, ) = OnboardingAnalyticsTracks.trackOnboardingAiResponseReceived( turnNumber, - assistantMessage, optionsCount, optionsList, durationSeconds, @@ -474,15 +479,20 @@ object AnalyticsHelper : Analytics { fun trackFeatureAnnouncementDismissed(featureId: String) = DiscoveryAnalyticsTracks.trackFeatureAnnouncementDismissed(featureId) - fun trackInAppAnnouncementViewed(category: String, hasImage: Boolean, hasAction: Boolean,) = DiscoveryAnalyticsTracks.trackInAppAnnouncementViewed(category, hasImage, hasAction) + fun trackInAppAnnouncementViewed(category: String, hasImage: Boolean, hasAction: Boolean, announcement: AnnouncementRef? = null,) = + DiscoveryAnalyticsTracks.trackInAppAnnouncementViewed(category, hasImage, hasAction, announcement) - fun trackInAppAnnouncementDismissed(category: String, hasImage: Boolean, hasAction: Boolean,) = DiscoveryAnalyticsTracks.trackInAppAnnouncementDismissed(category, hasImage, hasAction) + fun trackInAppAnnouncementDismissed(category: String, hasImage: Boolean, hasAction: Boolean, announcement: AnnouncementRef? = null,) = + DiscoveryAnalyticsTracks.trackInAppAnnouncementDismissed(category, hasImage, hasAction, announcement) - fun trackInAppAnnouncementAction(category: String, hasImage: Boolean, actionLabel: String,) = DiscoveryAnalyticsTracks.trackInAppAnnouncementAction(category, hasImage, actionLabel) + fun trackInAppAnnouncementAction(category: String, hasImage: Boolean, actionLabel: String, announcement: AnnouncementRef? = null,) = + DiscoveryAnalyticsTracks.trackInAppAnnouncementAction(category, hasImage, actionLabel, announcement) - fun trackNotificationPermissionRequested() = DiscoveryAnalyticsTracks.trackNotificationPermissionRequested() + /** The system notification prompt was shown, from [surface] (`show_bell`, `library_import`). */ + fun trackNotificationPermissionRequested(surface: String? = null) = DiscoveryAnalyticsTracks.trackNotificationPermissionRequested(surface) - fun trackNotificationPermissionDecided(isGranted: Boolean) = DiscoveryAnalyticsTracks.trackNotificationPermissionDecided(isGranted) + fun trackNotificationPermissionDecided(isGranted: Boolean, surface: String? = null,) = + DiscoveryAnalyticsTracks.trackNotificationPermissionDecided(isGranted, surface) fun trackHomeHeroCarouselSwiped(maxCardIndexViewed: Int, totalCardsAvailable: Int,) = DiscoveryAnalyticsTracks.trackHomeHeroCarouselSwiped(maxCardIndexViewed, totalCardsAvailable) @@ -961,7 +971,8 @@ object AnalyticsHelper : Analytics { podcastId: String? = null, queueSize: Int? = null, source: String? = null, - ) = QueueContentAnalyticsTracks.trackQueueModified(action, episodeId, podcastId, queueSize, source) + itemsCount: Int? = null, + ) = QueueContentAnalyticsTracks.trackQueueModified(action, episodeId, podcastId, queueSize, source, itemsCount) fun trackNavTabClicked(tabName: String, previousTab: String? = null,) = QueueContentAnalyticsTracks.trackNavTabClicked(tabName, previousTab) diff --git a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/AnnouncementRef.kt b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/AnnouncementRef.kt new file mode 100644 index 000000000..5588e8d29 --- /dev/null +++ b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/AnnouncementRef.kt @@ -0,0 +1,7 @@ +package cx.aswin.boxlore.core.analytics + +/** + * Which in-app announcement an event is about: [id] (its send time), [title] (cut to 80 characters) + * and [presentation] (`compact` or `fullscreen`), so announcements can be told apart in PostHog. + */ +data class AnnouncementRef(val id: String, val title: String, val presentation: String,) diff --git a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/DiscoveryAnalyticsTracks.kt b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/DiscoveryAnalyticsTracks.kt index 39fb8767f..3da310a72 100644 --- a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/DiscoveryAnalyticsTracks.kt +++ b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/DiscoveryAnalyticsTracks.kt @@ -16,51 +16,50 @@ internal object DiscoveryAnalyticsTracks { ) } - fun trackInAppAnnouncementViewed(category: String, hasImage: Boolean, hasAction: Boolean,) { + fun trackInAppAnnouncementViewed(category: String, hasImage: Boolean, hasAction: Boolean, announcement: AnnouncementRef? = null,) { AnalyticsEmit.event( "feature_announcement_action", - mapOf( - "action" to "viewed", - "category" to category, - "has_image" to hasImage, - "has_action" to hasAction, - ), + announcementProps("viewed", category, hasImage, announcement) + ("has_action" to hasAction), ) } - fun trackInAppAnnouncementDismissed(category: String, hasImage: Boolean, hasAction: Boolean,) { + fun trackInAppAnnouncementDismissed(category: String, hasImage: Boolean, hasAction: Boolean, announcement: AnnouncementRef? = null,) { AnalyticsEmit.event( "feature_announcement_action", - mapOf( - "action" to "dismissed", - "category" to category, - "has_image" to hasImage, - "has_action" to hasAction, - ), + announcementProps("dismissed", category, hasImage, announcement) + ("has_action" to hasAction), ) } - fun trackInAppAnnouncementAction(category: String, hasImage: Boolean, actionLabel: String,) { + /** A button on an in-app announcement was tapped; [actionLabel] is its kind (`update`, `github`, `custom`). */ + fun trackInAppAnnouncementAction(category: String, hasImage: Boolean, actionLabel: String, announcement: AnnouncementRef? = null,) { AnalyticsEmit.event( "feature_announcement_action", - mapOf( - "action" to "acted", - "category" to category, - "has_image" to hasImage, - "feature_id" to actionLabel, - "action_label" to actionLabel, - ), + announcementProps("acted", category, hasImage, announcement) + mapOf("feature_id" to actionLabel, "action_label" to actionLabel), ) } - fun trackNotificationPermissionRequested() { - AnalyticsEmit.event("notification_permission_requested") + private fun announcementProps(action: String, category: String, hasImage: Boolean, announcement: AnnouncementRef?,): Map = + buildMap { + put("action", action) + put("category", category) + put("has_image", hasImage) + announcement?.let { + put("announcement_id", it.id) + put("announcement_title", it.title.take(ANNOUNCEMENT_TITLE_MAX)) + put("presentation", it.presentation) + } + } + + private const val ANNOUNCEMENT_TITLE_MAX = 80 + + fun trackNotificationPermissionRequested(surface: String? = null) { + AnalyticsEmit.event("notification_permission_requested", surface?.let { mapOf("surface" to it) }.orEmpty()) } - fun trackNotificationPermissionDecided(isGranted: Boolean) { + fun trackNotificationPermissionDecided(isGranted: Boolean, surface: String? = null,) { AnalyticsEmit.event( "notification_permission_decided", - mapOf("granted" to isGranted), + mapOf("granted" to isGranted) + surface?.let { mapOf("surface" to it) }.orEmpty(), ) AnalyticsEmit.personSet(mapOf("notifications_enabled" to isGranted)) } @@ -159,14 +158,13 @@ internal object DiscoveryAnalyticsTracks { AnalyticsEmit.event("home_surface_tapped", props) } + /** The For You rail was shown; taps are `explore_recommendation_tapped`, which this once shared. */ fun trackExploreRecommendationsImpression(recommendationsCount: Int, episodeIds: List,) { AnalyticsEmit.event( - "explore_recommendation_tapped", + "explore_surface_impression", mapOf( - "rail" to "for_you", - "interaction" to "impression", - "position_index" to -1, - "recommendations_count" to recommendationsCount, + "surface_component" to "for_you", + "items_count" to recommendationsCount, "episode_ids" to episodeIds, ), ) diff --git a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/EntryPointContext.kt b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/EntryPointContext.kt new file mode 100644 index 000000000..549477922 --- /dev/null +++ b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/EntryPointContext.kt @@ -0,0 +1,28 @@ +package cx.aswin.boxlore.core.analytics + +/** + * The entry-point details a play may carry onto playback events beside `entry_point`. Media items + * hold other extras too (artwork links, Android Auto's own keys, the stream address, show IDs); + * those never reach analytics, and a detail never replaces a field the event already set. + */ +object EntryPointContext { + val ALLOWED_KEYS: Set = + setOf( + "source_entry_point", + "screen", + "previous_screen", + "ep_layout_type", + "ep_carousel_position", + "ep_is_subscribed", + "curated_vibe_id", + "curated_carousel_position", + "recommendation_id", + ) + + fun isAllowed(key: String): Boolean = key in ALLOWED_KEYS + + /** Adds the allowed details of [context] that [props] doesn't already have. */ + internal fun addTo(props: MutableMap, context: Map?) { + context?.forEach { (key, value) -> if (isAllowed(key) && key !in props) props[key] = value } + } +} diff --git a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/EpisodeRecipeIds.kt b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/EpisodeRecipeIds.kt index 45bdd6d32..b910cfbe8 100644 --- a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/EpisodeRecipeIds.kt +++ b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/EpisodeRecipeIds.kt @@ -42,7 +42,7 @@ internal object EpisodeRecipeIds { private const val EPISODE_ID_PROPERTY = "episode_id" private const val EPISODE_IDS_PROPERTY = "episode_ids" - private const val HOME_SURFACE_IMPRESSION = "home_surface_impression" + private val SURFACE_IMPRESSIONS = setOf("home_surface_impression", "explore_surface_impression") private val lock = Any() private val known = lruMap(MAX_KNOWN) @@ -78,7 +78,7 @@ internal object EpisodeRecipeIds { val recipeId = (properties[EPISODE_ID_PROPERTY] as? String)?.let { recipeIdsFor(listOf(it)).firstOrNull() } if (recipeId == null) properties else properties + (AnalyticsGlossary.RECIPE_ID_PROPERTY to recipeId.toString()) } - event == HOME_SURFACE_IMPRESSION && AnalyticsGlossary.RECIPE_IDS_PROPERTY !in properties -> { + event in SURFACE_IMPRESSIONS && AnalyticsGlossary.RECIPE_IDS_PROPERTY !in properties -> { val ids = (properties[EPISODE_IDS_PROPERTY] as? List<*>)?.filterIsInstance().orEmpty() val recipeIds = recipeIdsFor(ids).distinct().map(Long::toString) if (recipeIds.isEmpty()) properties else properties + (AnalyticsGlossary.RECIPE_IDS_PROPERTY to recipeIds) diff --git a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/GrowthSessionAnalyticsTracks.kt b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/GrowthSessionAnalyticsTracks.kt index cca34414f..471c9c50a 100644 --- a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/GrowthSessionAnalyticsTracks.kt +++ b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/GrowthSessionAnalyticsTracks.kt @@ -15,17 +15,12 @@ internal object GrowthSessionAnalyticsTracks { utmSource: String? = null, shareToken: String? = null, ) { - AnalyticsEmit.personSet( + AnalyticsEmit.personSetOnce( buildMap { - put( - "\$set_once", - buildMap { - put("install_channel", installChannel) - referrerRaw?.takeIf { it.isNotBlank() }?.let { put("referrer_raw", it) } - utmSource?.takeIf { it.isNotBlank() }?.let { put("utm_source", it) } - shareToken?.takeIf { it.isNotBlank() }?.let { put("share_token", it) } - }, - ) + put("install_channel", installChannel) + referrerRaw?.takeIf { it.isNotBlank() }?.let { put("referrer_raw", it) } + utmSource?.takeIf { it.isNotBlank() }?.let { put("utm_source", it) } + shareToken?.takeIf { it.isNotBlank() }?.let { put("share_token", it) } }, ) } diff --git a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/OnboardingAnalyticsTracks.kt b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/OnboardingAnalyticsTracks.kt index 1d2cd6776..9b94978cd 100644 --- a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/OnboardingAnalyticsTracks.kt +++ b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/OnboardingAnalyticsTracks.kt @@ -68,17 +68,12 @@ internal object OnboardingAnalyticsTracks { "total_onboarding_time_seconds" to totalOnboardingTimeSeconds, ), ) - AnalyticsEmit.personSet( - mapOf( - "onboarding_status" to "completed", - "onboarding_method" to "skip", - "user_intent" to "casual_browser", - ), - ) + setOnboardingPerson(method = "skip", intent = "casual_browser") } fun trackOnboardingAiTurnSubmitted(turnNumber: Int, selectedOptions: Set, customInputText: String, timeSpentSeconds: Float,) { - val userInputText = customInputText + // Capped here as well as in the chat box, so no caller can send more. + val userInputText = customInputText.take(USER_INPUT_TEXT_MAX) AnalyticsEmit.event( "onboarding_ai_turn_submitted", mapOf( @@ -91,9 +86,9 @@ internal object OnboardingAnalyticsTracks { ) } + /** Not the reply's text, which can repeat what the listener typed; the Worker logs the readable steps. */ fun trackOnboardingAiResponseReceived( turnNumber: Int, - assistantMessage: String, optionsCount: Int, optionsList: List, durationSeconds: Float, @@ -103,7 +98,6 @@ internal object OnboardingAnalyticsTracks { "onboarding_ai_response_received", buildMap { put("turn_number", turnNumber) - put("assistant_message", assistantMessage.take(500)) put("options_count", optionsCount) put("options_list", optionsList) put("duration_seconds", durationSeconds) @@ -161,14 +155,11 @@ internal object OnboardingAnalyticsTracks { "favorite_genres" to favoriteGenres, ), ) - AnalyticsEmit.personSet( - mapOf( - "onboarding_status" to "completed", - "onboarding_method" to "ai_chat", - "user_intent" to "ai_guided_listener", - "initial_podcasts_subscribed" to totalSubscribedCount, - "favorite_genres" to favoriteGenres, - ), + setOnboardingPerson( + method = "ai_chat", + intent = "ai_guided_listener", + initialSubscribed = totalSubscribedCount, + extra = mapOf("favorite_genres" to favoriteGenres), ) } @@ -227,14 +218,7 @@ internal object OnboardingAnalyticsTracks { "total_onboarding_time_seconds" to totalOnboardingTimeSeconds, ), ) - AnalyticsEmit.personSet( - mapOf( - "onboarding_status" to "completed", - "onboarding_method" to "search", - "user_intent" to "targeted_listener", - "initial_podcasts_subscribed" to totalSubscribedCount, - ), - ) + setOnboardingPerson(method = "search", intent = "targeted_listener", initialSubscribed = totalSubscribedCount) } fun trackImportSheetOpened() { @@ -262,14 +246,7 @@ internal object OnboardingAnalyticsTracks { "entry_point" to entryPoint, ), ) - AnalyticsEmit.personSet( - mapOf( - "onboarding_status" to "completed", - "onboarding_method" to "import", - "user_intent" to "migrating_power_user", - "initial_podcasts_subscribed" to importedPodcastCount, - ), - ) + setOnboardingPerson(method = "import", intent = "migrating_power_user", initialSubscribed = importedPodcastCount) } fun trackOnboardingImportFailed(importType: String, errorMessage: String?,) { @@ -338,27 +315,41 @@ internal object OnboardingAnalyticsTracks { ), ) - val personaMap = GenrePersonaLogic.deriveGenrePersona(favoriteGenres) - val finalProps = - mutableMapOf( - "onboarding_status" to "completed", - "onboarding_method" to "manual_genre", - "user_intent" to "selective_curator", - "initial_podcasts_subscribed" to totalSubscribedCount, - "favorite_genres" to favoriteGenres.toList(), - ) - finalProps.putAll(personaMap) - AnalyticsEmit.personSet(finalProps) + setOnboardingPerson( + method = "manual_genre", + intent = "selective_curator", + initialSubscribed = totalSubscribedCount, + extra = mapOf("favorite_genres" to favoriteGenres.toList()) + GenrePersonaLogic.deriveGenrePersona(favoriteGenres), + ) } fun setOnboardingImportCompletedUserProperties(initialPodcastsSubscribed: Int) { + setOnboardingPerson(method = "import", intent = "migrating_power_user", initialSubscribed = initialPodcastsSubscribed) + } + + /** + * The setup route on the person. `onboarding_method` and `user_intent` follow the latest route, + * so an import from Home later replaces them; `first_onboarding_method`, `first_user_intent` and + * `initial_podcasts_subscribed` keep how the listener first set up. + */ + private fun setOnboardingPerson( + method: String, + intent: String, + initialSubscribed: Int? = null, + extra: Map = emptyMap(), + ) { AnalyticsEmit.personSet( - mapOf( - "onboarding_status" to "completed", - "onboarding_method" to "import", - "user_intent" to "migrating_power_user", - "initial_podcasts_subscribed" to initialPodcastsSubscribed, - ), + mapOf("onboarding_status" to "completed", "onboarding_method" to method, "user_intent" to intent) + extra, + ) + AnalyticsEmit.personSetOnce( + buildMap { + put("first_onboarding_method", method) + put("first_user_intent", intent) + initialSubscribed?.let { put("initial_podcasts_subscribed", it) } + }, ) } } + +/** The Echo chat box's own limit. */ +private const val USER_INPUT_TEXT_MAX = 500 diff --git a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/PlaybackAnalyticsTracks.kt b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/PlaybackAnalyticsTracks.kt index 004f5587f..6e070dede 100644 --- a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/PlaybackAnalyticsTracks.kt +++ b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/PlaybackAnalyticsTracks.kt @@ -40,7 +40,7 @@ internal object PlaybackAnalyticsTracks { podcastName?.let { props["podcast_name"] = it } podcastGenre?.let { props["podcast_genre"] = it } episodeTitle?.let { props["episode_title"] = it } - entryPointContext?.let { props.putAll(it) } + EntryPointContext.addTo(props, entryPointContext) listen?.let { props["listen_id"] = it.listenId props["previously_finished"] = isRepeating @@ -93,7 +93,7 @@ internal object PlaybackAnalyticsTracks { podcastName?.let { props["podcast_name"] = it } podcastGenre?.let { props["podcast_genre"] = it } episodeTitle?.let { props["episode_title"] = it } - entryPointContext?.let { props.putAll(it) } + EntryPointContext.addTo(props, entryPointContext) queueSize?.let { props["queue_size"] = it } AnalyticsEmit.event("playback_paused", props) @@ -130,7 +130,7 @@ internal object PlaybackAnalyticsTracks { episodeTitle?.let { props["episode_title"] = it } speed?.let { props["speed"] = it } isSubscribed?.let { props["is_subscribed"] = it } - entryPointContext?.let { props.putAll(it) } + EntryPointContext.addTo(props, entryPointContext) AnalyticsEmit.event("playback_completed", props) } diff --git a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/QueueContentAnalyticsTracks.kt b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/QueueContentAnalyticsTracks.kt index 3e6cff052..f0cf36efd 100644 --- a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/QueueContentAnalyticsTracks.kt +++ b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/QueueContentAnalyticsTracks.kt @@ -70,13 +70,14 @@ internal object QueueContentAnalyticsTracks { AnalyticsEmit.event("offline_mode_entered") } + /** A category chip on Home's discovery section; a Home tap, not an Explore one. */ fun trackDiscoverCategoryFiltered(categoryName: String) { AnalyticsEmit.event( - "explore_recommendation_tapped", + "home_surface_tapped", mapOf( - "rail" to "category", - "interaction" to "filter", - "category_name" to categoryName, + "surface_component" to "category_filter", + "content_id" to categoryName, + "click_target" to "filter", ), ) } @@ -212,6 +213,7 @@ internal object QueueContentAnalyticsTracks { podcastId: String? = null, queueSize: Int? = null, source: String? = null, + itemsCount: Int? = null, ) { AnalyticsEmit.event( "queue_modified", @@ -221,6 +223,7 @@ internal object QueueContentAnalyticsTracks { podcastId?.let { put("podcast_id", it) } queueSize?.let { put("queue_size", it) } source?.let { put("source", it) } + itemsCount?.let { put("items_count", it) } }, ) } diff --git a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/RecordingAnalytics.kt b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/RecordingAnalytics.kt index b6bd8ddf8..426334f78 100644 --- a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/RecordingAnalytics.kt +++ b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/RecordingAnalytics.kt @@ -64,13 +64,14 @@ class RecordingAnalytics : Analytics { ) } - override fun trackEngagementPromptShown(promptType: String, source: String, completedEpisodes: Int?,) { + override fun trackEngagementPromptShown(promptType: String, source: String, completedEpisodes: Int?, npsScore: Int?,) { capture( - "feedback_submitted", + "engagement_prompt_shown", buildMap { - put("feedback_type", promptType) + put("prompt_type", promptType) put("source", source) completedEpisodes?.let { put("completed_episodes", it) } + npsScore?.let { put("nps_score", it) } }, ) } @@ -96,16 +97,6 @@ class RecordingAnalytics : Analytics { ) } - override fun trackPromoterReviewHandoff(npsScore: Int?) { - capture( - "feedback_submitted", - buildMap { - put("feedback_type", "promoter_review_handoff") - npsScore?.let { put("score", it) } - }, - ) - } - override fun trackFirstEpisodePlayed(episodeId: String?, podcastId: String?) { capture( "first_episode_played", diff --git a/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/AnalyticsHelperStateTest.kt b/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/AnalyticsHelperStateTest.kt index 5fd548e79..1cefbcced 100644 --- a/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/AnalyticsHelperStateTest.kt +++ b/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/AnalyticsHelperStateTest.kt @@ -73,38 +73,33 @@ class AnalyticsHelperStateTest { @Test fun trackEngagementPromptShownIncludesCompletedEpisodesWhenPresent() { - AnalyticsHelper.trackEngagementPromptShown("nps", "home", 7) + AnalyticsHelper.trackEngagementPromptShown("milestone_review", "episode_milestone", 7) - val event = recorder.first { it.first == "feedback_submitted" } - assertEquals("nps", event.second["feedback_type"]) - assertEquals("home", event.second["source"]) + val event = recorder.first { it.first == "engagement_prompt_shown" } + assertEquals("milestone_review", event.second["prompt_type"]) + assertEquals("episode_milestone", event.second["source"]) assertEquals(7, event.second["completed_episodes"]) } @Test fun trackEngagementPromptShownOmitsCompletedWhenNull() { - AnalyticsHelper.trackEngagementPromptShown("nps", "home", null) + AnalyticsHelper.trackEngagementPromptShown("promoter_review", "nps_handoff", null, npsScore = 9) - val event = recorder.first { it.first == "feedback_submitted" } + val event = recorder.first { it.first == "engagement_prompt_shown" } assertFalse(event.second.containsKey("completed_episodes")) - } - - @Test - fun trackPromoterReviewHandoffIncludesScores() { - AnalyticsHelper.trackPromoterReviewHandoff(9) - - val event = recorder.first { it.first == "feedback_submitted" } - assertEquals("promoter_review_handoff", event.second["feedback_type"]) - assertEquals(9, event.second["score"]) assertEquals(9, event.second["nps_score"]) } @Test - fun trackPromoterReviewHandoffOmitsScoreWhenNull() { - AnalyticsHelper.trackPromoterReviewHandoff(null) - - val event = recorder.first { it.first == "feedback_submitted" } - assertFalse(event.second.containsKey("score")) + fun reviewPromptsNeverSendTheSurveyTrigger() { + // Regression: prompts sent feedback_submitted, which also made the person eligible for the NPS survey. + AnalyticsHelper.trackEngagementPromptShown("milestone_review", "episode_milestone", 5) + AnalyticsHelper.trackStoreReviewOpened(method = "in_app_review", source = "review_prompt") + AnalyticsHelper.trackFeedbackSent(category = "bug", withEmail = true, withDiagnostics = false) + + assertFalse(recorder.any { it.first == "feedback_submitted" }, "got ${recorder.map { it.first }}") + val sent = recorder.first { it.first == "feedback_sent" }.second + assertEquals(mapOf("category" to "bug", "with_email" to true, "with_diagnostics" to false), sent) } @Test diff --git a/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/AnalyticsTracksFacadeTest.kt b/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/AnalyticsTracksFacadeTest.kt index bd63aa41e..4e878a80b 100644 --- a/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/AnalyticsTracksFacadeTest.kt +++ b/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/AnalyticsTracksFacadeTest.kt @@ -65,7 +65,7 @@ class AnalyticsTracksFacadeTest { @Test fun onboardingAiFlowEvents() { AnalyticsHelper.trackOnboardingAiTurnSubmitted(1, setOf("news"), "custom", 3f) - AnalyticsHelper.trackOnboardingAiResponseReceived(1, "hello", 2, listOf("a", "b"), 1f, "intent") + AnalyticsHelper.trackOnboardingAiResponseReceived(1, 2, listOf("a", "b"), 1f, "intent") AnalyticsHelper.trackOnboardingAiSearchRedirect(2, "query") AnalyticsHelper.trackOnboardingAiSearchRedirect(2, null) AnalyticsHelper.trackOnboardingAiSynthesisCompleted(3, 4, 2f) @@ -141,6 +141,9 @@ class AnalyticsTracksFacadeTest { assertTrue("home_surface_impression" in names()) assertTrue("home_surface_tapped" in names()) assertTrue("explore_recommendation_tapped" in names()) + // Regression: the For You impression was sent as explore_recommendation_tapped. + assertEquals(2, recorder.count { it.first == "explore_recommendation_tapped" }) + assertEquals(listOf("e1"), firstProps("explore_surface_impression")["episode_ids"]) val spotlightImpression = recorder .first { @@ -400,7 +403,8 @@ class AnalyticsTracksFacadeTest { assertTrue("queue_modified" in names()) assertTrue("offline_mode_entered" in names()) - assertTrue("explore_recommendation_tapped" in names()) + assertTrue("explore_recommendation_tapped" !in names(), "a category filter is a Home tap") + assertEquals("category_filter", firstProps("home_surface_tapped")["surface_component"]) assertTrue(firstProps("queue_modified").containsKey("source_count_server")) } diff --git a/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/GlossaryAllEventsEmissionTest.kt b/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/GlossaryAllEventsEmissionTest.kt index ce1ce4eef..40ab7a592 100644 --- a/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/GlossaryAllEventsEmissionTest.kt +++ b/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/GlossaryAllEventsEmissionTest.kt @@ -101,7 +101,7 @@ class GlossaryAllEventsEmissionTest { AnalyticsHelper.trackOnboardingAiTurnSubmitted(1, setOf("news"), "hi", 1f) }, "onboarding_ai_response_received" to { - AnalyticsHelper.trackOnboardingAiResponseReceived(1, "hello", 2, listOf("a"), 1f, "intent") + AnalyticsHelper.trackOnboardingAiResponseReceived(1, 2, listOf("a"), 1f, "intent") }, "onboarding_ai_search_redirect" to { AnalyticsHelper.trackOnboardingAiSearchRedirect(1, "query") @@ -238,6 +238,9 @@ class GlossaryAllEventsEmissionTest { "explore_recommendation_tapped" to { AnalyticsHelper.trackExploreRecommendationCardTapped("e1", "T", "p1", "Pod", 0) }, + "explore_surface_impression" to { + AnalyticsHelper.trackExploreRecommendationsImpression(2, listOf("e1", "e2")) + }, "queue_modified" to { AnalyticsHelper.trackQueueModified("add", "e1", "p1", queueSize = 2, source = "player") }, @@ -269,7 +272,16 @@ class GlossaryAllEventsEmissionTest { AnalyticsHelper.trackBackupRestoreResult("export", success = true, itemCount = 3, format = "opml") }, "feedback_submitted" to { - AnalyticsHelper.trackEngagementPromptShown("nps", "home", 5) + AnalyticsHelper.trackSurveyNpsManualTrigger(source = "long_press") + }, + "engagement_prompt_shown" to { + AnalyticsHelper.trackEngagementPromptShown("milestone_review", "episode_milestone", 5) + }, + "store_review_opened" to { + AnalyticsHelper.trackStoreReviewOpened(method = "store_page", source = "post_review") + }, + "feedback_sent" to { + AnalyticsHelper.trackFeedbackSent(category = "bug", withEmail = false, withDiagnostics = true) }, "library_destination_viewed" to { AnalyticsHelper.trackLibraryHubViewed("nav") diff --git a/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/PersonSetOnceTest.kt b/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/PersonSetOnceTest.kt new file mode 100644 index 000000000..85caee080 --- /dev/null +++ b/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/PersonSetOnceTest.kt @@ -0,0 +1,60 @@ +package cx.aswin.boxlore.core.analytics + +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +/** Set-once person properties go through the SDK's set-once slot, never nested under `$set`. */ +class PersonSetOnceTest { + private val set = mutableListOf>() + private val setOnce = mutableListOf>() + private lateinit var previousSet: (Map) -> Unit + private lateinit var previousSetOnce: (Map) -> Unit + + @BeforeEach + fun setUp() { + previousSet = AnalyticsEmit.personSetSink + previousSetOnce = AnalyticsEmit.personSetOnceSink + AnalyticsEmit.personSetSink = { set += it } + AnalyticsEmit.personSetOnceSink = { setOnce += it } + } + + @AfterEach + fun tearDown() { + AnalyticsEmit.personSetSink = previousSet + AnalyticsEmit.personSetOnceSink = previousSetOnce + } + + @Test + fun `install channel is set once as its own person properties`() { + // Regression: it was sent as {"$set_once": {...}} under $set, so PostHog stored one property + // literally named "$set_once" and install_channel never existed on the person. + AnalyticsHelper.trackInstallChannelAttributed(installChannel = "share", referrerRaw = "type=podcast&id=1") + + assertTrue(set.isEmpty(), "got \$set $set") + assertEquals(listOf(mapOf("install_channel" to "share", "referrer_raw" to "type=podcast&id=1")), setOnce) + } + + @Test + fun `a later import keeps the first setup route`() { + AnalyticsHelper.trackOnboardingSearchDone( + entryPoint = "welcome_screen", + totalSubscribedCount = 3, + subscribedPodcastsList = listOf("p1", "p2", "p3"), + searchesPerformed = 1, + timeSpentOnSearchSeconds = 4f, + totalOnboardingTimeSeconds = 9f, + ) + AnalyticsHelper.setOnboardingImportCompletedUserProperties(initialPodcastsSubscribed = 40) + + assertEquals("import", set.last()["onboarding_method"]) + assertEquals( + mapOf("first_onboarding_method" to "search", "first_user_intent" to "targeted_listener", "initial_podcasts_subscribed" to 3), + setOnce.first(), + ) + set.forEach { assertFalse("initial_podcasts_subscribed" in it, "initial count belongs to set-once: $it") } + } +} diff --git a/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/RecordingAnalyticsExtraTest.kt b/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/RecordingAnalyticsExtraTest.kt index a9b1c9850..73f32f9c4 100644 --- a/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/RecordingAnalyticsExtraTest.kt +++ b/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/RecordingAnalyticsExtraTest.kt @@ -47,19 +47,19 @@ class RecordingAnalyticsExtraTest { } @Test - fun trackPromoterReviewHandoffIncludesScoreWhenPresent() { - analytics.trackPromoterReviewHandoff(npsScore = 10) + fun trackEngagementPromptShownCarriesNpsScoreWhenPresent() { + analytics.trackEngagementPromptShown("promoter_review", "nps_handoff", npsScore = 10) val event = analytics.lastEvent!! - assertEquals("promoter_review_handoff", event.properties["feedback_type"]) - assertEquals(10, event.properties["score"]) + assertEquals("engagement_prompt_shown", event.name) + assertEquals(10, event.properties["nps_score"]) } @Test - fun trackPromoterReviewHandoffOmitsScoreWhenNull() { - analytics.trackPromoterReviewHandoff(npsScore = null) + fun trackEngagementPromptShownOmitsNpsScoreWhenNull() { + analytics.trackEngagementPromptShown("milestone_review", "episode_milestone", completedEpisodes = 5) - assertFalse("score" in analytics.lastEvent!!.properties) + assertFalse("nps_score" in analytics.lastEvent!!.properties) } @Test diff --git a/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/RecordingAnalyticsTest.kt b/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/RecordingAnalyticsTest.kt index eb6a8b744..994b37155 100644 --- a/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/RecordingAnalyticsTest.kt +++ b/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/RecordingAnalyticsTest.kt @@ -101,12 +101,12 @@ class RecordingAnalyticsTest { } @Test - fun `trackEngagementPromptShown emits feedback_submitted`() { - analytics.trackEngagementPromptShown("nps", "home_screen", 5) + fun `trackEngagementPromptShown emits engagement_prompt_shown`() { + analytics.trackEngagementPromptShown("milestone_review", "home_screen", 5) val event = analytics.lastEvent!! - assertEquals("feedback_submitted", event.name) - assertEquals("nps", event.properties["feedback_type"]) + assertEquals("engagement_prompt_shown", event.name) + assertEquals("milestone_review", event.properties["prompt_type"]) assertEquals("home_screen", event.properties["source"]) assertEquals(5, event.properties["completed_episodes"]) } @@ -168,7 +168,7 @@ class AnalyticsGlossaryAllowlistTest { @Test fun `phase A union B has expected cardinality`() { // 40 Phase A (with onboarding_similar_choice, the two listen events and the two screen events) + 1 A/B + 29 Phase B + 5 Support + 2 custom-theme + 1 hero-style + 1 LeaveMeAlone events. - assertEquals(79, AnalyticsGlossary.PHASE_A_UNION_B.size) + assertEquals(83, AnalyticsGlossary.PHASE_A_UNION_B.size) } @Test @@ -265,7 +265,7 @@ class AnalyticsGlossaryAllowlistTest { isSubscribed = true, entryPoint = "home_hero_resume_grid", ) - AnalyticsHelper.trackEngagementPromptShown("nps", "home", 3) + AnalyticsHelper.trackEngagementPromptShown("milestone_review", "home", 3) AnalyticsHelper.trackAdaptiveRankingStatus( listOf(RankingAggregateTelemetry("DISCOVERY", 1, "adaptive", "50_199", true)), ) @@ -290,7 +290,7 @@ class AnalyticsGlossaryAllowlistTest { assertTrue(eventNames.contains("onboarding_search_performed")) assertTrue(eventNames.contains("onboarding_ai_turn_submitted")) assertTrue(eventNames.contains("playback_started")) - assertTrue(eventNames.contains("feedback_submitted")) + assertTrue(eventNames.contains("engagement_prompt_shown")) assertTrue(eventNames.contains("adaptive_ranking_status")) assertTrue(eventNames.contains("late_night_safeguard_decision")) assertTrue(eventNames.contains("proxy_fallback_triggered")) @@ -315,6 +315,10 @@ class AnalyticsRawTextAndEntryPointTest { AnalyticsGlossary.normalizeEntryPoint(AnalyticsGlossary.VIDEO_SPOTLIGHT_ENTRY_POINT), ) assertEquals("android_auto_discover", AnalyticsGlossary.normalizeEntryPoint("android_auto_drive_picks")) + // Regression: these were sent as "unknown". + assertEquals("queue_auto_advance", AnalyticsGlossary.normalizeEntryPoint("queue_auto_advance")) + assertEquals("queue_skip", AnalyticsGlossary.normalizeEntryPoint("queue_skip")) + assertEquals("library_latest_episodes", AnalyticsGlossary.normalizeEntryPoint("library_latest_episodes")) } @Test @@ -338,6 +342,21 @@ class AnalyticsRawTextAndEntryPointTest { assertFalse(event.second.containsKey("custom_input_text")) } + @Test + fun `onboarding AI text is capped and replies never carry their text`() { + val captured = mutableListOf>>() + val restore = AnalyticsEmit.installRecordingSink(captured) + try { + AnalyticsHelper.trackOnboardingAiTurnSubmitted(1, emptySet(), "a".repeat(800), 2f) + AnalyticsHelper.trackOnboardingAiResponseReceived(1, optionsCount = 2, optionsList = listOf("x", "y"), durationSeconds = 1f) + } finally { + restore() + } + + assertEquals(500, (captured.first { it.first == "onboarding_ai_turn_submitted" }.second["user_input_text"] as String).length) + assertFalse("assistant_message" in captured.first { it.first == "onboarding_ai_response_received" }.second) + } + @Test fun `onboarding AI turn still sends user_input_text when empty`() { val captured = mutableListOf>>() @@ -384,6 +403,47 @@ class AnalyticsRawTextAndEntryPointTest { assertEquals("history wars", onboardingLegacy.second["search_query"]) } + @Test + fun `playback events keep only entry details and never let them replace a field`() { + // Regression: media item extras (artwork links, Android Auto keys) were copied onto playback + // events, and a context key could overwrite podcast_id or entry_point. + val captured = mutableListOf>>() + val restore = AnalyticsEmit.installRecordingSink(captured) + try { + AnalyticsHelper.trackPlaybackStarted( + podcastId = "p", + podcastName = null, + podcastGenre = null, + episodeId = "e", + episodeTitle = null, + startPositionSeconds = 0f, + totalDurationSeconds = 60f, + isRepeating = false, + isSubscribed = false, + entryPoint = "queue", + entryPointContext = + mapOf( + "source_entry_point" to "home_new_episodes", + "screen" to "home", + "podcast_id" to "other", + "entry_point" to "search", + "boxlore_artwork_sources" to listOf("https://cdn.example.com/cover.jpg"), + "cx.aswin.boxlore.auto.SOURCE" to "discover", + ), + ) + } finally { + restore() + } + + val props = captured.first { it.first == "playback_started" }.second + assertEquals("p", props["podcast_id"]) + assertEquals("queue", props["entry_point"]) + assertEquals("home_new_episodes", props["source_entry_point"]) + assertEquals("home", props["screen"]) + assertFalse("boxlore_artwork_sources" in props) + assertFalse("cx.aswin.boxlore.auto.SOURCE" in props) + } + @Test fun `playback_started always includes normalized entry_point`() { val captured = mutableListOf>>() diff --git a/core/catalog/README.md b/core/catalog/README.md index 18a4345c8..7783a8ee5 100644 --- a/core/catalog/README.md +++ b/core/catalog/README.md @@ -174,6 +174,7 @@ Main Kotlin files should remain below 1000 lines; extracted helpers keep reposit ## Testing notes +- Install referrer: Play's `utm_source=(not set)` placeholder is channel `unknown`, not `utm` (`InstallReferrerManagerTest`). `EngagementPromptCoordinator.shouldShowPromoterReview` waits out the review prompt's quiet days after installing or importing. - Saved feed addresses: `FeedAddressUpdaterTest` (Robolectric Room: one update writes both addresses, keeps the old one, resets every validator, marks the row dirty, re-registers and reports once; undo; `rss:` and non-HTTPS refused; a changed row left alone; fills, cloud addresses and unfollowed shows aren't corrections and an unfollowed row stays clean; a failed registration request of any kind keeps the address; a registration stays pending until acknowledged and is asked for again, also after the process died; no address written when its request couldn't be saved; a redirect from an address other than the saved one is never adopted; a re-follow keeps a corrected address), `FeedAddressDailyCheckTest` (501 followed shows make two calls, nothing again the same day; only confirmed moves, read before they are saved; an unreadable registry address never saved and tried again the next day; a swapped show stamped by its swap; never back to the address the saved one replaced, whatever replaced it; follows changed on the phone wait; a failed call or an unavailable registry stamps nothing, so the `/podcast` fallback runs once, and never for a show the check reached; the switch), `FeedAddressRecordedAnswersTest` (the API's own `/v2/shows/feeds` and `/v2/feed-report` answers, `fixtures/v2`, through the app's client: a registry move, a dump address, an unknown show, an http-only show, an unavailable registry and a refused report), `FeedReportsTest` and `SharedPreferencesFeedReportStampsTest` (once per show a day, 20 a day in all, limiters sharing one store counting each other's reports, a failed or refused send counting nothing, kept across processes, never offline, for `rss:` shows, without a read or with the switch off; only a readable new address offered back), `PodcastNotificationRegistrarTest` (the same node and topic, the shared node never written without its address, nothing without notifications, retry and refused outcomes, a cloud pull that changes only the address re-registering on the same node and topic, a pulled previous address never undoing a registry correction, and a merge never writing an older copy's address over a newer one), `SubscribedEpisodeCatalogTest` (moves followed on every refresh path, never for `rss:` shows or with the switch off, caller-handled requests left alone), and `DirectFeedRecoveryTest` (every failed read reported, the once-a-day `/podcast` re-check sharing the daily stamp, the report's address tried before the `/podcast` re-check, a passing failure keeping its address, a candidate replacing only the address that failed). - `PodcastRepositoryPlayerPrefsTest` checks the player preferences (and their legacy-file migration) open on first use, not while the repository is constructed on the main thread at startup. - Unit tests live under `core/catalog/src/test`. diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/EngagementPromptCoordinator.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/EngagementPromptCoordinator.kt index 9d445b302..fa190870a 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/EngagementPromptCoordinator.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/EngagementPromptCoordinator.kt @@ -73,11 +73,13 @@ class EngagementPromptCoordinator( /** * True when a deferred promoter handoff is pending and the user has not reviewed yet. - * Does not apply the 14-day cooldown — that prompt completes the NPS journey. + * Does not apply the 14-day cooldown — that prompt completes the NPS journey — but waits out + * the quiet days after installing ([installedAtMs]) or importing a library. */ - suspend fun shouldShowPromoterReview(isPlaying: Boolean): Boolean { + suspend fun shouldShowPromoterReview(isPlaying: Boolean, installedAtMs: Long,): Boolean { if (!canShowProactivePrompt(isPlaying)) return false if (userPrefs.hasReviewedSync()) return false + if (userPrefs.isInReviewQuietPeriod(installedAtMs)) return false if (!userPrefs.hasNpsSurveyFired()) return false return userPrefs.isPromoterReviewPending() } diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/InstallReferrerManager.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/InstallReferrerManager.kt index 8a17c88c4..409a49ad0 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/InstallReferrerManager.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/InstallReferrerManager.kt @@ -177,6 +177,8 @@ class InstallReferrerManager(private val context: Context,) { decoded.contains("type=episode") || decoded.startsWith("podcast_") || decoded.startsWith("episode_") -> "share" + // Play's placeholder when the install came with no referrer: not a campaign. + decoded.contains("utm_source=(not set)") -> "unknown" decoded.contains("utm_source=") -> "utm" else -> "unknown" } diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/InstallReferrerManagerTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/InstallReferrerManagerTest.kt index 3300d0e5e..80a539266 100644 --- a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/InstallReferrerManagerTest.kt +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/InstallReferrerManagerTest.kt @@ -114,6 +114,16 @@ class InstallReferrerManagerTest { assertEquals("unknown", InstallReferrerManager.deriveInstallChannel(" ")) } + @Test + fun deriveInstallChannel_playPlaceholderIsUnknownNotUtm() { + // Regression: Play's "(not set)" placeholder was counted as a utm campaign. + assertEquals( + "unknown", + InstallReferrerManager.deriveInstallChannel("utm_source=(not%20set)&utm_medium=(not%20set)"), + ) + assertEquals("utm", InstallReferrerManager.deriveInstallChannel("utm_source=newsletter&utm_medium=email")) + } + @Test fun deriveInstallChannel_googlePlayIsOrganicBeforeGenericUtm() { assertEquals("organic", InstallReferrerManager.deriveInstallChannel("utm_source=google-play")) diff --git a/core/playback/README.md b/core/playback/README.md index fd3c6b193..ef57dfc6e 100644 --- a/core/playback/README.md +++ b/core/playback/README.md @@ -156,6 +156,7 @@ Files under `core/data/service` are compatibility stubs for old service class na - Media ID prefixes such as `episode:`, `queue:`, and `learn:` are session and Android Auto contracts. - SharedPreferences file `boxcast_player` stores playback session flags. - SharedPreferences file `boxlore_listen_note` holds the note of the listen in progress (`PrefsListenNoteStore`); it is empty between listens and left out of backups and device transfers, so a restore never reports a listen again. +- SharedPreferences file `boxlore_install_milestones` holds `first_play_claimed` (`FirstPlayClaim`, `PrefsFirstPlayStore`): `first_episode_played` is claimed once per install when a listen starts, whatever started it, with the episode and show. The file is left out of backups and device transfers, so a restored or new phone counts its own first play; an install updated from an older build that already sent it from the screen stays quiet (`FirstPlayClaimTest`). - SharedPreferences file `android_auto_artwork_sources` maps Android Auto artwork content keys to remote HTTPS URLs or sandboxed local paths (identity for Auto collage ContentProvider; do not rename lightly). - Preference key `device_uuid` is a stable install identifier and must not be logged raw. - Queue, history, and download rows are persisted by `:core:database` and `:core:downloads`. @@ -165,6 +166,8 @@ Media-session bitmap loading classifies transient transport failures separately ## Testing notes +- Analytics labels: `labelCarPlay` (`service/auto/CarPlayLabel.kt`) names a play `android_auto_*` only when Android Auto's controller asked for it. The app's own plays reach `onAddMediaItems` too (Media3 sends `setMediaItems` through it), and labelling them replaced the app's entry point, so phone plays were counted as car plays (`CarPlayLabelTest`). `QueueRepository.clearQueue` reports `queue_modified` "clear" only with a `listenerSource` (the Lore conflict dialog); starting a new episode or mix replaces the queue silently (`QueueRepositoryTest`). `analyticsEntryPointContext` keeps only `EntryPointContext.ALLOWED_KEYS` from a media item's extras (`CastMediaItemConverterTest`). Auto-advance and skips start listens as `queue_auto_advance` and `queue_skip`, which are now named entry points. + - `PlaybackIntroOutroControllerTest` covers the 1 ms / 100 ms automatic-transition regression, explicit zero, intro trim, stale cutoff, explicit History resume, completed episodes, delayed readiness, and manual seeks. `QueueManagerPlaybackTest`, `QueueRepositoryTest`, `SmartQueueRefillCoordinatorTest`, and `ContextQueueContinuationCoordinatorTest` cover context replacement, ordered mixed-show playback, transactional rollback, completion-before-refill, long lists, and delayed results after a new queue or pause. - Review regressions also cover abandoned positions after a different activation, current-item transport seeks, live Auto resume, Room prefix trimming during candidate loading, rejected upcoming queue edits, and natural-advance telemetry signal expiry. diff --git a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/CastMediaItemConverter.kt b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/CastMediaItemConverter.kt index 9846a5319..1a0dc271c 100644 --- a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/CastMediaItemConverter.kt +++ b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/CastMediaItemConverter.kt @@ -26,8 +26,6 @@ internal object CastMediaMetadata { fun effectiveUri(mediaItem: MediaItem): String? = remoteUri(mediaItem) ?: mediaItem.localConfiguration?.uri?.toString() - - fun isAnalyticsSafeExtra(key: String): Boolean = key != REMOTE_URI_KEY } /** diff --git a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/FirstPlayClaim.kt b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/FirstPlayClaim.kt new file mode 100644 index 000000000..52268e75b --- /dev/null +++ b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/FirstPlayClaim.kt @@ -0,0 +1,56 @@ +package cx.aswin.boxlore.core.playback + +import android.content.Context +import android.content.SharedPreferences +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Claims this install's first play once, from the player, so `first_episode_played` fires whatever + * started playback (the app, Android Auto, a notification) and names the episode. The claim lives in + * a prefs file kept out of backups, so a restored or new phone counts its own first play. + */ +internal class FirstPlayClaim( + private val store: FirstPlayStore, + /** True when an install updated from an older boxlore already sent the event from the screen. */ + private val loggedBeforeUpdate: suspend () -> Boolean, +) { + private val lock = Mutex() + + /** True exactly once per install: for the first play that should send the event. */ + suspend fun claim(): Boolean = lock.withLock { + if (store.isClaimed()) return@withLock false + val alreadySent = runCatching { loggedBeforeUpdate() }.getOrDefault(false) + store.markClaimed() + !alreadySent + } +} + +/** Where [FirstPlayClaim] keeps its flag: SharedPreferences in the app, a fake in tests. */ +internal interface FirstPlayStore { + fun isClaimed(): Boolean + + fun markClaimed() +} + +/** [FirstPlayStore] in `boxlore_install_milestones.xml`, which the backup rules exclude. */ +internal class PrefsFirstPlayStore(context: Context) : FirstPlayStore { + private val prefs: SharedPreferences = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + override fun isClaimed(): Boolean = prefs.getBoolean(KEY_FIRST_PLAY, false) + + override fun markClaimed() { + prefs.edit().putBoolean(KEY_FIRST_PLAY, true).apply() + } + + private companion object { + const val PREFS_NAME = "boxlore_install_milestones" + const val KEY_FIRST_PLAY = "first_play_claimed" + } +} + +/** True when this install was updated after it was installed, so it may predate [FirstPlayClaim]. */ +internal fun Context.isUpdatedInstall(): Boolean = runCatching { + val info = packageManager.getPackageInfo(packageName, 0) + info.lastUpdateTime > info.firstInstallTime +}.getOrDefault(false) diff --git a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackQueueCoordinator.kt b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackQueueCoordinator.kt index 2a84bbf25..e31810c9d 100644 --- a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackQueueCoordinator.kt +++ b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackQueueCoordinator.kt @@ -956,7 +956,8 @@ internal class PlaybackQueueCoordinator( // A new queue is about to start; don't block session restore on next launch. prefs.edit().putBoolean(playerDismissedKey, false).apply() try { - queueRepository.clearQueue() + // Only the Lore conflict dialog stops and clears, after the listener chose to start Lore. + queueRepository.clearQueue(listenerSource = "lore_conflict") } catch (e: Exception) { android.util.Log.e("PlaybackRepo", "stopAndClearQueue: Failed to clear DB queue", e) } diff --git a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackTelemetrySession.kt b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackTelemetrySession.kt index 9054ebd7c..be53d1613 100644 --- a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackTelemetrySession.kt +++ b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackTelemetrySession.kt @@ -5,6 +5,7 @@ import android.util.Log import androidx.media3.common.MediaItem import androidx.media3.common.Player import cx.aswin.boxlore.core.analytics.AnalyticsHelper +import cx.aswin.boxlore.core.analytics.EntryPointContext import cx.aswin.boxlore.core.analytics.ListenStart import cx.aswin.boxlore.core.analytics.PendingEntryPoint import cx.aswin.boxlore.core.analytics.PlaybackListenValues @@ -57,6 +58,9 @@ internal class PlaybackTelemetrySession( /** This installation's listening device ID (`BoxcastPrefs.getOrCreateListeningDeviceId`); set by the service. */ var listeningDeviceId: () -> String? = { null } + /** Sends `first_episode_played` once per install when a listen starts; set by the service. */ + var firstPlay: FirstPlayClaim? = null + /** The listen in progress and its one record; the service sets its route, note and visibility. */ val listens = ListenRecorder(playerProvider) @@ -336,6 +340,7 @@ internal class PlaybackTelemetrySession( speed = speed, listen = listen, ) + if (firstPlay?.claim() == true) AnalyticsHelper.trackFirstEpisodePlayed(episodeId, resolvedPodcastId) } /** Download when any stored ID of the episode holds a finished download, else stream. */ @@ -674,13 +679,14 @@ internal class PlaybackTelemetrySession( } } +/** The entry-point details in a media item's extras; its artwork links, stream address and Auto keys stay out. */ internal fun analyticsEntryPointContext(extras: Bundle?): Map? { if (extras == null) return null val context = mutableMapOf() extras.keySet().forEach { key -> @Suppress("DEPRECATION") val value = extras.get(key) - if (value != null && key != "entry_point" && CastMediaMetadata.isAnalyticsSafeExtra(key)) { + if (value != null && EntryPointContext.isAllowed(key)) { context[key] = value } } diff --git a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/QueueRepository.kt b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/QueueRepository.kt index 6ad79d9f9..4e3c3ce23 100644 --- a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/QueueRepository.kt +++ b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/QueueRepository.kt @@ -146,7 +146,11 @@ class QueueRepository( } } - suspend fun clearQueue() { + /** + * Empties the queue. Only a clear the listener chose sends `queue_modified` "clear" (pass its + * [listenerSource]); starting a new episode or mix replaces the queue without one. + */ + suspend fun clearQueue(listenerSource: String? = null) { android.util.Log.d(TAG, "clearQueue: Clearing all queue items") val localDeviceId = deviceIdentityPort?.getDeviceId() database.withTransaction { @@ -160,10 +164,9 @@ class QueueRepository( removedIdentities = listenerStore.queueIdentities(removedIds), ) } - cx.aswin.boxlore.core.analytics.AnalyticsHelper.trackQueueModified( - action = "clear", - queueSize = 0, - ) + listenerSource?.let { source -> + cx.aswin.boxlore.core.analytics.AnalyticsHelper.trackQueueModified(action = "clear", queueSize = 0, source = source) + } } /** Rolls back suspended refill writes if another transport request or queue edit wins. */ diff --git a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/BoxLorePlaybackService.kt b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/BoxLorePlaybackService.kt index be92842f2..83ad9d217 100644 --- a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/BoxLorePlaybackService.kt +++ b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/BoxLorePlaybackService.kt @@ -18,6 +18,7 @@ import cx.aswin.boxlore.core.playback.PlaybackSkipPolicy import cx.aswin.boxlore.core.playback.PlaybackTaskRemovalPolicy import cx.aswin.boxlore.core.playback.PlaybackTelemetrySession import cx.aswin.boxlore.core.playback.PlaybackUiVisibility +import cx.aswin.boxlore.core.playback.isUpdatedInstall import cx.aswin.boxlore.core.playback.service.auto.AutoBrowseLibraryCallback import cx.aswin.boxlore.core.playback.service.auto.AutoBrowseLibraryHost import cx.aswin.boxlore.core.playback.service.auto.stripEpisodePrefix @@ -186,6 +187,10 @@ open class BoxLorePlaybackService : session.listens.analyticsOutput = { ListeningOutputRoute.forAnalytics(this, mediaSession?.player ?: playbackPlayer) } session.listens.isAppInBackground = { !PlaybackUiVisibility.isForeground.value } session.listens.listenNotes = cx.aswin.boxlore.core.playback.ListenNoteKeeper(cx.aswin.boxlore.core.playback.PrefsListenNoteStore(this)) + session.firstPlay = + cx.aswin.boxlore.core.playback.FirstPlayClaim(cx.aswin.boxlore.core.playback.PrefsFirstPlayStore(this)) { + isUpdatedInstall() && userPreferencesRepository.hasLoggedFirstPlay.first() + } } } diff --git a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseLibraryCallback.kt b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseLibraryCallback.kt index aadf371d0..611190140 100644 --- a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseLibraryCallback.kt +++ b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseLibraryCallback.kt @@ -623,12 +623,12 @@ internal class AutoBrowseLibraryCallback( mediaItems: MutableList, ): ListenableFuture> { android.util.Log.d("AutoBrowse", "onAddMediaItems: ${mediaItems.size} items") + // The app's own plays come through here too (Media3 sends setMediaItems this way). + val fromCar = isAndroidAuto(mediaSession, controller) return host.serviceScope.future { if (mediaItems.size > 1) { - cx.aswin.boxlore.core.analytics.PendingEntryPoint.set( - mapOf("entry_point" to "android_auto_play_all"), - ) + labelCarPlay(fromCar, "android_auto_play_all") return@future mediaItems.map { mediaResolver.resolveMediaItem(it) }.toMutableList() } @@ -637,18 +637,16 @@ internal class AutoBrowseLibraryCallback( if (!searchQuery.isNullOrBlank()) { android.util.Log.d("AutoBrowse", "Voice play request: '$searchQuery'") - cx.aswin.boxlore.core.analytics.PendingEntryPoint.set( - mapOf("entry_point" to "android_auto_voice"), - ) - return@future voiceSearch.handleVoiceSearchQuery(searchQuery) + labelCarPlay(fromCar, "android_auto_voice") + return@future voiceSearch.handleVoiceSearchQuery(searchQuery, fromCar) } when (selectedItem.mediaId) { - PLAY_ALL_NEW_EPISODES_ID -> return@future voiceSearch.handlePlayAllNewEpisodes() - AutoBrowseContract.PLAY_ALL_LIKED_ID -> return@future voiceSearch.handlePlayAllLiked() - AutoBrowseContract.PLAY_ALL_DOWNLOADS_ID -> return@future voiceSearch.handlePlayAllDownloads() - AutoBrowseContract.PLAY_ALL_DRIVE_ID -> return@future voiceSearch.handlePlayAllDrivePicks() - AutoBrowseContract.PLAY_ALL_MIXTAPE_ID -> return@future voiceSearch.handlePlayAllMixtape() + PLAY_ALL_NEW_EPISODES_ID -> return@future voiceSearch.handlePlayAllNewEpisodes(fromCar) + AutoBrowseContract.PLAY_ALL_LIKED_ID -> return@future voiceSearch.handlePlayAllLiked(fromCar) + AutoBrowseContract.PLAY_ALL_DOWNLOADS_ID -> return@future voiceSearch.handlePlayAllDownloads(fromCar) + AutoBrowseContract.PLAY_ALL_DRIVE_ID -> return@future voiceSearch.handlePlayAllDrivePicks(fromCar) + AutoBrowseContract.PLAY_ALL_MIXTAPE_ID -> return@future voiceSearch.handlePlayAllMixtape(fromCar) } val source = @@ -656,13 +654,13 @@ internal class AutoBrowseLibraryCallback( ?.getString(AutoBrowseContract.EXTRA_SOURCE) ?: AutoBrowseContract.SOURCE_DISCOVER if (selectedItem.mediaId.startsWith(AutoBrowseContract.QUEUE_PREFIX)) { - return@future voiceSearch.handlePlayFromQueue(selectedItem.mediaId.stripEpisodePrefix()) + return@future voiceSearch.handlePlayFromQueue(selectedItem.mediaId.stripEpisodePrefix(), fromCar) } if (source == AutoBrowseContract.SOURCE_MIXTAPE) { - return@future voiceSearch.handlePlayFromMixtape(selectedItem.mediaId.stripEpisodePrefix()) + return@future voiceSearch.handlePlayFromMixtape(selectedItem.mediaId.stripEpisodePrefix(), fromCar) } - handleSingleMediaItemSelection(mediaSession, selectedItem, source) + handleSingleMediaItemSelection(mediaSession, selectedItem, source, fromCar) } } @@ -670,15 +668,14 @@ internal class AutoBrowseLibraryCallback( mediaSession: MediaSession, selectedItem: MediaItem, source: String, + fromCar: Boolean, ): MutableList { android.util.Log.d( "BoxCastPlayer", "onAddMediaItems: selectedItem.mediaId=${selectedItem.mediaId}, " + "extrasKeys=${selectedItem.mediaMetadata.extras?.keySet()?.joinToString(", ")}", ) - cx.aswin.boxlore.core.analytics.PendingEntryPoint.set( - mapOf("entry_point" to "android_auto_$source"), - ) + labelCarPlay(fromCar, "android_auto_$source") val resolvedItem = mediaResolver.resolveMediaItem(selectedItem) val episodeId = selectedItem.mediaId.stripEpisodePrefix() android.util.Log.d( diff --git a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoVoiceSearchHandler.kt b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoVoiceSearchHandler.kt index b83a9e1fa..3635c8744 100644 --- a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoVoiceSearchHandler.kt +++ b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoVoiceSearchHandler.kt @@ -111,7 +111,7 @@ internal class AutoVoiceSearchHandler(private val host: AutoBrowseLibraryHost, p private fun voiceMatchScore(title: String, author: String?, query: String,): Int = cx.aswin.boxlore.core.playback.AutoVoiceSearchLogic .voiceMatchScore(title, author, query) - suspend fun handleVoiceSearchQuery(searchQuery: String): MutableList { + suspend fun handleVoiceSearchQuery(searchQuery: String, fromCar: Boolean): MutableList { val rawQuery = searchQuery.lowercase() val normalizedQuery = normalizeVoiceQuery(searchQuery) android.util.Log.d( @@ -119,7 +119,7 @@ internal class AutoVoiceSearchHandler(private val host: AutoBrowseLibraryHost, p "Normalized voice query '$searchQuery' → '$normalizedQuery'", ) - handleVoiceQueryQuickFallbacks(rawQuery, normalizedQuery)?.let { return it } + handleVoiceQueryQuickFallbacks(rawQuery, normalizedQuery, fromCar)?.let { return it } handleVoiceQueryHistoryResume(rawQuery)?.let { return it } handleVoiceQuerySubscriptionMatch(normalizedQuery)?.let { return it } handleVoiceQueryRemoteSearch(normalizedQuery)?.let { return it } @@ -130,17 +130,17 @@ internal class AutoVoiceSearchHandler(private val host: AutoBrowseLibraryHost, p return mutableListOf(voiceHistoryItem(fallback)) } - return handlePlayAllMixtape().ifEmpty { + return handlePlayAllMixtape(fromCar).ifEmpty { treeBuilder.getDownloadEpisodeItems().take(1).toMutableList() } } - private suspend fun handleVoiceQueryQuickFallbacks(rawQuery: String, normalizedQuery: String,): MutableList? { + private suspend fun handleVoiceQueryQuickFallbacks(rawQuery: String, normalizedQuery: String, fromCar: Boolean,): MutableList? { if (rawQuery.contains("download") || rawQuery.contains("offline")) { return treeBuilder.getDownloadEpisodeItems().toMutableList() } if (rawQuery.contains("drive mix") || rawQuery.contains("mixtape")) { - return handlePlayAllMixtape() + return handlePlayAllMixtape(fromCar) } if ( normalizedQuery in @@ -155,7 +155,7 @@ internal class AutoVoiceSearchHandler(private val host: AutoBrowseLibraryHost, p "my mix", ) ) { - return handlePlayAllMixtape() + return handlePlayAllMixtape(fromCar) } return null } @@ -328,7 +328,7 @@ internal class AutoVoiceSearchHandler(private val host: AutoBrowseLibraryHost, p groupTitle = host.getString(cx.aswin.boxlore.core.catalog.R.string.auto_group_continue), ) - suspend fun handlePlayAllNewEpisodes(): MutableList { + suspend fun handlePlayAllNewEpisodes(fromCar: Boolean): MutableList { android.util.Log.d("AutoBrowse", "Play All New Episodes triggered") val subscriptions = host.database.podcastDao().getSubscribedPodcastsList() @@ -338,9 +338,7 @@ internal class AutoVoiceSearchHandler(private val host: AutoBrowseLibraryHost, p .sortedByDescending { (ep, _) -> ep.publishedDate } .take(20) - cx.aswin.boxlore.core.analytics.PendingEntryPoint.set( - mapOf("entry_point" to "android_auto_new_episodes"), - ) + labelCarPlay(fromCar, "android_auto_new_episodes") return newEpisodes .map { (episode, podcast) -> AutoMediaItemFactory.fromEpisode( @@ -356,10 +354,8 @@ internal class AutoVoiceSearchHandler(private val host: AutoBrowseLibraryHost, p }.toMutableList() } - suspend fun handlePlayAllLiked(): MutableList { - cx.aswin.boxlore.core.analytics.PendingEntryPoint.set( - mapOf("entry_point" to "android_auto_liked"), - ) + suspend fun handlePlayAllLiked(fromCar: Boolean): MutableList { + labelCarPlay(fromCar, "android_auto_liked") // Each liked episode once, even when its like is stored under several IDs. return host.database .episodeListenerStore() @@ -383,32 +379,26 @@ internal class AutoVoiceSearchHandler(private val host: AutoBrowseLibraryHost, p }.toMutableList() } - suspend fun handlePlayAllDownloads(): MutableList { - cx.aswin.boxlore.core.analytics.PendingEntryPoint.set( - mapOf("entry_point" to "android_auto_downloads"), - ) + suspend fun handlePlayAllDownloads(fromCar: Boolean): MutableList { + labelCarPlay(fromCar, "android_auto_downloads") return treeBuilder.getDownloadEpisodeItems().toMutableList() } - suspend fun handlePlayAllDrivePicks(): MutableList { - cx.aswin.boxlore.core.analytics.PendingEntryPoint.set( - mapOf("entry_point" to "android_auto_drive_picks"), - ) + suspend fun handlePlayAllDrivePicks(fromCar: Boolean): MutableList { + labelCarPlay(fromCar, "android_auto_drive_picks") return treeBuilder.getDrivePicksChildren() .filterNot { it.mediaId == AutoBrowseContract.PLAY_ALL_DRIVE_ID } .toMutableList() } - suspend fun handlePlayAllMixtape(): MutableList { - cx.aswin.boxlore.core.analytics.PendingEntryPoint.set( - mapOf("entry_point" to "android_auto_mixtape"), - ) + suspend fun handlePlayAllMixtape(fromCar: Boolean): MutableList { + labelCarPlay(fromCar, "android_auto_mixtape") return treeBuilder.getMixtapeChildren() .filterNot { it.mediaId == AutoBrowseContract.PLAY_ALL_MIXTAPE_ID } .toMutableList() } - suspend fun handlePlayFromMixtape(episodeId: String): MutableList { + suspend fun handlePlayFromMixtape(episodeId: String, fromCar: Boolean): MutableList { val mixtape = treeBuilder.getMixtapeChildren() .filterNot { it.mediaId == AutoBrowseContract.PLAY_ALL_MIXTAPE_ID } @@ -417,22 +407,18 @@ internal class AutoVoiceSearchHandler(private val host: AutoBrowseLibraryHost, p it.mediaId.stripEpisodePrefix() == episodeId } if (selectedIndex < 0) return mutableListOf() - cx.aswin.boxlore.core.analytics.PendingEntryPoint.set( - mapOf("entry_point" to "android_auto_mixtape"), - ) + labelCarPlay(fromCar, "android_auto_mixtape") return mixtape.drop(selectedIndex).toMutableList() } - suspend fun handlePlayFromQueue(episodeId: String): MutableList { + suspend fun handlePlayFromQueue(episodeId: String, fromCar: Boolean): MutableList { val queue = host.queueRepository.getQueueEpisodeSnapshot() val selectedIndex = queue.indexOfFirst { it.id == episodeId } if (selectedIndex < 0) { android.util.Log.w("AutoBrowse", "Ignoring stale queue selection: $episodeId") return mutableListOf() } - cx.aswin.boxlore.core.analytics.PendingEntryPoint.set( - mapOf("entry_point" to "android_auto_queue"), - ) + labelCarPlay(fromCar, "android_auto_queue") return queue .drop(selectedIndex) .map { episode -> diff --git a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/CarPlayLabel.kt b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/CarPlayLabel.kt new file mode 100644 index 000000000..d83da50f1 --- /dev/null +++ b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/CarPlayLabel.kt @@ -0,0 +1,12 @@ +package cx.aswin.boxlore.core.playback.service.auto + +import cx.aswin.boxlore.core.analytics.PendingEntryPoint + +/** + * Names a play `android_auto_*` only when Android Auto asked for it. The app's own plays reach the + * session callback too, and labelling them replaced the entry point the app had set, so phone plays + * were counted as car plays. + */ +internal fun labelCarPlay(fromCar: Boolean, entryPoint: String) { + if (fromCar) PendingEntryPoint.set(mapOf("entry_point" to entryPoint)) +} diff --git a/core/playback/src/test/java/cx/aswin/boxlore/core/playback/CastMediaItemConverterTest.kt b/core/playback/src/test/java/cx/aswin/boxlore/core/playback/CastMediaItemConverterTest.kt index a4a543c15..90566ca0a 100644 --- a/core/playback/src/test/java/cx/aswin/boxlore/core/playback/CastMediaItemConverterTest.kt +++ b/core/playback/src/test/java/cx/aswin/boxlore/core/playback/CastMediaItemConverterTest.kt @@ -69,6 +69,11 @@ class CastMediaItemConverterTest { putString("entry_point", "queue") putString("recommendation_id", "rec-1") putString(CastMediaMetadata.REMOTE_URI_KEY, "https://cdn.example.com/episode.mp3") + // Regression: these reached playback events as properties. + putStringArrayList("boxlore_artwork_sources", arrayListOf("https://cdn.example.com/cover.jpg?token=1")) + putString("cx.aswin.boxlore.auto.SOURCE", "discover") + putInt("android.media.extra.DOWNLOAD_STATUS", 2) + putString("boxlore_podcast_id", "123") } assertEquals( diff --git a/core/playback/src/test/java/cx/aswin/boxlore/core/playback/FirstPlayClaimTest.kt b/core/playback/src/test/java/cx/aswin/boxlore/core/playback/FirstPlayClaimTest.kt new file mode 100644 index 000000000..177f11b91 --- /dev/null +++ b/core/playback/src/test/java/cx/aswin/boxlore/core/playback/FirstPlayClaimTest.kt @@ -0,0 +1,55 @@ +package cx.aswin.boxlore.core.playback + +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** `first_episode_played` is claimed once per install, from the player. */ +class FirstPlayClaimTest { + private class MemoryStore(var claimed: Boolean = false) : FirstPlayStore { + override fun isClaimed() = claimed + + override fun markClaimed() { + claimed = true + } + } + + @Test + fun `the first play claims once and later plays never do`() = runTest { + val store = MemoryStore() + val claim = FirstPlayClaim(store) { false } + + assertTrue(claim.claim()) + assertFalse(claim.claim()) + assertTrue(store.claimed) + } + + @Test + fun `plays starting together still claim once`() = runTest { + val claim = FirstPlayClaim(MemoryStore()) { false } + + val results = List(5) { async { claim.claim() } }.awaitAll() + + assertEquals(1, results.count { it }) + } + + @Test + fun `an updated install that already sent it from the screen stays quiet`() = runTest { + val store = MemoryStore() + val claim = FirstPlayClaim(store) { true } + + assertFalse(claim.claim()) + assertTrue(store.claimed) + } + + @Test + fun `a failed legacy read still claims`() = runTest { + val claim = FirstPlayClaim(MemoryStore()) { error("datastore unavailable") } + + assertTrue(claim.claim()) + } +} diff --git a/core/playback/src/test/java/cx/aswin/boxlore/core/playback/QueueRepositoryTest.kt b/core/playback/src/test/java/cx/aswin/boxlore/core/playback/QueueRepositoryTest.kt index e1250982c..2ff332dcc 100644 --- a/core/playback/src/test/java/cx/aswin/boxlore/core/playback/QueueRepositoryTest.kt +++ b/core/playback/src/test/java/cx/aswin/boxlore/core/playback/QueueRepositoryTest.kt @@ -198,6 +198,28 @@ class QueueRepositoryTest { assertTrue(repository.queue.first().isEmpty()) } + @Test + fun onlyAListenerClearIsReported() = runTest { + // Regression: every new episode or mix replaced the queue through clearQueue and was reported + // as the listener clearing it. + val recorder = mutableListOf>>() + val restore = cx.aswin.boxlore.core.analytics.AnalyticsHelper.installRecordingSink(recorder) + try { + repository.addToQueue(episodeItem(1), podcast()) + recorder.clear() + repository.clearQueue() + assertFalse(recorder.any { it.first == "queue_modified" && it.second["action"] == "clear" }) + + repository.addToQueue(episodeItem(2), podcast()) + recorder.clear() + repository.clearQueue(listenerSource = "lore_conflict") + val clear = recorder.single { it.first == "queue_modified" && it.second["action"] == "clear" }.second + assertEquals("lore_conflict", clear["source"]) + } finally { + restore() + } + } + @Test fun replaceQueueDeduplicatesAndOrders() = runTest { repository.replaceQueue( diff --git a/core/playback/src/test/java/cx/aswin/boxlore/core/playback/service/auto/AutoEpisodeAliasListsTest.kt b/core/playback/src/test/java/cx/aswin/boxlore/core/playback/service/auto/AutoEpisodeAliasListsTest.kt index dcafb3039..81cdcf715 100644 --- a/core/playback/src/test/java/cx/aswin/boxlore/core/playback/service/auto/AutoEpisodeAliasListsTest.kt +++ b/core/playback/src/test/java/cx/aswin/boxlore/core/playback/service/auto/AutoEpisodeAliasListsTest.kt @@ -70,7 +70,7 @@ class AutoEpisodeAliasListsTest { @Test fun playAllLikedQueuesAnEpisodeStoredUnderTwoIdsOnce() = runBlocking { - val ids = voiceHandler.handlePlayAllLiked().map { it.mediaId } + val ids = voiceHandler.handlePlayAllLiked(fromCar = true).map { it.mediaId } assertEquals(2, ids.size) assertTrue(ids.none { it.endsWith("B") }) diff --git a/core/playback/src/test/java/cx/aswin/boxlore/core/playback/service/auto/CarPlayLabelTest.kt b/core/playback/src/test/java/cx/aswin/boxlore/core/playback/service/auto/CarPlayLabelTest.kt new file mode 100644 index 000000000..aeafc64db --- /dev/null +++ b/core/playback/src/test/java/cx/aswin/boxlore/core/playback/service/auto/CarPlayLabelTest.kt @@ -0,0 +1,30 @@ +package cx.aswin.boxlore.core.playback.service.auto + +import cx.aswin.boxlore.core.analytics.PendingEntryPoint +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class CarPlayLabelTest { + @AfterEach + fun tearDown() { + PendingEntryPoint.consume() + } + + @Test + fun `a play from the app keeps the app's entry point`() { + // Regression: phone plays pass through the session callback and were relabelled android_auto_*. + PendingEntryPoint.set(mapOf("entry_point" to "home_hero_resume")) + + labelCarPlay(fromCar = false, entryPoint = "android_auto_discover") + + assertEquals("home_hero_resume", PendingEntryPoint.consume()?.get("entry_point")) + } + + @Test + fun `a play Android Auto asked for is labelled as one`() { + labelCarPlay(fromCar = true, entryPoint = "android_auto_liked") + + assertEquals("android_auto_liked", PendingEntryPoint.consume()?.get("entry_point")) + } +} diff --git a/core/prefs/README.md b/core/prefs/README.md index 14719ee52..c495bf34e 100644 --- a/core/prefs/README.md +++ b/core/prefs/README.md @@ -81,6 +81,7 @@ src/main/java/cx/aswin/boxlore/core/prefs/ - Preference keys defined in `UserPreferenceKeys` and `BoxcastPrefs` are persisted user identity and must not be renamed casually. - `subscription_manual_order` and `home_pinned_podcast_ids` live in the same `user_preferences` DataStore (do not rename the file). Unsubscribe drops that id from both lists. - `seen_listening_story` (`seenListeningStoryStream` / `setSeenListeningStory`, added 2026-10-31): the last monthly listening story opened, as `yyyy-MM`, so Listening history stops calling it New. Local to the phone. +- Review prompt (`EngagementPreferences`): a milestone (5, 15 or 30 episodes finished in boxlore on this install; Home counts them) is stored as `review_milestone_pending` and, once a prompt shows, as `review_milestone_consumed`, so the same milestone never asks again. `shouldShowReviewPrompt` and `isInReviewQuietPeriod` take Android's install time (a backup doesn't carry it) and keep a 2-day quiet period (`REVIEW_QUIET_DAYS`) after it and after `library_imported_at` (`markLibraryImported`). `review_first_launch_at` (the old lazily set install day) is no longer read. `has_logged_first_play` is read only so an updated install doesn't send `first_episode_played` again; the player claims it now. `UserPreferencesRepositoryTest` covers the quiet days after install and import and a used milestone never coming back. - `legacy_rss_repair_*` keys are a crash-recovery contract; keep them until every shipping build that can begin the repair has aged out. ## Testing notes diff --git a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/EngagementPreferences.kt b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/EngagementPreferences.kt index 604c904e6..aeec5e390 100644 --- a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/EngagementPreferences.kt +++ b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/EngagementPreferences.kt @@ -43,9 +43,6 @@ private object AnalyticsKeys { val REVIEW_HAS_REVIEWED = androidx.datastore.preferences.core .booleanPreferencesKey("review_has_reviewed") - val REVIEW_FIRST_LAUNCH_AT = - androidx.datastore.preferences.core - .longPreferencesKey("review_first_launch_at") // NPS survey: milestone marks eligibility (pending); the event fires on // the next app open so it never surfaces during background playback. @@ -70,6 +67,16 @@ private object AnalyticsKeys { val REVIEW_MILESTONE_PENDING = androidx.datastore.preferences.core .intPreferencesKey("review_milestone_pending") + + /** The highest milestone a review prompt already used; a milestone counts once. */ + val REVIEW_MILESTONE_CONSUMED = + androidx.datastore.preferences.core + .intPreferencesKey("review_milestone_consumed") + + /** When a library import or backup restore last finished, for the quiet days after it. */ + val LIBRARY_IMPORTED_AT = + androidx.datastore.preferences.core + .longPreferencesKey("library_imported_at") } private object FeatureKeys { @@ -146,19 +153,40 @@ internal class ReviewPromptPreferences(private val dataStore: DataStore + pref[AnalyticsKeys.REVIEW_MILESTONE_CONSUMED] = maxOf(used, pref[AnalyticsKeys.REVIEW_MILESTONE_CONSUMED] ?: 0) + } pref.remove(AnalyticsKeys.REVIEW_MILESTONE_PENDING) } } + /** A library import or backup restore finished: no review prompt for the quiet days after it. */ + suspend fun markLibraryImported(nowMs: Long = System.currentTimeMillis()) { + dataStore.edit { it[AnalyticsKeys.LIBRARY_IMPORTED_AT] = nowMs } + } + + /** + * True within [EngagementPromptConstants.REVIEW_QUIET_DAYS] of installing boxlore ([installedAtMs], + * Android's own install time, which a backup doesn't carry) or of the last library import. + */ + suspend fun isInReviewQuietPeriod(installedAtMs: Long, nowMs: Long = System.currentTimeMillis(),): Boolean = + isInReviewQuietPeriod(dataStore.data.first(), installedAtMs, nowMs) + + private fun isInReviewQuietPeriod(prefs: Preferences, installedAtMs: Long, nowMs: Long,): Boolean { + val quietFrom = maxOf(installedAtMs, prefs[AnalyticsKeys.LIBRARY_IMPORTED_AT] ?: 0L) + return nowMs - quietFrom < EngagementPromptConstants.REVIEW_QUIET_DAYS * DAY_MS + } + /** * Rules to show milestone Play review: * - A milestone (5/15/30) was reached and stored as pending (survives playback gaps) * - NPS survey already fired; skip detractors (score <= 7) * - Shared 14-day engagement cooldown - * - User has NOT reviewed yet; app installed 2+ days; max 3 lifetime; 30-day review gap + * - User has NOT reviewed yet; 2+ days since install ([installedAtMs]) and since the last import; + * max 3 lifetime; 30-day review gap * - Never during playback */ - suspend fun shouldShowReviewPrompt(isPlaying: Boolean): Boolean { + suspend fun shouldShowReviewPrompt(isPlaying: Boolean, installedAtMs: Long, nowMs: Long = System.currentTimeMillis(),): Boolean { if (isPlaying) return false val prefs = dataStore.data.first() @@ -169,21 +197,17 @@ internal class ReviewPromptPreferences(private val dataStore: DataStore= 3) return false - val firstLaunch = prefs[AnalyticsKeys.REVIEW_FIRST_LAUNCH_AT] - if (firstLaunch == null) { - dataStore.edit { it[AnalyticsKeys.REVIEW_FIRST_LAUNCH_AT] = System.currentTimeMillis() } - return false - } - - val daysSinceInstall = (System.currentTimeMillis() - firstLaunch) / (1000 * 60 * 60 * 24) - if (daysSinceInstall < 2) return false + if (isInReviewQuietPeriod(prefs, installedAtMs, nowMs)) return false val lastPrompt = prefs[AnalyticsKeys.REVIEW_LAST_PROMPT_AT] ?: 0L - val daysSinceLastPrompt = (System.currentTimeMillis() - lastPrompt) / (1000 * 60 * 60 * 24) + val daysSinceLastPrompt = (nowMs - lastPrompt) / DAY_MS return lastPrompt == 0L || daysSinceLastPrompt >= 30 } - /** Remember the highest unreached milestone so prompts survive playback gaps. */ + /** + * Remember the highest milestone reached so prompts survive playback gaps; one a prompt already + * used never comes back. [completedCount] should count episodes finished in boxlore on this install. + */ suspend fun syncReviewMilestonePending(completedCount: Int) { val milestone = when { @@ -194,6 +218,7 @@ internal class ReviewPromptPreferences(private val dataStore: DataStore if (pref[AnalyticsKeys.REVIEW_HAS_REVIEWED] == true) return@edit + if (milestone <= (pref[AnalyticsKeys.REVIEW_MILESTONE_CONSUMED] ?: 0)) return@edit val current = pref[AnalyticsKeys.REVIEW_MILESTONE_PENDING] if (current == null || milestone > current) { pref[AnalyticsKeys.REVIEW_MILESTONE_PENDING] = milestone @@ -222,6 +247,10 @@ internal class ReviewPromptPreferences(private val dataStore: DataStore) { + /** + * Set by versions that sent `first_episode_played` from the screen. The player now claims it once + * per install (core:playback `FirstPlayClaim`) and reads this only so an update doesn't send it again. + */ val hasLoggedFirstPlay: Flow = dataStore.data .catch { if (it is IOException) emit(emptyPreferences()) else throw it } @@ -303,3 +332,5 @@ private fun isEngagementCooldownElapsed(pref: Preferences): Boolean { val days = (System.currentTimeMillis() - last) / (1000 * 60 * 60 * 24) return days >= EngagementPromptConstants.ENGAGEMENT_COOLDOWN_DAYS } + +private const val DAY_MS = 24L * 60 * 60 * 1000 diff --git a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/EngagementPromptConstants.kt b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/EngagementPromptConstants.kt index 41cba36a3..d66df2190 100644 --- a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/EngagementPromptConstants.kt +++ b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/EngagementPromptConstants.kt @@ -12,4 +12,7 @@ object EngagementPromptConstants { /** Maximum NPS score (inclusive) treated as a detractor for milestone review gating. */ const val DETRACTOR_SCORE_MAX = 7 + + /** Days after installing boxlore, or after a library import, before any review prompt. */ + const val REVIEW_QUIET_DAYS = 2 } diff --git a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/UserPreferencesRepository.kt b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/UserPreferencesRepository.kt index 15734b24c..bb3a89918 100644 --- a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/UserPreferencesRepository.kt +++ b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/UserPreferencesRepository.kt @@ -624,7 +624,13 @@ class UserPreferencesRepository(context: Context,) { suspend fun markReviewPromptShown() = reviews.markReviewPromptShown() - suspend fun shouldShowReviewPrompt(isPlaying: Boolean): Boolean = reviews.shouldShowReviewPrompt(isPlaying) + suspend fun shouldShowReviewPrompt(isPlaying: Boolean, installedAtMs: Long, nowMs: Long = System.currentTimeMillis(),): Boolean = + reviews.shouldShowReviewPrompt(isPlaying, installedAtMs, nowMs) + + suspend fun isInReviewQuietPeriod(installedAtMs: Long, nowMs: Long = System.currentTimeMillis(),): Boolean = + reviews.isInReviewQuietPeriod(installedAtMs, nowMs) + + suspend fun markLibraryImported(nowMs: Long = System.currentTimeMillis()) = reviews.markLibraryImported(nowMs) suspend fun syncReviewMilestonePending(completedCount: Int) = reviews.syncReviewMilestonePending(completedCount) diff --git a/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/ReviewPromptPreferencesTest.kt b/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/ReviewPromptPreferencesTest.kt new file mode 100644 index 000000000..278526a88 --- /dev/null +++ b/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/ReviewPromptPreferencesTest.kt @@ -0,0 +1,135 @@ +package cx.aswin.boxlore.core.prefs + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** The milestone review prompt: when it may show, the quiet days, and milestones counting once. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class ReviewPromptPreferencesTest { + private lateinit var context: Context + private lateinit var repository: UserPreferencesRepository + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + runBlocking { context.userPreferencesDataStore.edit { it.clear() } } + repository = UserPreferencesRepository(context) + } + + @After + fun tearDown() { + runBlocking { context.userPreferencesDataStore.edit { it.clear() } } + } + + @Test + fun shouldShowReviewPromptFalseWhilePlaying() = runTest { + reachReviewMilestone() + assertFalse(repository.shouldShowReviewPrompt(isPlaying = true, installedAtMs = INSTALLED_LONG_AGO)) + } + + @Test + fun shouldShowReviewPromptFalseWithoutPendingMilestone() = runTest { + repository.markNpsSurveyFired() + assertFalse(repository.shouldShowReviewPrompt(isPlaying = false, installedAtMs = INSTALLED_LONG_AGO)) + } + + @Test + fun reviewPromptShowsOnceMilestoneIsReached() = runTest { + reachReviewMilestone() + assertTrue(repository.shouldShowReviewPrompt(isPlaying = false, installedAtMs = INSTALLED_LONG_AGO)) + } + + @Test + fun reviewPromptWaitsTwoDaysAfterInstall() = runTest { + reachReviewMilestone() + val now = System.currentTimeMillis() + assertFalse(repository.shouldShowReviewPrompt(isPlaying = false, installedAtMs = now - DAY_MS, nowMs = now)) + assertTrue(repository.shouldShowReviewPrompt(isPlaying = false, installedAtMs = now - 2 * DAY_MS, nowMs = now)) + } + + @Test + fun reviewPromptWaitsTwoDaysAfterLibraryImport() = runTest { + reachReviewMilestone() + val now = System.currentTimeMillis() + repository.markLibraryImported(nowMs = now - DAY_MS) + assertTrue(repository.isInReviewQuietPeriod(installedAtMs = INSTALLED_LONG_AGO, nowMs = now)) + assertFalse(repository.shouldShowReviewPrompt(isPlaying = false, installedAtMs = INSTALLED_LONG_AGO, nowMs = now)) + + val later = now + DAY_MS + assertFalse(repository.isInReviewQuietPeriod(installedAtMs = INSTALLED_LONG_AGO, nowMs = later)) + assertTrue(repository.shouldShowReviewPrompt(isPlaying = false, installedAtMs = INSTALLED_LONG_AGO, nowMs = later)) + } + + @Test + fun usedReviewMilestoneNeverComesBack() = runTest { + // Regression: the same milestone was stored again on the next Home load and re-asked 30 days later. + reachReviewMilestone(completedCount = 7) + repository.markReviewPromptShown() + assertNull(repository.reviewMilestonePending()) + + repository.syncReviewMilestonePending(7) + assertNull(repository.reviewMilestonePending()) + + repository.syncReviewMilestonePending(15) + assertEquals(15, repository.reviewMilestonePending()) + } + + private suspend fun reachReviewMilestone(completedCount: Int = 5) { + repository.markNpsSurveyFired() + repository.syncReviewMilestonePending(completedCount) + } + + @Test + fun markReviewedBlocksFuturePrompts() = runTest { + assertFalse(repository.reviewHasReviewed.first()) + repository.markReviewed() + assertTrue(repository.reviewHasReviewed.first()) + assertTrue(repository.hasReviewedSync()) + } + + @Test + fun reviewMilestonePendingTracksHighestReachedTier() = runTest { + assertNull(repository.reviewMilestonePending()) + + repository.syncReviewMilestonePending(3) + assertNull(repository.reviewMilestonePending()) + + repository.syncReviewMilestonePending(5) + assertEquals(5, repository.reviewMilestonePending()) + + repository.syncReviewMilestonePending(30) + assertEquals(30, repository.reviewMilestonePending()) + + // Lower milestone does not downgrade. + repository.syncReviewMilestonePending(15) + assertEquals(30, repository.reviewMilestonePending()) + + repository.clearReviewMilestonePending() + assertNull(repository.reviewMilestonePending()) + } + + @Test + fun reviewMilestoneNotStoredAfterReview() = runTest { + repository.markReviewed() + repository.syncReviewMilestonePending(30) + assertNull(repository.reviewMilestonePending()) + } +} + +private const val DAY_MS = 24L * 60 * 60 * 1000 +private const val INSTALLED_LONG_AGO = 0L diff --git a/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/UserPreferencesRepositoryTest.kt b/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/UserPreferencesRepositoryTest.kt index 77245d9e5..98f830d1f 100644 --- a/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/UserPreferencesRepositoryTest.kt +++ b/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/UserPreferencesRepositoryTest.kt @@ -524,54 +524,6 @@ class UserPreferencesRepositoryTest { assertEquals("2.5.0", repository.dismissedFeatureVersion.first()) } - // ---- Review logic ---- - - @Test - fun shouldShowReviewPromptFalseWhilePlaying() = runTest { - assertFalse(repository.shouldShowReviewPrompt(isPlaying = true)) - } - - @Test - fun shouldShowReviewPromptFalseWithoutPendingMilestone() = runTest { - assertFalse(repository.shouldShowReviewPrompt(isPlaying = false)) - } - - @Test - fun markReviewedBlocksFuturePrompts() = runTest { - assertFalse(repository.reviewHasReviewed.first()) - repository.markReviewed() - assertTrue(repository.reviewHasReviewed.first()) - assertTrue(repository.hasReviewedSync()) - } - - @Test - fun reviewMilestonePendingTracksHighestReachedTier() = runTest { - assertNull(repository.reviewMilestonePending()) - - repository.syncReviewMilestonePending(3) - assertNull(repository.reviewMilestonePending()) - - repository.syncReviewMilestonePending(5) - assertEquals(5, repository.reviewMilestonePending()) - - repository.syncReviewMilestonePending(30) - assertEquals(30, repository.reviewMilestonePending()) - - // Lower milestone does not downgrade. - repository.syncReviewMilestonePending(15) - assertEquals(30, repository.reviewMilestonePending()) - - repository.clearReviewMilestonePending() - assertNull(repository.reviewMilestonePending()) - } - - @Test - fun reviewMilestoneNotStoredAfterReview() = runTest { - repository.markReviewed() - repository.syncReviewMilestonePending(30) - assertNull(repository.reviewMilestonePending()) - } - @Test fun engagementCooldownElapsedByDefaultThenResets() = runTest { assertTrue(repository.isEngagementCooldownElapsed()) diff --git a/docs/ANALYTICS_EVENT_GLOSSARY.md b/docs/ANALYTICS_EVENT_GLOSSARY.md index e12c21f13..7fefa96b1 100644 --- a/docs/ANALYTICS_EVENT_GLOSSARY.md +++ b/docs/ANALYTICS_EVENT_GLOSSARY.md @@ -25,7 +25,7 @@ Launch enrichment (`first_seen_at`, `onboarding_status=pending`) goes on **perso ### entry_point -`home_hero_resume`, `home_hero_jump_back_in`, `home_hero_new_episodes`, `home_hero_spotlight`, `home_mixtape`, `home_adaptive_*`, `home_because_you_like`, `home_discover_grid`, `home_video_spotlight`, `home_recommendations`, `briefing`, `explore_for_you`, `explore_trending`, `explore_category`, `explore_search_shows`, `explore_search_episodes`, `learn`, `podcast_detail`, `episode_detail`, `queue`, `downloads`, `history`, `liked`, `notification`, `deep_link`, `share`, `install_referrer`, `android_auto`, `android_auto_continue`, `android_auto_queue`, `android_auto_new_episodes`, `android_auto_mixtape`, `android_auto_downloads`, `android_auto_liked`, `android_auto_history`, `android_auto_discover`, `android_auto_voice`, `android_auto_play_all`, `mini_player`, `session_restore`, `player_up_next`, `smart_queue`, `onboarding_suggestion`, `unknown` +`home_hero_resume`, `home_hero_jump_back_in`, `home_hero_new_episodes`, `home_hero_spotlight`, `home_mixtape`, `home_adaptive_*`, `home_because_you_like`, `home_discover_grid`, `home_video_spotlight`, `home_recommendations`, `briefing`, `explore_for_you`, `explore_trending`, `explore_category`, `explore_search_shows`, `explore_search_episodes`, `learn`, `podcast_detail`, `episode_detail`, `queue`, `queue_auto_advance`, `queue_skip`, `library_latest_episodes`, `downloads`, `history`, `liked`, `notification`, `deep_link`, `share`, `install_referrer`, `android_auto`, `android_auto_continue`, `android_auto_queue`, `android_auto_new_episodes`, `android_auto_mixtape`, `android_auto_downloads`, `android_auto_liked`, `android_auto_history`, `android_auto_discover`, `android_auto_voice`, `android_auto_play_all`, `mini_player`, `session_restore`, `player_up_next`, `smart_queue`, `onboarding_suggestion`, `unknown` Aliases (normalized in app; do not add as new enum values): `learn_history` → `learn`. Brief audio play tags `briefing` on `playback_*` (interactions remain on `daily_briefing_action`). @@ -58,7 +58,7 @@ Aliases (normalized in app; do not add as new enum values): `learn_history` → ### Episode identity -`episode_id` is always the episode's ID on this phone. Events that carry it may also carry `recipe_id`, boxlore's own ID for the same episode (a decimal string), when the app knows it. `home_surface_impression` may carry `recipe_ids` for episode surfaces (the known recipe IDs of `episode_ids`, in surface order). Join impressions, taps and plays across devices on `recipe_id`; it is absent for episodes without one (shows added by feed address, and episodes not yet matched). The recipe ID is the episode ID itself when that is a recipe-range ID, else the episode's own `recipeId` or the recipe ID of its trusted alias; it is never guessed. Events are never held back for it: an episode whose recipe ID is not in memory yet is looked up in the background, so the first event of a stored older ID can lack it while the tap and play after the impression carry it. +`episode_id` is always the episode's ID on this phone. Events that carry it may also carry `recipe_id`, boxlore's own ID for the same episode (a decimal string), when the app knows it. `home_surface_impression` and `explore_surface_impression` may carry `recipe_ids` for episode surfaces (the known recipe IDs of `episode_ids`, in surface order). Join impressions, taps and plays across devices on `recipe_id`; it is absent for episodes without one (shows added by feed address, and episodes not yet matched). The recipe ID is the episode ID itself when that is a recipe-range ID, else the episode's own `recipeId` or the recipe ID of its trusted alias; it is never guessed. Events are never held back for it: an episode whose recipe ID is not in memory yet is looked up in the background, so the first event of a stored older ID can lack it while the tap and play after the impression carry it. ### Feed reads and addresses @@ -78,7 +78,8 @@ Neither event carries an address. `show_id` is the Podcast Index ID or the hashe - playback_mode: `stream` | `download` | `unknown` (see Listen record) - client_surface: `phone` | `car` (`car` only when the car controls the listen) -- install_channel: `play_store` | `apk_github` | `share_referrer` | `unknown` +- install_channel: `organic` | `share` | `utm` | `unknown` (Play's `(not set)` referrer is `unknown`); set once on the person +- first_onboarding_method / first_user_intent / initial_podcasts_subscribed: set once at the first setup; `onboarding_method` and `user_intent` follow the latest route - search_mode: `show_keyword` | `episode_semantic` - mix_mode: `daily` | `offline` - pause_reason: `user_voluntary` | `interruption` | `error` | `sleep_timer` | `app_kill` | `auto_pause` @@ -99,8 +100,8 @@ Neither event carries an address. `show_id` is the Podcast Index ID or the hashe | `onboarding_abandoned` | User left before complete | last_step:string; flow_type:string | time_spent_seconds:int; subscribed_count:int | none | | `onboarding_completed` | User finished an onboarding path | method:string; total_subscribed_count:int | screen:string; subscribed_podcasts_list:list; total_onboarding_time_seconds:float; entry_point:string; favorite_genres:list; did_scroll_suggestions:bool; did_switch_from_ai:bool; import_type:string; searches_performed:int | none | | `onboarding_similar_choice` | After search or import, the listener chose See similar shows (`similar`), Go to Home (`home`) or Skip to Home while suggestions loaded (`skip_loading`) | path:string; choice:string; seed_count:int | — | none | -| `onboarding_ai_turn_submitted` | User sent a message in onboarding AI chat | turn_number:int; user_input_text:string; has_custom_input:bool | selected_options:list; time_spent_seconds:float | logged_by_policy | -| `onboarding_ai_response_received` | Assistant reply returned in onboarding AI | turn_number:int; options_count:int | options_list:list; duration_seconds:float; detected_intent:string; assistant_message:string | none | +| `onboarding_ai_turn_submitted` | User sent a message in onboarding AI chat (user_input_text capped at 500 characters) | turn_number:int; user_input_text:string; has_custom_input:bool | selected_options:list; time_spent_seconds:float | logged_by_policy | +| `onboarding_ai_response_received` | Assistant reply returned in onboarding AI | turn_number:int; options_count:int | options_list:list; duration_seconds:float; detected_intent:string | none | | `onboarding_ai_search_redirect` | AI suggested leaving chat for search | turn_number:int | suggested_query:string | logged_by_policy | | `onboarding_ai_synthesis_completed` | AI produced podcast suggestions | rows_count:int; podcasts_count:int | duration_seconds:float | none | | `onboarding_ai_synthesis_failed` | AI suggestion generation failed | — | error_message:string | none | @@ -123,7 +124,7 @@ Neither event carries an address. `show_id` is the Podcast Index ID or the hashe | `session_restore_prompt` | Resume previous session prompt shown/acted | action:string | episode_id:string; recipe_id:string; podcast_id:string; position_seconds:float | none | | `podcast_subscription_toggled` | User subscribed or unsubscribed | podcast_id:string; is_subscribed:bool | surface:string; source:string | none | | `notification_permission_requested` | System permission prompt requested | — | surface:string | none | -| `notification_permission_decided` | User granted/denied notifications | granted:bool | — | none | +| `notification_permission_decided` | User granted/denied notifications | granted:bool | surface:string | none | | `notification_tapped` | User opened a push/local notification | notification_type:string | podcast_id:string; episode_id:string; recipe_id:string; target_route:string | none | | `notification_received` | Push/local notification delivered | notification_type:string | podcast_id:string; episode_id:string; recipe_id:string | none | | `identity_reset` | Analytics identity cleared/reset | — | reason:string | none | @@ -137,8 +138,9 @@ Neither event carries an address. `show_id` is the Podcast Index ID or the hashe | `learn_card_action` | User acted on a Learn card | action:string; episode_id:string; podcast_id:string | recipe_id:string; position_index:int | none | | `learn_screen_viewed` | Learn tab/screen opened | — | cards_available:int | none | | `explore_screen_viewed` | Explore opened | — | tab:string | none | -| `explore_recommendation_tapped` | For You/trending card tapped | — | podcast_id:string; position_index:int; rail:string | none | -| `queue_modified` | Queue add/remove/reorder/clear | action:string | episode_id:string; recipe_id:string; podcast_id:string; queue_size:int; source:string | none | +| `explore_recommendation_tapped` | For You/trending card tapped (taps only) | — | podcast_id:string; position_index:int; rail:string | none | +| `explore_surface_impression` | Explore rail became visible | surface_component:string | items_count:int; episode_ids:list; recipe_ids:list | none | +| `queue_modified` | Queue add/remove/reorder/clear (clear only when the listener chose it) | action:string | episode_id:string; recipe_id:string; podcast_id:string; queue_size:int; source:string; items_count:int | none | | `episode_liked_toggled` | Like/unlike episode | episode_id:string; podcast_id:string; is_liked:bool | recipe_id:string; surface:string | none | | `episode_mark_played` | User marked episode played/unplayed | episode_id:string; podcast_id:string; is_played:bool | recipe_id:string; surface:string | none | | `download_requested` | User or Smart Downloads requested download | episode_id:string; podcast_id:string; source:string | recipe_id:string; wifi_only:bool | none | @@ -148,17 +150,20 @@ Neither event carries an address. `show_id` is the Podcast Index ID or the hashe | `show_notification_toggled` | Per-show notification preference changed | podcast_id:string; enabled:bool | — | none | | `share_content` | User shared podcast/episode/app | content_type:enum | podcast_id:string; episode_id:string; recipe_id:string; channel:string; surface:string | none | | `backup_restore_result` | Library backup or restore finished | action:string; success:bool | item_count:int; format:string; error_message:string | none | -| `feedback_submitted` | In-app feedback/NPS/review handoff | feedback_type:string | score:int; source:string | none | +| `feedback_submitted` | NPS survey trigger (eligible or manual); the survey shows on any of these | feedback_type:string | score:int; source:string | none | +| `engagement_prompt_shown` | A Home review prompt was shown | prompt_type:string; source:string | completed_episodes:int; nps_score:int | none | +| `store_review_opened` | Play review sheet or store page opened from boxlore | method:string; source:string | — | none | +| `feedback_sent` | In-app feedback form accepted by the server | category:string; with_email:bool; with_diagnostics:bool | — | none | | `library_destination_viewed` | Library hub or sub-destination opened | destination:string | item_count:int | none | | `podcast_detail_viewed` | Podcast info screen opened | podcast_id:string | is_subscribed:bool | none | | `episode_detail_viewed` | Episode info screen opened | episode_id:string; podcast_id:string | recipe_id:string | none | | `nav_tab_clicked` | Bottom nav tab selected | tab:string | previous_tab:string | none | -| `settings_interaction` | Settings screen view or control | action:string | setting_key:string | none | +| `settings_interaction` | Settings screen view or control; a control reports only when its value changes (`smart_downloads_*`, `auto_download_*`, `mini_player_seek_buttons_toggled`, `full_player_style_changed` among the keys) | action:string | setting_key:string; value:string | none | | `custom_theme_editor_opened` | Custom theme editor becomes visible | entry_point:string | — | none | | `custom_theme_saved` | Custom theme preferences saved successfully | background_style:string | — | none | | `home_hero_style_selected` | Home hero card style or its colors picked in Settings › Appearance › Home | hero_card_style:string; hero_colors:string; hero_style_combo:string; changed:string | previous_hero_style_combo:string | none | | `recommendation_section_toggled` | A LeaveMeAlone switch flipped, or the Brief hidden from its card | section:string; enabled:bool; source:string; sections_off:int | — | none | -| `feature_announcement_action` | In-app/feature announcement viewed/dismissed/acted | action:string | feature_id:string; category:string | none | +| `feature_announcement_action` | In-app/feature announcement viewed/dismissed/acted | action:string | feature_id:string; category:string; has_image:bool; has_action:bool; action_label:string; announcement_id:string; announcement_title:string; presentation:string | none | | `offline_mode_entered` | App detected offline / offline UI | — | reason:string | none | | `player_chrome_interaction` | Mini/full player, control bar, or playback-route action | surface:string; action:string | screen:string; volume_control_available:bool | none | | `daily_briefing_action` | Briefing impression or interaction | action:string; region:string; date:string | content_id:string; source:string; chapter_index:int; chapter_title:string; method:string; playback_status:string; previous_region:string; episode_id:string; recipe_id:string; episode_title:string; podcast_id:string; podcast_title:string | none | diff --git a/docs/analytics/event_glossary.csv b/docs/analytics/event_glossary.csv index 1ee6f66cf..2b9f9055f 100644 --- a/docs/analytics/event_glossary.csv +++ b/docs/analytics/event_glossary.csv @@ -10,7 +10,7 @@ onboarding_abandoned,Onboarding abandoned,User left before complete,A,Abandon st onboarding_completed,Onboarding completed,User finished an onboarding path,A,Activation; path mix; initial subs,method:string:Y;screen:string:N;total_subscribed_count:int:Y;subscribed_podcasts_list:list:N;total_onboarding_time_seconds:float:N;entry_point:string:N;favorite_genres:list:N;did_scroll_suggestions:bool:N;did_switch_from_ai:bool:N;import_type:string:N;searches_performed:int:N,onboarding_completed,activation;onboarding,activation_complete,none,method: ai_suggestions_done|manual_genre_flow|search_done|import|skip_welcome — skip is completion of skip path not abandon,always,0.41 onboarding_similar_choice,Onboarding similar choice,After search or import: see similar shows or go to Home,A,How many want suggestions after search or import; skips during load,path:string:Y;choice:string:Y;seed_count:int:Y,,activation;onboarding,activation_path,none,path: search|import; choice: similar|home|skip_loading,always,0.00 onboarding_ai_turn_submitted,AI chat turn submitted,User sent a message in onboarding AI chat,A,AI path quality; what users say,turn_number:int:Y;user_input_text:string:Y;has_custom_input:bool:Y;selected_options:list:N;time_spent_seconds:float:N,onboarding_ai_turn_submitted,activation;onboarding;ai,ai_turn,logged_by_policy,ALWAYS log raw user_input_text (rename from custom_input_text),always,0.47 -onboarding_ai_response_received,AI response received,Assistant reply returned in onboarding AI,A,AI path quality,turn_number:int:Y;options_count:int:Y;options_list:list:N;duration_seconds:float:N;detected_intent:string:N;assistant_message:string:N,onboarding_ai_response_received,activation;onboarding;ai,,none,assistant_message optional truncated,always,0.44 +onboarding_ai_response_received,AI response received,Assistant reply returned in onboarding AI,A,AI path quality,turn_number:int:Y;options_count:int:Y;options_list:list:N;duration_seconds:float:N;detected_intent:string:N,onboarding_ai_response_received,activation;onboarding;ai,,none,No reply text: it can repeat what the listener typed,always,0.44 onboarding_ai_search_redirect,AI search redirect,AI suggested leaving chat for search,A,Search redirect follow-through,turn_number:int:Y;suggested_query:string:N,onboarding_ai_search_redirect,activation;onboarding;ai,,logged_by_policy,suggested_query raw when present,always,0.02 onboarding_ai_synthesis_completed,AI synthesis completed,AI produced podcast suggestions,A,AI path → subscribe,rows_count:int:Y;podcasts_count:int:Y;duration_seconds:float:N,onboarding_ai_synthesis_completed,activation;onboarding;ai,,none,,always,0.09 onboarding_ai_synthesis_failed,AI synthesis failed,AI suggestion generation failed,A,AI path drop,error_message:string:N,onboarding_ai_synthesis_failed,activation;onboarding;ai,,none,No PII in errors,always,0.00 @@ -33,22 +33,23 @@ screen_left,Screen left,Leaving summary of a screen: time on it and what was don session_restore_prompt,Session restore prompt,Resume previous session prompt shown/acted,A,Session restore play vs dismiss,action:string:Y;episode_id:string:N;recipe_id:string:N;podcast_id:string:N;position_seconds:float:N,,playback;activation,,none,action: shown|play|dismiss,off,3.66 podcast_subscription_toggled,Subscription toggled,User subscribed or unsubscribed,A,Sub count; time-to-first-play after sub,podcast_id:string:Y;is_subscribed:bool:Y;surface:string:N;source:string:N,podcast_subscription_toggled,library;activation,subscribe,none,,always,0.97 notification_permission_requested,Notification permission requested,System permission prompt requested,A,Notification loop vs trust,surface:string:N,notification_permission_requested,notifications;activation,,none,,always,1.76 -notification_permission_decided,Notification permission decided,User granted/denied notifications,A,Notification enable rate,granted:bool:Y,notification_permission_decided,notifications;activation,,none,Also set person notifications_enabled,always,1.74 +notification_permission_decided,Notification permission decided,User granted/denied notifications,A,Notification enable rate,granted:bool:Y;surface:string:N,notification_permission_decided,notifications;activation,,none,Also set person notifications_enabled,always,1.74 notification_tapped,Notification tapped,User opened a push/local notification,A,Notif→play,notification_type:string:Y;podcast_id:string:N;episode_id:string:N;recipe_id:string:N;target_route:string:N,notification_tapped,notifications;growth,notif_open,none,,always,0.12 notification_received,Notification received,Push/local notification delivered,A,Notif delivery vs tap,notification_type:string:Y;podcast_id:string:N;episode_id:string:N;recipe_id:string:N,,notifications,,none,New; may be FCM-side,always,2.76 identity_reset,Identity reset,Analytics identity cleared/reset,A,Privacy; identity hygiene,reason:string:N,,company_health,,none,New explicit event,always,0.01 app_check_status,App Check status,App Check token attempt result (one per API session exchange that asks App Check),A,Integrity / abuse; which phones fail attestation and why,token_obtained:bool:Y;provider:string:Y;reason:string:N,app_check_status,company_health,,none,reason is a short failure category (play_integrity_; http_; too_many_attempts; network; other),always,17.21 api_session_status,API session status,API session exchange; silent renewal or Turnstile step-up result,A,Session adoption; attestation mix; limited sessions; renewal health; step-up pass rate,kind:enum:Y;success:bool:Y;trigger:enum:N;attestation:enum:N;limited:bool:N;duration_ms:int:N;app_check_attempted:bool:N;renew_outcome:enum:N;step_up_outcome:enum:N;failure_reason:string:N;server_reason:string:N,,company_health,,none,Categories only: never tokens; ids or keys. Renewals report once per token,always,0.82 first_episode_played,First episode played,First-ever play milestone,A,Activation; time-to-first-play,episode_id:string:N;recipe_id:string:N;podcast_id:string:N;entry_point:enum:N;hours_since_install:float:N,first_episode_played,activation;company_health,first_play,none,$set_once first_play_at,always,0.27 -home_surface_tapped,Home surface tapped,First-class Home component tap,B,Home first tap; surface→play,surface_component:string:Y;rail_intent:string:N;content_id:string:N;position_index:int:N;items_count:int:N;mix_mode:string:N;podcast_id:string:N;podcast_name:string:N;episode_id:string:N;recipe_id:string:N;episode_title:string:N;click_target:string:N,home_recommendation_card_tapped;home_hero_carousel_swiped;home_podcast_filtered;play_mix_clicked;daily_briefing_banner_tapped;daily_briefing_play_clicked;curated_card_tapped;home_import_banner_clicked;video_spotlight_tapped;video_spotlight_play_initiated,discovery;home,discovery_tap,none,Consolidates fragmented home_* taps; mix_mode is daily|offline,always,7.83 +home_surface_tapped,Home surface tapped,First-class Home component tap,B,Home first tap; surface→play,surface_component:string:Y;rail_intent:string:N;content_id:string:N;position_index:int:N;items_count:int:N;mix_mode:string:N;podcast_id:string:N;podcast_name:string:N;episode_id:string:N;recipe_id:string:N;episode_title:string:N;click_target:string:N,home_recommendation_card_tapped;home_hero_carousel_swiped;home_podcast_filtered;play_mix_clicked;daily_briefing_banner_tapped;daily_briefing_play_clicked;curated_card_tapped;home_import_banner_clicked;video_spotlight_tapped;video_spotlight_play_initiated;discover_category_filtered,discovery;home,discovery_tap,none,Consolidates fragmented home_* taps; mix_mode is daily|offline,always,7.89 home_surface_impression,Home surface impression,Home rail/block became visible,B,Home reach,surface_component:string:Y;items_count:int:N;podcast_ids:list:N;recipe_ids:list:N,home_recommendations_impression;curated_block_impression;daily_briefing_card_impression;home_import_banner_impression;video_spotlight_impression,discovery;home,,none,,always,10.92 search_performed,Search performed,User ran show or episode search,A/B,Query quality; zero results; Explore vs onboarding,surface:string:Y;search_mode:enum:Y;search_query:string:Y;results_count:int:Y;result_quality:enum:N;query_length:int:N,explore_search_performed;onboarding_search_performed,discovery;search,search,logged_by_policy,ALWAYS log raw search_query,always,5.80 search_result_tapped,Search result tapped,User tapped a search result,B,Search→play/subscribe,surface:string:Y;search_mode:enum:N;result_type:enum:Y;podcast_id:string:N;episode_id:string:N;recipe_id:string:N;position_index:int:N;search_query:string:N,,discovery;search,,logged_by_policy,search_query optional echo,always,0.09 learn_card_action,Learn card action,User acted on a Learn card,B,Learn session→play/queue; dismiss vs play,action:string:Y;episode_id:string:Y;recipe_id:string:N;podcast_id:string:Y;position_index:int:N,learn_card_dismissed;learn_card_queued;learn_card_info_clicked;learn_card_play_clicked;learn_card_podcast_clicked,discovery;learn,,none,action: play|queue|info|dismiss|podcast,always,0.93 learn_screen_viewed,Learn screen viewed,Learn tab/screen opened,B,Learn reach,cards_available:int:N,learn_screen_viewed;learn_screen_session,discovery;learn,,none,Session props optional; visits only; the leaving summary is screen_left,always,0.92 explore_screen_viewed,Explore screen viewed,Explore opened,B,Explore reach,tab:string:N,explore_screen_viewed;explore_screen_session,discovery;explore,,none,visits only; the leaving summary is screen_left,always,4.74 -explore_recommendation_tapped,Explore recommendation tapped,For You/trending card tapped,B,Explore→play,podcast_id:string:N;position_index:int:N;rail:string:N,explore_recommendation_card_tapped;explore_recommendations_impression;discover_category_filtered,discovery;explore,,none,,always,4.14 -queue_modified,Queue modified,Queue add/remove/reorder/clear,B,Intentional queue use,action:string:Y;episode_id:string:N;recipe_id:string:N;podcast_id:string:N;queue_size:int:N;source:string:N,queue_reordered;smart_queue_episode_skipped;smart_queue_refilled;lore_queue_conflict_shown;lore_queue_conflict_result,library;queue,,none,action: add|remove|reorder|clear|skip|refill|conflict_*,always,8.70 +explore_recommendation_tapped,Explore recommendation tapped,For You/trending card tapped,B,Explore→play,podcast_id:string:N;position_index:int:N;rail:string:N,explore_recommendation_card_tapped,discovery;explore,,none,Taps only; the rail being shown is explore_surface_impression,always,0.15 +explore_surface_impression,Explore surface impression,Explore rail became visible,B,Explore reach; impression to tap,surface_component:string:Y;items_count:int:N;episode_ids:list:N;recipe_ids:list:N,explore_recommendations_impression,discovery;explore,,none,Split from explore_recommendation_tapped (94% of its rows were impressions),always,3.93 +queue_modified,Queue modified,Queue add/remove/reorder/clear,B,Intentional queue use,action:string:Y;episode_id:string:N;recipe_id:string:N;podcast_id:string:N;queue_size:int:N;source:string:N;items_count:int:N,queue_reordered;smart_queue_episode_skipped;smart_queue_refilled;lore_queue_conflict_shown;lore_queue_conflict_result,library;queue,,none,action: add|remove|reorder|clear|skip|refill|conflict_*,always,8.70 episode_liked_toggled,Episode liked toggled,Like/unlike episode,B,Liked replay,episode_id:string:Y;recipe_id:string:N;podcast_id:string:Y;is_liked:bool:Y;surface:string:N,,library,,none,New consolidated,always,0.22 episode_mark_played,Episode mark played,User marked episode played/unplayed,B,Library hygiene,episode_id:string:Y;recipe_id:string:N;podcast_id:string:Y;is_played:bool:Y;surface:string:N,,library,,none,New; was action-only in session aggregator,always,0.49 download_requested,Download requested,User or Smart Downloads requested download,B,Downloads as habit,episode_id:string:Y;recipe_id:string:N;podcast_id:string:Y;source:string:Y;wifi_only:bool:N,,downloads,download_start,none,source: manual|smart|auto_show,always,3.80 @@ -58,13 +59,16 @@ smart_download_sync,Smart download sync,Smart Downloads sync ran,B,Smart Downloa show_notification_toggled,Show notification toggled,Per-show notification preference changed,B,Notif % among subs,podcast_id:string:Y;enabled:bool:Y,,notifications;library,,none,New,always,0.09 share_content,Share content,User shared podcast/episode/app,B,Share/deeplink growth,content_type:enum:Y;podcast_id:string:N;episode_id:string:N;recipe_id:string:N;channel:string:N;surface:string:N,,growth;share,share,none,New consolidated share_*,always,0.02 backup_restore_result,Backup/restore result,Library backup or restore finished,B,Import/migrate retention,action:string:Y;success:bool:Y;item_count:int:N;format:string:N;error_message:string:N,,library,,none,action: backup|restore|opml_export|opml_import,always,0.08 -feedback_submitted,Feedback submitted,In-app feedback/NPS/review handoff,B,Promoters; delight,feedback_type:string:Y;score:int:N;source:string:N,survey_nps_eligible;survey_nps_manual_trigger;engagement_prompt_shown;promoter_review_handoff;feedback_submitted,company_health;delight,,none,Consolidates NPS/engagement family where possible,always,0.31 +feedback_submitted,Feedback submitted,NPS survey trigger (eligible or manual),B,Promoters; delight,feedback_type:string:Y;score:int:N;source:string:N,survey_nps_eligible;survey_nps_manual_trigger;promoter_review_handoff;feedback_submitted,company_health;delight,,none,Only nps_eligible and nps_manual: the NPS survey shows on any feedback_submitted,always,0.05 +engagement_prompt_shown,Engagement prompt shown,A Home review prompt was shown,B,Review funnel reach,prompt_type:string:Y;source:string:Y;completed_episodes:int:N;nps_score:int:N,,company_health;delight,,none,promoter_review or milestone_review; completed_episodes counts episodes finished on this install,always,0.05 +store_review_opened,Store review opened,Play review sheet or store page opened from boxlore,B,Review funnel,method:string:Y;source:string:Y,,company_health;delight,,none,method in_app_review or store_page,always,0.02 +feedback_sent,Feedback sent,In-app feedback form accepted by the server,B,Feedback volume,category:string:Y;with_email:bool:Y;with_diagnostics:bool:Y,,company_health;delight,,none,Never the message text or email,always,0.02 library_destination_viewed,Library destination viewed,Library hub or sub-destination opened,B,Library destinations that drive listening,destination:string:Y;item_count:int:N,library_hub_viewed;library_hub_session;library_subscriptions_viewed;library_subscriptions_session;library_liked_viewed;library_liked_session;library_downloads_viewed;library_downloads_session;library_history_viewed;library_history_session;library_subscriptions_sort_changed;library_subscriptions_layout_toggled;library_subscriptions_genre_filtered,library,,none,destination: hub|subscriptions|liked|downloads|history; visits only; the leaving summary is screen_left,always,9.63 podcast_detail_viewed,Podcast detail viewed,Podcast info screen opened,B,Visit→subscribe/play,podcast_id:string:Y;is_subscribed:bool:N,podcast_info_screen_viewed;podcast_info_screen_session,discovery;detail,,none,visits only; the leaving summary is screen_left,always,6.71 episode_detail_viewed,Episode detail viewed,Episode info screen opened,B,Episode action mix,episode_id:string:Y;recipe_id:string:N;podcast_id:string:Y,episode_info_screen_viewed;episode_info_screen_session,discovery;detail,,none,visits only; the leaving summary is screen_left,always,4.30 nav_tab_clicked,Nav tab clicked,Bottom nav tab selected,B,Tab bounce without play,tab:string:Y;previous_tab:string:N,nav_tab_clicked,discovery,,none,,always,8.37 -settings_interaction,Settings interaction,Settings screen view or control,B,Settings discovery,action:string:Y;setting_key:string:N,settings_screen_viewed;settings_interaction,settings,,none,,always,6.13 -feature_announcement_action,Feature announcement action,In-app/feature announcement viewed/dismissed/acted,B,Announcement efficacy,action:string:Y;feature_id:string:N;category:string:N,feature_announcement_viewed;feature_announcement_dismissed;in_app_announcement_viewed;in_app_announcement_dismissed;in_app_announcement_action,delight,,none,,always,1.51 +settings_interaction,Settings interaction,Settings screen view or control,B,Settings discovery,action:string:Y;setting_key:string:N;value:string:N,settings_screen_viewed;settings_interaction,settings,,none,Controls report only when the value changes; per-control keys include smart_downloads_* and auto_download_* and the Player appearance keys,always,6.13 +feature_announcement_action,Feature announcement action,In-app/feature announcement viewed/dismissed/acted,B,Announcement efficacy,action:string:Y;feature_id:string:N;category:string:N;has_image:bool:N;has_action:bool:N;action_label:string:N;announcement_id:string:N;announcement_title:string:N;presentation:string:N,feature_announcement_viewed;feature_announcement_dismissed;in_app_announcement_viewed;in_app_announcement_dismissed;in_app_announcement_action,delight,,none,,always,1.51 offline_mode_entered,Offline mode entered,App detected offline / offline UI,B,Offline vs stream,reason:string:N,offline_mode_entered,downloads;playback,,none,,always,4.72 player_chrome_interaction,Player chrome interaction,Mini/full player or control bar action,B,Player UX,surface:string:Y;action:string:Y,mini_player_interaction;top_controlbar_interaction;full_player_screen_session,playback,,none,,always,9.72 daily_briefing_action,Daily briefing action,Briefing impression or interaction,B,Briefing→play,action:string:Y;region:string:Y;date:string:Y;content_id:string:N;source:string:N;chapter_index:int:N;chapter_title:string:N;method:string:N;playback_status:string:N;previous_region:string:N;episode_id:string:N;recipe_id:string:N;episode_title:string:N;podcast_id:string:N;podcast_title:string:N,daily_briefing_screen_viewed;daily_briefing_interaction;daily_briefing_pause_clicked;daily_briefing_region_changed;daily_briefing_related_episode_clicked;daily_briefing_card_impression;daily_briefing_banner_tapped;daily_briefing_play_clicked,discovery;briefing,,none,home_surface_tapped also covers primary play CTA,always,4.29 diff --git a/docs/analytics/glossary_emission_coverage.csv b/docs/analytics/glossary_emission_coverage.csv index 17554af19..e4baa7c41 100644 --- a/docs/analytics/glossary_emission_coverage.csv +++ b/docs/analytics/glossary_emission_coverage.csv @@ -48,6 +48,7 @@ learn_card_action,emission:GlossaryAllEventsEmissionTest,GlossaryAllEventsEmissi learn_screen_viewed,emission:GlossaryAllEventsEmissionTest,GlossaryAllEventsEmissionTest explore_screen_viewed,emission:GlossaryAllEventsEmissionTest,GlossaryAllEventsEmissionTest explore_recommendation_tapped,emission:GlossaryAllEventsEmissionTest,GlossaryAllEventsEmissionTest +explore_surface_impression,emission:GlossaryAllEventsEmissionTest,GlossaryAllEventsEmissionTest queue_modified,emission:GlossaryAllEventsEmissionTest,GlossaryAllEventsEmissionTest episode_liked_toggled,emission:GlossaryAllEventsEmissionTest,GlossaryAllEventsEmissionTest episode_mark_played,emission:GlossaryAllEventsEmissionTest,GlossaryAllEventsEmissionTest @@ -59,6 +60,9 @@ show_notification_toggled,emission:GlossaryAllEventsEmissionTest,GlossaryAllEven share_content,emission:GlossaryAllEventsEmissionTest,GlossaryAllEventsEmissionTest backup_restore_result,emission:GlossaryAllEventsEmissionTest,GlossaryAllEventsEmissionTest feedback_submitted,emission:GlossaryAllEventsEmissionTest,GlossaryAllEventsEmissionTest +engagement_prompt_shown,emission:GlossaryAllEventsEmissionTest,GlossaryAllEventsEmissionTest +store_review_opened,emission:GlossaryAllEventsEmissionTest,GlossaryAllEventsEmissionTest +feedback_sent,emission:GlossaryAllEventsEmissionTest,GlossaryAllEventsEmissionTest library_destination_viewed,emission:GlossaryAllEventsEmissionTest,GlossaryAllEventsEmissionTest podcast_detail_viewed,emission:GlossaryAllEventsEmissionTest,GlossaryAllEventsEmissionTest episode_detail_viewed,emission:GlossaryAllEventsEmissionTest,GlossaryAllEventsEmissionTest diff --git a/feature/explore/README.md b/feature/explore/README.md index 7149834f0..2f75343be 100644 --- a/feature/explore/README.md +++ b/feature/explore/README.md @@ -76,6 +76,7 @@ src/main/java/cx/aswin/boxlore/feature/explore/ ## Testing notes +- The For You rail being shown sends `explore_surface_impression`; `explore_recommendation_tapped` is taps only (it used to carry the impressions too, 94% of its rows). - `LoreTabletPaneSizeTest` covers pane sizing, missing-pane fallback, and width/height threshold transitions. Lore retains configuration-based phone geometry while tablet spacing follows the current shell-owned pane. - `ExploreGridResizeTest` scrolls the production search and topic grids before live compact/tablet width and height transitions, including changing tablet column counts, then verifies the same result remains visible and its original tap rank is retained. Every poster stays an independently keyed lazy item so the saved scroll anchor does not turn into a row index when resizing. diff --git a/feature/home/README.md b/feature/home/README.md index f880b21f1..d9fe34ac4 100644 --- a/feature/home/README.md +++ b/feature/home/README.md @@ -124,6 +124,7 @@ Main Kotlin files should remain below 1000 lines; extracted Home feed, ViewModel ## Testing notes +- Review prompt: Home counts only episodes played to the end in boxlore since this install (`countFinishedSince` on `HomeListeningHistoryItem.finishedAt`, with `HomeViewModel.installedAtMs` from Android's install time), so marked-played episodes and a restore or sync of older listening never trigger it, and the sheet's "You've finished N episodes" uses the same count. A prompt is recorded when it shows (`markReviewPromptShown`), so closing boxlore mid-prompt can't bring it back, and it sends `engagement_prompt_shown`, not the NPS survey's trigger. The promoter hand-off also waits out the 2 quiet days after installing or importing. The category chips send `home_surface_tapped` (`category_filter`). `HomeListeningHistoryItemTest` covers the count. - `HomeRecommendationsApplyTest` covers `applyHomeRecommendations`: a failed request keeps the restored picks and saves nothing; fresh picks replace them once per episode ID and title and are saved with their fallback flag; an empty answer that did not fail clears them. - `HomeSerialLogicTest` covers serial progress IDs and picks, and an oldest-first show switched to its feed continuing after the episode its old show was playing, found under the ID the show lists. `HomeSelectedPodcastLogicTest` checks such a show's last play, and `HomeCatchUpLogicTest` that its latest episode played under the old ID reads as played. - Recommendation entry points share an immutable columns/tablet layout value. Selected-show episode rendering has focused private playback, action and layout inputs; the parent crossfade retains each show's presentation and scroll owner. `HomeDiscoveryFeedSpacingTest` checks recommendation caps, order and episode/browse attribution while switching between the phone grid and tablet rail. `HomeConnectedRailTest` also checks selected-show playback, episode and browse callbacks across sidebar resizing, loading/empty-to-content recovery, and a 200-show library keeping a bounded lazy sidebar where every show stays reachable. `HomeArtworkHandoffTest` also checks that hidden tablet artwork waits, phone round trips keep the tablet target, and artwork sized only by its own content loads. diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeListeningHistoryItem.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeListeningHistoryItem.kt index a1dce065b..075ad4c5b 100644 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeListeningHistoryItem.kt +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeListeningHistoryItem.kt @@ -12,8 +12,17 @@ data class HomeListeningHistoryItem( val isCompleted: Boolean, val isLiked: Boolean, val lastPlayedAt: Long, + /** When it was played to the end; null when only marked played or not finished. */ + val finishedAt: Long? = null, ) +/** + * Episodes played to the end in boxlore since it was installed on this device. Marked-played rows + * and rows restored or synced from before the install don't count. + */ +internal fun List.countFinishedSince(installedAtMs: Long): Int = + count { it.isCompleted && it.finishedAt?.let { at -> at >= installedAtMs } == true } + internal fun ListeningHistoryEntity.toHomeListeningHistoryItem(): HomeListeningHistoryItem = HomeListeningHistoryItem( episodeId = episodeId, podcastId = podcastId, @@ -24,4 +33,5 @@ internal fun ListeningHistoryEntity.toHomeListeningHistoryItem(): HomeListeningH isCompleted = isCompleted, isLiked = isLiked, lastPlayedAt = lastPlayedAt, + finishedAt = finishedAt, ) diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeScreen.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeScreen.kt index 82c217260..25d566d4d 100644 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeScreen.kt +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeScreen.kt @@ -351,10 +351,7 @@ fun HomeRoute( onNavigateToSettings = onNavigateToSettings, onForceReviewPrompt = viewModel::forceReviewPrompt, onForceSurveyNps = viewModel::forceSurveyNps, - onDismissReviewPrompt = { - viewModel.markReviewPromptShown() - viewModel.dismissReviewPrompt() - }, + onDismissReviewPrompt = viewModel::dismissReviewPrompt, onDismissPostReview = viewModel::dismissPostReview, onDismissFeedback = viewModel::dismissFeedback, onNavigateToPlayStoreReview = { @@ -531,6 +528,8 @@ private fun HomeScreenBottomSheets( onSubmit = callbacks.onSubmitFeedback, onRateInstead = { callbacks.onDismissFeedback() + cx.aswin.boxlore.core.analytics.AnalyticsHelper + .trackStoreReviewOpened(method = "store_page", source = "home_feedback") val pkgName = context.packageName try { context.startActivity( diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModel.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModel.kt index 5802b9d67..897049415 100644 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModel.kt +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModel.kt @@ -177,6 +177,12 @@ class HomeViewModel( ) internal val userPrefs = userPreferencesRepository internal val boxcastPrefs = BoxcastPrefs(application) + + /** When boxlore was installed on this device; a backup restore doesn't carry it over. 0 if unknown. */ + internal val installedAtMs: Long by lazy { + runCatching { application.packageManager.getPackageInfo(application.packageName, 0).firstInstallTime } + .getOrDefault(0L) + } private val _featuredVideoPodcasts = MutableStateFlow(featuredVideoPodcasts()) val featuredVideoPodcasts: StateFlow> = _featuredVideoPodcasts.asStateFlow() private val _featuredVideoShowcaseDismissed = @@ -616,13 +622,6 @@ class HomeViewModel( _showPostReview.value = true } - fun markReviewPromptShown() { - viewModelScope.launch { - userPrefs.markReviewPromptShown() - _showReviewPrompt.value = false - } - } - fun markReviewed() { viewModelScope.launch { userPrefs.markReviewed() diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModelLoadData.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModelLoadData.kt index 6c19ce694..df1ba6a28 100644 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModelLoadData.kt +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModelLoadData.kt @@ -209,31 +209,31 @@ internal fun HomeViewModel.loadData() { val resolvedSerial = wrapper.resolvedSerial val completedEpisodeIds = wrapper.completedEpisodeIds - // Compute completed count for review prompt logic - val completedCount = allHistory.count { it.isCompleted } + // Prompts count only episodes finished here, so a restore or import can't trigger them. + val completedCount = allHistory.countFinishedSince(installedAtMs) // Check if review prompt should be shown (promoter handoff or milestone-based) if (!_showReviewPrompt.value && !_showFeedback.value && !_showPostReview.value) { val isPlayingNow = playerState.value.isPlaying - if (engagementCoordinator.shouldShowPromoterReview(isPlayingNow)) { + if (engagementCoordinator.shouldShowPromoterReview(isPlayingNow, installedAtMs)) { engagementCoordinator.recordProactivePromptShown() engagementCoordinator.clearPromoterReviewPending() - val npsScore = userPrefs.npsLastScore() - cx.aswin.boxlore.core.analytics.AnalyticsHelper.trackPromoterReviewHandoff( - npsScore, - ) + // Counted when shown, so closing the app mid-prompt can't bring it back. + userPrefs.markReviewPromptShown() cx.aswin.boxlore.core.analytics.AnalyticsHelper.trackEngagementPromptShown( promptType = "promoter_review", source = "nps_handoff", + npsScore = userPrefs.npsLastScore(), ) _reviewPromptVariant.value = cx.aswin.boxlore.feature.home.components.ReviewPromptVariant.PromoterHandoff _showReviewPrompt.value = true } else if (engagementCoordinator.canShowProactivePrompt(isPlayingNow)) { userPrefs.syncReviewMilestonePending(completedCount) - val shouldPrompt = userPrefs.shouldShowReviewPrompt(isPlayingNow) + val shouldPrompt = userPrefs.shouldShowReviewPrompt(isPlayingNow, installedAtMs) if (shouldPrompt) { engagementCoordinator.recordProactivePromptShown() + userPrefs.markReviewPromptShown() cx.aswin.boxlore.core.analytics.AnalyticsHelper.trackEngagementPromptShown( promptType = "milestone_review", source = "episode_milestone", @@ -274,6 +274,7 @@ internal fun HomeViewModel.loadData() { subs = subs, allHistory = allHistory, episodeAliases = wrapper.episodeAliases, + installedAtMs = installedAtMs, resolvedSerial = resolvedSerial, completedEpisodeIds = completedEpisodeIds, region = region, diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/ReviewSheet.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/ReviewSheet.kt index d825a0c2f..5e49bae9f 100644 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/ReviewSheet.kt +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/ReviewSheet.kt @@ -277,6 +277,8 @@ fun PostReviewSheet( OutlinedButton( onClick = { onDismissRequest() + cx.aswin.boxlore.core.analytics.AnalyticsHelper + .trackStoreReviewOpened(method = "store_page", source = "post_review") val pkgName = context.packageName try { context.startActivity( diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/logic/HomeUiAssemblyLogic.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/logic/HomeUiAssemblyLogic.kt index 9335c7ba2..9db36e819 100644 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/logic/HomeUiAssemblyLogic.kt +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/logic/HomeUiAssemblyLogic.kt @@ -10,6 +10,7 @@ import cx.aswin.boxlore.core.playback.PlaybackSkipPolicy import cx.aswin.boxlore.feature.home.HomeEditorialRow import cx.aswin.boxlore.feature.home.HomeListeningHistoryItem import cx.aswin.boxlore.feature.home.SmartHeroItem +import cx.aswin.boxlore.feature.home.countFinishedSince internal data class HomeMixtapeCache( val podcasts: List, @@ -107,8 +108,10 @@ internal object HomeUiAssemblyLogic { pinnedPodcastIds: List = emptyList(), refreshShowsOrderFromScores: Boolean = false, episodeAliases: Map> = emptyMap(), + installedAtMs: Long = 0L, ): HomeUiAssemblyResult { - val completedCount = allHistory.count { it.isCompleted } + // The review prompt's count: the same one that decides when it shows. + val completedCount = allHistory.countFinishedSince(installedAtMs) // Looked up by the IDs the shows list: an episode stored under several IDs under each of them. val historyByEveryId = HomeCatchUpLogic.underEveryId(allHistory, episodeAliases) val catchUp = diff --git a/feature/home/src/test/java/cx/aswin/boxlore/feature/home/HomeListeningHistoryItemTest.kt b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/HomeListeningHistoryItemTest.kt index 47227ee11..50885d557 100644 --- a/feature/home/src/test/java/cx/aswin/boxlore/feature/home/HomeListeningHistoryItemTest.kt +++ b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/HomeListeningHistoryItemTest.kt @@ -28,6 +28,7 @@ class HomeListeningHistoryItemTest { isManualCompletion = true, isBulkCompletion = false, episodeDescription = "Ignored description", + finishedAt = 120_000_000L, ) val item = entity.toHomeListeningHistoryItem() @@ -41,5 +42,27 @@ class HomeListeningHistoryItemTest { assertEquals(true, item.isCompleted) assertEquals(true, item.isLiked) assertEquals(123_456_789L, item.lastPlayedAt) + assertEquals(120_000_000L, item.finishedAt) } + + @Test + fun `counts only episodes finished in boxlore since install`() { + val installedAt = 1_000L + val history = + listOf( + item("finished-here", isCompleted = true, finishedAt = 1_500L), + item("finished-at-install", isCompleted = true, finishedAt = 1_000L), + // Restored or synced from before this install. + item("restored", isCompleted = true, finishedAt = 900L), + // Marked played: no finish time. + item("marked-played", isCompleted = true, finishedAt = null), + // Replayed after finishing, so no longer completed. + item("restarted", isCompleted = false, finishedAt = 1_600L), + ) + + assertEquals(2, history.countFinishedSince(installedAt)) + } + + private fun item(id: String, isCompleted: Boolean, finishedAt: Long?) = + HomeListeningHistoryItem(id, "show", "Show", null, 0L, 1L, isCompleted, false, 2_000L, finishedAt) } diff --git a/feature/info/README.md b/feature/info/README.md index 5f185aee9..da30e4b1a 100644 --- a/feature/info/README.md +++ b/feature/info/README.md @@ -124,6 +124,7 @@ The spotlight shows the newest full episode while the list is newest first and s ## Testing notes +- The show bell asks for notifications with `surface` `show_bell` on both permission events; batch-adding selected episodes to the queue sends one `queue_modified` add with `items_count`, not one per episode. - `EpisodeInfoAdaptiveTransitionTest` drives one live episode page through "Finding this episode", "Episode not found" (with and without its show) and a later loaded episode across 599↔600, 479↔480, 839↔840 landscape, portrait↔landscape and flat↔tabletop fold both ways, plus `StateRestorationTester` recreation, and checks View show and Back run once and that window changes never load the page again; it also opens the page by a link ID that resolves to a downloaded row under the phone's own ID and checks the download shows across the same window changes. `EpisodeLoadRequestsTest` covers which loads are skipped, which unresolved states are not found or the load error, the page's episode, and the show a share fills in. - Tablet detail content is centered in the shared Reading width (720dp); the app palette still paints the full viewport. Poster rails keep fixed card sizes and Podroll rail items use the shared RailItem cap. Phone modifiers return unchanged. diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoEpisodeActions.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoEpisodeActions.kt index 57d3bb197..97ed5b061 100644 --- a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoEpisodeActions.kt +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoEpisodeActions.kt @@ -250,20 +250,19 @@ internal class PodcastInfoEpisodeActions( fun addEpisodesToQueue(episodes: List) { val currentState = uiState.value as? PodcastInfoUiState.Success ?: return scope.launch { - episodes.distinctBy(Episode::id).forEach { episode -> - val added = - playbackRepository.addToQueue( - episode = episode, - podcast = currentState.podcast, - ) - if (added) { - AnalyticsHelper.trackQueueModified( - action = "add", - episodeId = episode.id, - podcastId = currentState.podcast.id, - queueSize = playbackRepository.playerState.value.queue.size, - ) + val added = + episodes.distinctBy(Episode::id).count { episode -> + playbackRepository.addToQueue(episode = episode, podcast = currentState.podcast) } + // One event for the whole selection, not one per episode. + if (added > 0) { + AnalyticsHelper.trackQueueModified( + action = "add", + podcastId = currentState.podcast.id, + queueSize = playbackRepository.playerState.value.queue.size, + source = "episode_selection", + itemsCount = added, + ) } } } diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoScreenParts.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoScreenParts.kt index d3cb5144e..2552d7268 100644 --- a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoScreenParts.kt +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoScreenParts.kt @@ -126,7 +126,14 @@ internal class ShowNotificationPermission(blocked: Boolean) { /** Asks for Android 13's notification permission. */ fun request() { - launcher?.launch(Manifest.permission.POST_NOTIFICATIONS) + val launcher = launcher ?: return + cx.aswin.boxlore.core.analytics.AnalyticsHelper.trackNotificationPermissionRequested(SURFACE) + launcher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + + internal companion object { + /** `surface` on the permission events: a show's bell. */ + const val SURFACE = "show_bell" } internal fun runPending() { @@ -165,6 +172,7 @@ internal fun rememberShowNotificationPermission( } permission.launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> BoxcastPrefs(context).setHasRequestedNotificationPermission(true) + cx.aswin.boxlore.core.analytics.AnalyticsHelper.trackNotificationPermissionDecided(isGranted, ShowNotificationPermission.SURFACE) permission.systemBlocked = !isGranted if (isGranted) { allowed(true) diff --git a/feature/onboarding/README.md b/feature/onboarding/README.md index 127991d8d..c5f135fb1 100644 --- a/feature/onboarding/README.md +++ b/feature/onboarding/README.md @@ -120,6 +120,7 @@ src/main/java/cx/aswin/boxlore/feature/onboarding/ ## Testing notes +- `onboarding_ai_response_received` no longer carries the reply's text (it can repeat what the listener typed); the turn event's typed text is capped at 500 characters in `:core:analytics`. - `EchoRequestLifecycleTest` covers which API refusals send a failed Echo turn to the topic picker (`verification_required`, `daily_ai_limit`) and which keep Try again. No onboarding ViewModel harness exists, so the switch itself is verified by source inspection. - Tablet search respects IME insets, and suggestion forms remain reachable in short contiguous regions. Existing onboarding steps, state owners and phone composition remain unchanged. diff --git a/feature/onboarding/src/main/java/cx/aswin/boxlore/feature/onboarding/OnboardingViewModelAi.kt b/feature/onboarding/src/main/java/cx/aswin/boxlore/feature/onboarding/OnboardingViewModelAi.kt index c9ce70896..9e653dbf3 100644 --- a/feature/onboarding/src/main/java/cx/aswin/boxlore/feature/onboarding/OnboardingViewModelAi.kt +++ b/feature/onboarding/src/main/java/cx/aswin/boxlore/feature/onboarding/OnboardingViewModelAi.kt @@ -169,7 +169,6 @@ internal fun OnboardingViewModel.sendAiTurnInput() { } AnalyticsHelper.trackOnboardingAiResponseReceived( turnNumber = currentState.aiCurrentTurn, - assistantMessage = body.assistantMessage, optionsCount = body.options.size, optionsList = body.options, durationSeconds = durationSec, diff --git a/feature/settings/README.md b/feature/settings/README.md index 863c5ac06..e18bbcfc0 100644 --- a/feature/settings/README.md +++ b/feature/settings/README.md @@ -158,6 +158,7 @@ src/main/java/cx/aswin/boxlore/feature/settings/ ## Testing notes +- Settings analytics: a control sends `settings_interaction` only when its value changes (`trackedForAnalytics` reads the current `AppearanceUiState`; `trackDownloadSetting` for Smart Downloads and automatic downloads, which now report each control as `smart_downloads_*` and `auto_download_*`; the playback lengths compare with the saved value). Player appearance reports `mini_player_seek_buttons_toggled` and `full_player_style_changed`. A sent feedback form sends `feedback_sent` (category, with email, with diagnostics; never the text), and the Feedback screen's rate button sends `store_review_opened`. `AppearanceActionsTrackedTest` and `AutoDownloadSettingsActionsTest` cover re-picks reporting nothing. - Tablet top bars align with their bounded content column and keep a flat surface. Footer clearance follows actual mini-player visibility. RSS match confirmation remains scrollable in short viewports; the Support page fits without scrolling instead (see Support worlds). Phone spacing remains unchanged. - Tablet settings use the shared Utility cap (720dp), including Smart Downloads, Feedback and Support outside the ordinary settings scaffold. Existing account/editor caps remain smaller or bound each editor column. Phone constraints are unchanged. diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsScreen.kt index 64f513bf0..635a89325 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsScreen.kt @@ -677,95 +677,112 @@ private fun trackedLibraryBackupActions( /** * Wraps the appearance callbacks with their analytics tracking, without changing behavior. - * [currentState] is read at each pick, so a hero card style or colour pick reports the full look it makes. + * [currentState] is read at each pick, so a hero card style or colour pick reports the full look it + * makes, and a pick that leaves a setting as it was (re-tapping the current choice) reports nothing. */ -internal fun AppearanceActions.trackedForAnalytics(currentState: () -> AppearanceUiState? = { null }): AppearanceActions = AppearanceActions( - onSetThemeConfig = { - AnalyticsHelper.trackSettingsInteraction("theme_mode_changed", it) - onSetThemeConfig(it) - }, - onToggleDynamicColor = { - AnalyticsHelper.trackSettingsInteraction("dynamic_color_toggled", it.toString()) - onToggleDynamicColor(it) - }, - onSetThemeBrand = { - AnalyticsHelper.trackSettingsInteraction("theme_brand_changed", it) - onSetThemeBrand(it) - }, - onSetThemePreset = { - AnalyticsHelper.trackSettingsInteraction("surface_style_changed", it) - onSetThemePreset(it) - }, - onSetSurfaceStyle = { - AnalyticsHelper.trackSettingsInteraction("surface_style_changed", it) - onSetSurfaceStyle(it) - }, - onSetFontRoundness = { - AnalyticsHelper.trackSettingsInteraction("font_roundness_changed", it) - onSetFontRoundness(it) - }, - onSetNavigationStyle = { - AnalyticsHelper.trackSettingsInteraction("navigation_style_changed", it) - onSetNavigationStyle(it) - }, - onSetOpenAppTo = { - AnalyticsHelper.trackSettingsInteraction("open_app_to_changed", it) - onSetOpenAppTo(it) - }, - onSetHomeShortcutsInLibrary = { - AnalyticsHelper.trackSettingsInteraction("home_shortcuts_in_library_toggled", it.toString()) - onSetHomeShortcutsInLibrary(it) - }, - onSetWidgetAppearance = { - AnalyticsHelper.trackSettingsInteraction("widget_appearance_changed", it) - onSetWidgetAppearance(it) - }, - onSetExploreDefaultTab = { - AnalyticsHelper.trackSettingsInteraction("explore_default_tab_changed", it) - onSetExploreDefaultTab(it) - }, - onSetSubscriptionsDefaultTab = { - AnalyticsHelper.trackSettingsInteraction("subscriptions_default_tab_changed", it) - onSetSubscriptionsDefaultTab(it) - }, - onSetSubscriptionsTabStyle = { - AnalyticsHelper.trackSettingsInteraction("subscriptions_tab_style_changed", it) - onSetSubscriptionsTabStyle(it) - }, - onSetMiniPlayerSeekButtonsEnabled = onSetMiniPlayerSeekButtonsEnabled, - onSetFullPlayerStyle = onSetFullPlayerStyle, - onSetArtworkColorsEnabled = { enabled -> - AnalyticsHelper.trackSettingsInteraction("artwork_colors_toggled", enabled.toString()) - onSetArtworkColorsEnabled(enabled) - }, - onSetNavigationBlurLevel = { - AnalyticsHelper.trackSettingsInteraction("navigation_blur_changed", it) - onSetNavigationBlurLevel(it) - }, - onSetHeroCardStyle = { style -> - AnalyticsHelper.trackSettingsInteraction("hero_card_style_changed", style) - val current = currentState() - val shade = current?.heroCoverShade ?: HeroCoverShade.UNSET - AnalyticsHelper.trackHomeHeroStyleSelected(current?.heroCardStyle, current?.heroCoverShade, style, shade) - onSetHeroCardStyle(style) - }, - onSetHeroCoverShade = { shade -> - AnalyticsHelper.trackSettingsInteraction("hero_cover_shade_changed", shade) - val current = currentState() - val style = current?.heroCardStyle ?: HeroCardStyle.DEFAULT - AnalyticsHelper.trackHomeHeroStyleSelected(current?.heroCardStyle, current?.heroCoverShade, style, shade) - onSetHeroCoverShade(shade) - }, - onSetHeroTideProgress = { shape -> - AnalyticsHelper.trackSettingsInteraction("hero_tide_progress_changed", shape) - onSetHeroTideProgress(shape) - }, - onApplyTheme = { selection, custom -> - AnalyticsHelper.trackSettingsInteraction("surface_style_changed", selection.surfaceStyle) - onApplyTheme(selection, custom) - }, - onSaveCustomTheme = onSaveCustomTheme, -) +internal fun AppearanceActions.trackedForAnalytics(currentState: () -> AppearanceUiState? = { null }): AppearanceActions { + fun report(key: String, value: T, current: (AppearanceUiState) -> T) { + val state = currentState() + if (state == null || current(state) != value) AnalyticsHelper.trackSettingsInteraction(key, value.toString()) + } + return AppearanceActions( + onSetThemeConfig = { + report("theme_mode_changed", it) { state -> state.currentThemeConfig } + onSetThemeConfig(it) + }, + onToggleDynamicColor = { + report("dynamic_color_toggled", it) { state -> state.isDynamicColorEnabled } + onToggleDynamicColor(it) + }, + onSetThemeBrand = { + report("theme_brand_changed", it) { state -> state.currentThemeBrand } + onSetThemeBrand(it) + }, + onSetThemePreset = { + report("surface_style_changed", it) { state -> state.currentSurfaceStyle } + onSetThemePreset(it) + }, + onSetSurfaceStyle = { + report("surface_style_changed", it) { state -> state.currentSurfaceStyle } + onSetSurfaceStyle(it) + }, + onSetFontRoundness = { + report("font_roundness_changed", it) { state -> state.currentFontRoundness } + onSetFontRoundness(it) + }, + onSetNavigationStyle = { + report("navigation_style_changed", it) { state -> state.currentNavigationStyle } + onSetNavigationStyle(it) + }, + onSetOpenAppTo = { + report("open_app_to_changed", it) { state -> state.currentOpenAppTo } + onSetOpenAppTo(it) + }, + onSetHomeShortcutsInLibrary = { + report("home_shortcuts_in_library_toggled", it) { state -> state.homeShortcutsInLibrary } + onSetHomeShortcutsInLibrary(it) + }, + onSetWidgetAppearance = { + report("widget_appearance_changed", it) { state -> state.currentWidgetAppearance } + onSetWidgetAppearance(it) + }, + onSetExploreDefaultTab = { + report("explore_default_tab_changed", it) { state -> state.currentExploreDefaultTab } + onSetExploreDefaultTab(it) + }, + onSetSubscriptionsDefaultTab = { + report("subscriptions_default_tab_changed", it) { state -> state.currentSubscriptionsDefaultTab } + onSetSubscriptionsDefaultTab(it) + }, + onSetSubscriptionsTabStyle = { + report("subscriptions_tab_style_changed", it) { state -> state.currentSubscriptionsTabStyle } + onSetSubscriptionsTabStyle(it) + }, + onSetMiniPlayerSeekButtonsEnabled = { + report("mini_player_seek_buttons_toggled", it) { state -> state.miniPlayerSeekButtonsEnabled } + onSetMiniPlayerSeekButtonsEnabled(it) + }, + onSetFullPlayerStyle = { + report("full_player_style_changed", it) { state -> state.fullPlayerStyle } + onSetFullPlayerStyle(it) + }, + onSetArtworkColorsEnabled = { enabled -> + report("artwork_colors_toggled", enabled) { state -> state.artworkColorsEnabled } + onSetArtworkColorsEnabled(enabled) + }, + onSetNavigationBlurLevel = { + report("navigation_blur_changed", it) { state -> state.navigationBlurLevel } + onSetNavigationBlurLevel(it) + }, + onSetHeroCardStyle = { style -> + report("hero_card_style_changed", style) { state -> state.heroCardStyle } + val current = currentState() + val shade = current?.heroCoverShade ?: HeroCoverShade.UNSET + AnalyticsHelper.trackHomeHeroStyleSelected(current?.heroCardStyle, current?.heroCoverShade, style, shade) + onSetHeroCardStyle(style) + }, + onSetHeroCoverShade = { shade -> + report("hero_cover_shade_changed", shade) { state -> state.heroCoverShade } + val current = currentState() + val style = current?.heroCardStyle ?: HeroCardStyle.DEFAULT + AnalyticsHelper.trackHomeHeroStyleSelected(current?.heroCardStyle, current?.heroCoverShade, style, shade) + onSetHeroCoverShade(shade) + }, + onSetHeroTideProgress = { shape -> + report("hero_tide_progress_changed", shape) { state -> state.heroTideProgress } + onSetHeroTideProgress(shape) + }, + onApplyTheme = { selection, custom -> + // A theme is its brand and surface together; either changing is a change. + val state = currentState() + if (state == null || state.currentThemeBrand != selection.brand || state.currentSurfaceStyle != selection.surfaceStyle) { + AnalyticsHelper.trackSettingsInteraction("surface_style_changed", selection.surfaceStyle) + } + onApplyTheme(selection, custom) + }, + onSaveCustomTheme = onSaveCustomTheme, + ) +} @Composable private fun rememberAppInfo(context: Context): AppInfo { diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/downloads/AutoDownloadSettingsActions.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/downloads/AutoDownloadSettingsActions.kt index 3eed3541d..eb4c1ca10 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/downloads/AutoDownloadSettingsActions.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/downloads/AutoDownloadSettingsActions.kt @@ -1,11 +1,20 @@ package cx.aswin.boxlore.feature.settings.downloads +import cx.aswin.boxlore.core.analytics.AnalyticsHelper import cx.aswin.boxlore.core.prefs.AutoDownloadBackgroundSettings import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch internal val autoDownloadEpisodeLimits = listOf(1, 2, 3, 5) +/** + * One `settings_interaction` per download control (Smart Downloads and automatic downloads), sent + * only when [value] differs from [previous], so re-picking the current choice reports nothing. + */ +internal fun trackDownloadSetting(key: String, value: Any, previous: Any) { + if (value != previous) AnalyticsHelper.trackSettingsInteraction(key, value.toString()) +} + /** Only write the chosen preference; hiding background controls never resets their values. */ internal class AutoDownloadSettingsActions( private val scope: CoroutineScope, diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/downloads/AutoDownloadSettingsScreen.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/downloads/AutoDownloadSettingsScreen.kt index aa592ed89..1b13fc4d4 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/downloads/AutoDownloadSettingsScreen.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/downloads/AutoDownloadSettingsScreen.kt @@ -38,6 +38,7 @@ import cx.aswin.boxlore.feature.settings.components.SettingsDivider import cx.aswin.boxlore.feature.settings.components.SettingsGroup import cx.aswin.boxlore.feature.settings.components.SettingsScaffold import cx.aswin.boxlore.feature.settings.components.SettingsSwitchRow +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @Composable @@ -95,7 +96,10 @@ fun AutoDownloadSettingsScreen( supportingText = "Audio files wait for an unmetered connection.", icon = Icons.Rounded.Wifi, checked = wifiOnly, - onCheckedChange = { scope.launch { userPrefs.setAutoDownloadWifiOnly(it) } }, + onCheckedChange = { + trackDownloadSetting("auto_download_wifi_only_toggled", it, wifiOnly) + scope.launch { userPrefs.setAutoDownloadWifiOnly(it) } + }, ) } @@ -111,7 +115,10 @@ fun AutoDownloadSettingsScreen( ConnectedOptionSelector( options = autoDownloadEpisodeLimits.map { it to it.toString() }, selected = maxEpisodes, - onSelect = actions::selectEpisodeLimit, + onSelect = { limit -> + trackDownloadSetting("auto_download_episode_limit_changed", limit, maxEpisodes) + actions.selectEpisodeLimit(limit) + }, contentPadding = PaddingValues(horizontal = 8.dp, vertical = 10.dp), ) } @@ -122,19 +129,42 @@ fun AutoDownloadSettingsScreen( supportingText = "Delete downloaded files after an episode is finished.", icon = Icons.Rounded.DeleteOutline, checked = deleteCompleted, - onCheckedChange = { scope.launch { userPrefs.setAutoDownloadDeleteCompleted(it) } }, + onCheckedChange = { + trackDownloadSetting("auto_download_delete_completed_toggled", it, deleteCompleted) + scope.launch { userPrefs.setAutoDownloadDeleteCompleted(it) } + }, ) } - AutoDownloadBackgroundSettingsCard( - settings = background, - onEnabledChange = actions::setBackgroundEnabled, - onWifiOnlyChange = { scope.launch { userPrefs.setAutoDownloadBackgroundWifiOnly(it) } }, - onChargingOnlyChange = { scope.launch { userPrefs.setAutoDownloadBackgroundChargingOnly(it) } }, - ) + TrackedBackgroundSettingsCard(background, actions, userPrefs, scope) } if (showHelp) { AutoDownloadSettingsHelp(onDismiss = actions::dismissHelp) } } + +/** The background checks card; each switch reports only when it changes. */ +@Composable +private fun TrackedBackgroundSettingsCard( + background: AutoDownloadBackgroundSettings, + actions: AutoDownloadSettingsActions, + userPrefs: UserPreferencesRepository, + scope: CoroutineScope, +) { + AutoDownloadBackgroundSettingsCard( + settings = background, + onEnabledChange = { + trackDownloadSetting("auto_download_background_toggled", it, background.enabled) + actions.setBackgroundEnabled(it) + }, + onWifiOnlyChange = { + trackDownloadSetting("auto_download_background_wifi_only_toggled", it, background.wifiOnly) + scope.launch { userPrefs.setAutoDownloadBackgroundWifiOnly(it) } + }, + onChargingOnlyChange = { + trackDownloadSetting("auto_download_background_charging_only_toggled", it, background.chargingOnly) + scope.launch { userPrefs.setAutoDownloadBackgroundChargingOnly(it) } + }, + ) +} diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/downloads/SmartDownloadsSettingsScreen.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/downloads/SmartDownloadsSettingsScreen.kt index 0d877af79..c19693518 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/downloads/SmartDownloadsSettingsScreen.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/downloads/SmartDownloadsSettingsScreen.kt @@ -138,6 +138,7 @@ fun SmartDownloadsSettingsScreen( .clickable { scope.launch { val nextVal = !isEnabled + trackDownloadSetting("smart_downloads_toggled", nextVal, isEnabled) userPrefs.setSmartDownloadsEnabled(nextVal) if (nextVal) { cx.aswin.boxlore.core.downloads.SmartDownloadManager.schedulePeriodicSync(context, wifiOnly, chargingOnly) @@ -182,6 +183,7 @@ fun SmartDownloadsSettingsScreen( checked = isEnabled, onCheckedChange = { checked -> scope.launch { + trackDownloadSetting("smart_downloads_toggled", checked, isEnabled) userPrefs.setSmartDownloadsEnabled(checked) if (checked) { cx.aswin.boxlore.core.downloads.SmartDownloadManager.schedulePeriodicSync(context, wifiOnly, chargingOnly) @@ -258,6 +260,7 @@ fun SmartDownloadsSettingsScreen( FilterChip( selected = isSelected, onClick = { + trackDownloadSetting("smart_downloads_max_episodes_changed", count, maxEpisodes) scope.launch { userPrefs.setSmartDownloadsMaxEpisodes(count) } @@ -309,6 +312,7 @@ fun SmartDownloadsSettingsScreen( FilterChip( selected = isSelected, onClick = { + trackDownloadSetting("smart_downloads_storage_budget_changed", bytes, storageBudget) scope.launch { userPrefs.setSmartDownloadsStorageBudget(bytes) } @@ -388,6 +392,7 @@ fun SmartDownloadsSettingsScreen( .clickable { scope.launch { val nextVal = !wifiOnly + trackDownloadSetting("smart_downloads_wifi_only_toggled", nextVal, wifiOnly) userPrefs.setSmartDownloadsWifiOnly(nextVal) if (isEnabled) { cx.aswin.boxlore.core.downloads.SmartDownloadManager.schedulePeriodicSync(context, wifiOnly = nextVal, chargingOnly = chargingOnly) @@ -420,6 +425,7 @@ fun SmartDownloadsSettingsScreen( checked = wifiOnly, onCheckedChange = { checked -> scope.launch { + trackDownloadSetting("smart_downloads_wifi_only_toggled", checked, wifiOnly) userPrefs.setSmartDownloadsWifiOnly(checked) if (isEnabled) { cx.aswin.boxlore.core.downloads.SmartDownloadManager.schedulePeriodicSync(context, wifiOnly = checked, chargingOnly = chargingOnly) @@ -438,6 +444,7 @@ fun SmartDownloadsSettingsScreen( .clickable { scope.launch { val nextVal = !chargingOnly + trackDownloadSetting("smart_downloads_charging_only_toggled", nextVal, chargingOnly) userPrefs.setSmartDownloadsChargingOnly(nextVal) if (isEnabled) { cx.aswin.boxlore.core.downloads.SmartDownloadManager.schedulePeriodicSync(context, wifiOnly = wifiOnly, chargingOnly = nextVal) @@ -470,6 +477,7 @@ fun SmartDownloadsSettingsScreen( checked = chargingOnly, onCheckedChange = { checked -> scope.launch { + trackDownloadSetting("smart_downloads_charging_only_toggled", checked, chargingOnly) userPrefs.setSmartDownloadsChargingOnly(checked) if (isEnabled) { cx.aswin.boxlore.core.downloads.SmartDownloadManager.schedulePeriodicSync(context, wifiOnly = wifiOnly, chargingOnly = checked) @@ -533,6 +541,7 @@ fun SmartDownloadsSettingsScreen( FilterChip( selected = isSelected, onClick = { + trackDownloadSetting("smart_downloads_cleanup_rule_changed", rule, cleanupRule) scope.launch { userPrefs.setSmartDownloadsCleanupRule(rule) } diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackScreen.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackScreen.kt index 2b86449c1..c0b8c609a 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackScreen.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackScreen.kt @@ -655,6 +655,8 @@ private fun FeedbackCommunityRow( OutlinedButton( onClick = { + cx.aswin.boxlore.core.analytics.AnalyticsHelper + .trackStoreReviewOpened(method = "store_page", source = "feedback_screen") val pkgName = context.packageName val marketUri = try { Uri.parse("market://details?id=$pkgName") diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackViewModel.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackViewModel.kt index 96941f8bb..bf81d8ffd 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackViewModel.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackViewModel.kt @@ -6,6 +6,7 @@ import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope +import cx.aswin.boxlore.core.analytics.AnalyticsHelper import cx.aswin.boxlore.core.catalog.PodcastRepository import cx.aswin.boxlore.core.prefs.BoxcastPrefs import cx.aswin.boxlore.core.prefs.FeedbackDraft @@ -218,6 +219,13 @@ class FeedbackViewModel( sanitizedLogs, ) + if (success) { + AnalyticsHelper.trackFeedbackSent( + category = wireCategory, + withEmail = state.email.isNotBlank(), + withDiagnostics = state.attachDiagnostics, + ) + } handleSubmissionResult(success) } } diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/AppearanceActionsTrackedTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/AppearanceActionsTrackedTest.kt index c506bf6ef..1d229f726 100644 --- a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/AppearanceActionsTrackedTest.kt +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/AppearanceActionsTrackedTest.kt @@ -174,7 +174,7 @@ class AppearanceActionsTrackedTest { actions.onSetHeroCardStyle(HeroCardStyle.DUOTONE) actions.onSetHeroCoverShade(HeroCoverShade.DARK) - // Re-tapping the picked tile or colour keeps the look, so only settings_interaction records it. + // Re-tapping the picked tile or colour keeps the look, so neither event records it. actions.onSetHeroCardStyle(HeroCardStyle.DUOTONE) actions.onSetHeroCoverShade(HeroCoverShade.DARK) @@ -182,11 +182,44 @@ class AppearanceActionsTrackedTest { assertEquals(listOf("duotone_theme", "duotone_dark"), picks.map { it["hero_style_combo"] }) assertEquals(listOf("style", "colors"), picks.map { it["changed"] }) assertEquals(listOf("cover_theme", "duotone_theme"), picks.map { it["previous_hero_style_combo"] }) - assertEquals(4, events.count { it.first == "settings_interaction" }) + assertEquals(2, events.count { it.first == "settings_interaction" }) assertEquals(HeroCardStyle.DUOTONE, state.heroCardStyle) assertEquals(HeroCoverShade.DARK, state.heroCoverShade) } finally { restore() } } + + @Test + fun `a setting reports only when its value changes`() { + // Regression: every tap reported, so re-picking the current choice looked like a change. + val events = mutableListOf>>() + val restore = AnalyticsHelper.installRecordingSink(events) + try { + var state = AppearanceUiState("system", false, "violet", "standard") + val actions = AppearanceActions( + onSetThemeConfig = { state = state.copy(currentThemeConfig = it) }, + onToggleDynamicColor = {}, + onSetThemeBrand = {}, + onSetSurfaceStyle = {}, + onSetFullPlayerStyle = { state = state.copy(fullPlayerStyle = it) }, + onSetMiniPlayerSeekButtonsEnabled = { state = state.copy(miniPlayerSeekButtonsEnabled = it) }, + ).trackedForAnalytics { state } + + actions.onSetThemeConfig("system") + actions.onSetThemeConfig("dark") + actions.onSetThemeConfig("dark") + actions.onSetFullPlayerStyle("bunpod") + actions.onSetFullPlayerStyle("bunpod") + actions.onSetMiniPlayerSeekButtonsEnabled(true) + + val keys = events.filter { it.first == "settings_interaction" }.map { it.second["setting_key"] to it.second["value"] } + assertEquals( + listOf("theme_mode_changed" to "dark", "full_player_style_changed" to "bunpod", "mini_player_seek_buttons_toggled" to "true"), + keys, + ) + } finally { + restore() + } + } } diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/downloads/AutoDownloadSettingsActionsTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/downloads/AutoDownloadSettingsActionsTest.kt index e6f30be4c..356bef29c 100644 --- a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/downloads/AutoDownloadSettingsActionsTest.kt +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/downloads/AutoDownloadSettingsActionsTest.kt @@ -75,4 +75,22 @@ class AutoDownloadSettingsActionsTest { assertEquals(AutoDownloadBackgroundSettings(true, wifi, charging), saved) } } + + @Test + fun `a download control reports only a real change`() { + val events = mutableListOf>>() + val restore = cx.aswin.boxlore.core.analytics.AnalyticsHelper.installRecordingSink(events) + try { + trackDownloadSetting("auto_download_episode_limit_changed", 2, previous = 2) + trackDownloadSetting("auto_download_episode_limit_changed", 3, previous = 2) + trackDownloadSetting("smart_downloads_toggled", true, previous = false) + } finally { + restore() + } + + assertEquals( + listOf("auto_download_episode_limit_changed" to "3", "smart_downloads_toggled" to "true"), + events.filter { it.first == "settings_interaction" }.map { it.second["setting_key"] to it.second["value"] }, + ) + } } diff --git a/release-notes/upcoming.md b/release-notes/upcoming.md index c1a9c78f3..973125a49 100644 --- a/release-notes/upcoming.md +++ b/release-notes/upcoming.md @@ -49,6 +49,7 @@ Announcement title: - After importing your library or picking shows in search, choose whether to see similar shows or go straight to Home, and skip to Home anytime while suggestions load. (#1194) - When boxlore needs a quick check that you're a person before Echo can suggest shows, Echo asks for it in its own voice, and the check fits in with the rest of the screen. Fewer phones need it: phones that are slow to confirm they run genuine boxlore now get more time to finish in the background. (#1205) - New-episode notifications arrive sooner after a release. (#1151) +- boxlore asks to send notifications only when you turn them on for a show or finish importing your library, not every time you open Home. - On Explore, tap the Explore tab again to start searching. From a show or episode you opened in Explore, the first tap takes you back to Explore. ## Fixed @@ -74,3 +75,4 @@ Announcement title: - If boxlore can't check Google Play for updates, it offers to open its Play Store page so you can update there. (#1197) - Clearing boxlore's data no longer opens a random episode on the next launch. (#1185) - On an episode's page, More from shows the show's cover instead of a placeholder, and its header stands out from the episodes below it. (#1219) +- The prompt to rate boxlore counts only episodes you finish in boxlore on this phone, waits two days after you install or import your library, and doesn't ask twice for the same milestone. From cf6290c6fb8245f98ba355045fe00a65020517e2 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Sun, 1 Nov 2026 19:03:59 +0530 Subject: [PATCH 2/9] docs: link the listener notes to the PR --- release-notes/upcoming.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/release-notes/upcoming.md b/release-notes/upcoming.md index 973125a49..03ca71b2d 100644 --- a/release-notes/upcoming.md +++ b/release-notes/upcoming.md @@ -49,7 +49,7 @@ Announcement title: - After importing your library or picking shows in search, choose whether to see similar shows or go straight to Home, and skip to Home anytime while suggestions load. (#1194) - When boxlore needs a quick check that you're a person before Echo can suggest shows, Echo asks for it in its own voice, and the check fits in with the rest of the screen. Fewer phones need it: phones that are slow to confirm they run genuine boxlore now get more time to finish in the background. (#1205) - New-episode notifications arrive sooner after a release. (#1151) -- boxlore asks to send notifications only when you turn them on for a show or finish importing your library, not every time you open Home. +- boxlore asks to send notifications only when you turn them on for a show or finish importing your library, not every time you open Home. (#1226) - On Explore, tap the Explore tab again to start searching. From a show or episode you opened in Explore, the first tap takes you back to Explore. ## Fixed @@ -75,4 +75,4 @@ Announcement title: - If boxlore can't check Google Play for updates, it offers to open its Play Store page so you can update there. (#1197) - Clearing boxlore's data no longer opens a random episode on the next launch. (#1185) - On an episode's page, More from shows the show's cover instead of a placeholder, and its header stands out from the episodes below it. (#1219) -- The prompt to rate boxlore counts only episodes you finish in boxlore on this phone, waits two days after you install or import your library, and doesn't ask twice for the same milestone. +- The prompt to rate boxlore counts only episodes you finish in boxlore on this phone, waits two days after you install or import your library, and doesn't ask twice for the same milestone. (#1226) From 0f7e0596005a09311a6dabd07626f949f7cf56ec Mon Sep 17 00:00:00 2001 From: Aswin C Date: Sun, 1 Nov 2026 19:26:33 +0530 Subject: [PATCH 3/9] fix(analytics): record review prompts on screen and close restore gaps A decided review prompt is used up only once its sheet is on screen. Restored milestones without local listening are dropped, installs prompted before the update keep their milestone used, and the quiet days also start when an import begins and on an account's first sync. first_episode_played is claimed in listen start order and survives an update of a restored phone. Queue auto-advance and skips keep their entry point, Explore taps carry recipe_id, theme changes count wallpaper colours, and a skipped setup records zero initial shows. --- .../cx/aswin/boxlore/BoxLoreApplication.kt | 2 + .../navigation/NavGraphTabDestinations.kt | 1 - .../surveys/BoxcastPostHogSurveysDelegate.kt | 1 - .../cx/aswin/boxlore/ui/BoxLoreAppRoot.kt | 10 +++-- .../ui/libraryimport/ImportFlowModel.kt | 7 ++++ .../ui/libraryimport/ImportFlowModelTest.kt | 9 ++++ core/analytics/README.md | 2 +- .../core/analytics/AnalyticsGlossary.kt | 1 + .../analytics/OnboardingAnalyticsTracks.kt | 2 +- .../core/analytics/GlossaryRecipeIdTest.kt | 2 +- core/catalog/README.md | 2 +- .../catalog/EngagementPromptCoordinator.kt | 5 +++ .../core/catalog/sync/UserSyncCoordinator.kt | 7 ++++ .../catalog/sync/UserSyncCoordinatorTest.kt | 34 +++++++++++++++ core/playback/README.md | 4 +- .../boxlore/core/playback/FirstPlayClaim.kt | 41 +++++++++++++++++-- .../core/playback/PlaybackTelemetrySession.kt | 26 ++++++++++-- .../service/BoxLorePlaybackService.kt | 8 ++-- .../core/playback/FirstPlayClaimTest.kt | 35 +++++++++++++--- .../boxlore/core/playback/ItemEntryTest.kt | 32 +++++++++++++++ core/prefs/README.md | 2 +- .../core/prefs/EngagementPreferences.kt | 20 ++++++--- .../core/prefs/ReviewPromptPreferencesTest.kt | 29 ++++++++++++- docs/ANALYTICS_EVENT_GLOSSARY.md | 2 +- docs/analytics/event_glossary.csv | 2 +- feature/home/README.md | 2 +- .../feature/home/HomeListeningHistoryItem.kt | 6 ++- .../aswin/boxlore/feature/home/HomeScreen.kt | 7 ++++ .../boxlore/feature/home/HomeViewModel.kt | 20 +++++++++ .../feature/home/HomeViewModelLoadData.kt | 30 +++++++------- .../feature/home/ReviewPromptRecorder.kt | 26 ++++++++++++ .../feature/home/ReviewPromptRecorderTest.kt | 25 +++++++++++ .../feature/settings/SettingsScreen.kt | 4 +- .../settings/pages/AppearanceSettingsPage.kt | 7 +++- 34 files changed, 353 insertions(+), 60 deletions(-) create mode 100644 core/playback/src/test/java/cx/aswin/boxlore/core/playback/ItemEntryTest.kt create mode 100644 feature/home/src/main/java/cx/aswin/boxlore/feature/home/ReviewPromptRecorder.kt create mode 100644 feature/home/src/test/java/cx/aswin/boxlore/feature/home/ReviewPromptRecorderTest.kt diff --git a/app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt b/app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt index 67097c2dd..405c52778 100644 --- a/app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt +++ b/app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt @@ -408,6 +408,8 @@ class BoxLoreApplication : // A listen the last run left unfinished because Android ended the app (queued until setup ends). cx.aswin.boxlore.core.playback.ListenInterruptions .reportLeftover(this) + // Reads package info over binder, so off the main thread. + applicationScope.launch { cx.aswin.boxlore.core.playback.FirstPlayInstallOrigin.note(this@BoxLoreApplication) } } /** diff --git a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphTabDestinations.kt b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphTabDestinations.kt index 7a8c87e2d..9db739348 100644 --- a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphTabDestinations.kt +++ b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphTabDestinations.kt @@ -1,6 +1,5 @@ package cx.aswin.boxlore.navigation -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.setValue diff --git a/app/src/main/java/cx/aswin/boxlore/surveys/BoxcastPostHogSurveysDelegate.kt b/app/src/main/java/cx/aswin/boxlore/surveys/BoxcastPostHogSurveysDelegate.kt index 01b262771..7eec081f3 100644 --- a/app/src/main/java/cx/aswin/boxlore/surveys/BoxcastPostHogSurveysDelegate.kt +++ b/app/src/main/java/cx/aswin/boxlore/surveys/BoxcastPostHogSurveysDelegate.kt @@ -7,7 +7,6 @@ import com.posthog.surveys.OnPostHogSurveyResponse import com.posthog.surveys.OnPostHogSurveyShown import com.posthog.surveys.PostHogDisplaySurvey import com.posthog.surveys.PostHogSurveysDelegate -import cx.aswin.boxlore.core.analytics.AnalyticsHelper import cx.aswin.boxlore.core.catalog.EngagementPromptCoordinator import cx.aswin.boxlore.core.prefs.UserPreferencesRepository import cx.aswin.boxlore.surveys.internal.ActivityProvider diff --git a/app/src/main/java/cx/aswin/boxlore/ui/BoxLoreAppRoot.kt b/app/src/main/java/cx/aswin/boxlore/ui/BoxLoreAppRoot.kt index bb00cd655..e2b1481b1 100644 --- a/app/src/main/java/cx/aswin/boxlore/ui/BoxLoreAppRoot.kt +++ b/app/src/main/java/cx/aswin/boxlore/ui/BoxLoreAppRoot.kt @@ -96,6 +96,7 @@ import cx.aswin.boxlore.ui.libraryimport.OpmlImportDialog import cx.aswin.boxlore.ui.libraryimport.OpmlImportDialogActions import cx.aswin.boxlore.ui.libraryimport.OpmlImportEffects import cx.aswin.boxlore.ui.libraryimport.OpmlImportState +import cx.aswin.boxlore.ui.libraryimport.libraryImportPhase import cx.aswin.boxlore.ui.libraryimport.performJsonLibraryImport import cx.aswin.boxlore.ui.logic.SubscriptionResumeRefreshLogic import cx.aswin.boxlore.ui.logic.canUseAdaptivePlayer @@ -482,10 +483,11 @@ fun BoxLoreAppRoot( var opmlImportState by remember { mutableStateOf(OpmlImportState.Idle) } var opmlImportSource by remember { mutableStateOf("welcome_screen") } var importTriggerKey by remember { mutableStateOf(0L) } - // Every import path ends in Success; Home's review prompt waits a couple of days after one. - val importSucceeded = opmlImportState is OpmlImportState.Success - LaunchedEffect(importSucceeded) { - if (importSucceeded) userPrefs.markLibraryImported() + // Home's review prompt waits a couple of days after an import: marked when it starts (a backup's + // listening history lands before it ends) and again when it succeeds. + val importPhase = libraryImportPhase(opmlImportState) + LaunchedEffect(importPhase) { + if (importPhase != null) userPrefs.markLibraryImported() } fun performJsonImport(uri: android.net.Uri) { diff --git a/app/src/main/java/cx/aswin/boxlore/ui/libraryimport/ImportFlowModel.kt b/app/src/main/java/cx/aswin/boxlore/ui/libraryimport/ImportFlowModel.kt index b083767c5..300183278 100644 --- a/app/src/main/java/cx/aswin/boxlore/ui/libraryimport/ImportFlowModel.kt +++ b/app/src/main/java/cx/aswin/boxlore/ui/libraryimport/ImportFlowModel.kt @@ -162,3 +162,10 @@ internal fun jsonImportOutcome(count: Int, hasNotificationsEnabled: Boolean): Op /** The notifications nudge after a backup restore: only when restored shows use alerts or downloads and notifications are off. */ internal fun showsImportNotificationPrompt(state: OpmlImportState.Success, notificationsAllowed: Boolean): Boolean = state.isJson && state.hasNotificationsEnabled && !notificationsAllowed + +/** Where an import is, for the review prompt's quiet days: null when none is under way or done. */ +internal fun libraryImportPhase(state: OpmlImportState): String? = when (state) { + is OpmlImportState.Parsing, is OpmlImportState.ActiveImportState, is OpmlImportState.Completing -> "running" + is OpmlImportState.Success -> "done" + else -> null +} diff --git a/app/src/test/java/cx/aswin/boxlore/ui/libraryimport/ImportFlowModelTest.kt b/app/src/test/java/cx/aswin/boxlore/ui/libraryimport/ImportFlowModelTest.kt index 6e33169a0..286990c13 100644 --- a/app/src/test/java/cx/aswin/boxlore/ui/libraryimport/ImportFlowModelTest.kt +++ b/app/src/test/java/cx/aswin/boxlore/ui/libraryimport/ImportFlowModelTest.kt @@ -108,4 +108,13 @@ class ImportFlowModelTest { assertFalse(showsImportNotificationPrompt(backup.copy(hasNotificationsEnabled = false), notificationsAllowed = false)) assertFalse(showsImportNotificationPrompt(backup.copy(isJson = false), notificationsAllowed = false)) } + + @Test fun `an import starts the review prompt's quiet days when it begins, not only when it ends`() { + // Regression: a backup's listening history landed before Success, so a prompt could show first. + assertEquals("running", libraryImportPhase(OpmlImportState.ImportingJson())) + assertEquals("running", libraryImportPhase(OpmlImportState.Parsing(Uri.parse("content://backup")))) + assertEquals("done", libraryImportPhase(OpmlImportState.Success(importedCount = 3, completedCount = 0))) + assertNull(libraryImportPhase(OpmlImportState.Idle)) + assertNull(libraryImportPhase(OpmlImportState.ShowSelector)) + } } diff --git a/core/analytics/README.md b/core/analytics/README.md index 9a90a950b..abdd6fda8 100644 --- a/core/analytics/README.md +++ b/core/analytics/README.md @@ -91,7 +91,7 @@ src/main/java/cx/aswin/boxlore/core/analytics/ - Install channel: set-once person `install_channel` only (`trackInstallChannelAttributed`). Set-once properties go through `AnalyticsEmit.personSetOnce` (the SDK's set-once slot); nested as `$set_once` under `personSet` they landed as one person property named `$set_once`, which hid `install_channel` from every person before this fix. - Setup route: `onboarding_method` and `user_intent` follow the latest route (`$set`); `first_onboarding_method`, `first_user_intent` and `initial_podcasts_subscribed` are set once, so a later import from Home doesn't overwrite how the listener first set up. - Feedback: `feedback_submitted` is only the NPS survey's trigger (`nps_eligible`, `nps_manual`), because the survey shows on any of them. A Home review prompt is `engagement_prompt_shown`, a hand-off to Play `store_review_opened` (`in_app_review` or `store_page`), and an accepted feedback form `feedback_sent` (categories only, never the text or email). -- Explore: the For You rail being shown is `explore_surface_impression`; `explore_recommendation_tapped` is taps only. Home's category chips are `home_surface_tapped` (`category_filter`). +- Explore: the For You rail being shown is `explore_surface_impression`; `explore_recommendation_tapped` is taps only and now carries `recipe_id` beside `episode_id` (29 episode events). Home's category chips are `home_surface_tapped` (`category_filter`). - Playback events take only `EntryPointContext.ALLOWED_KEYS` from a play's context, and none replaces a field the event set; media item extras (artwork links, Android Auto keys, the stream address) never reach analytics. - Echo's chat sends what the listener typed capped at 500 characters, and never the reply text. - Deep links: custom `deep_link_opened` (`captureDeepLinks = false`). diff --git a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/AnalyticsGlossary.kt b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/AnalyticsGlossary.kt index e90f4c0c4..23c740ac6 100644 --- a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/AnalyticsGlossary.kt +++ b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/AnalyticsGlossary.kt @@ -141,6 +141,7 @@ object AnalyticsGlossary { */ val EPISODE_ID_EVENTS: Set = setOf( + "explore_recommendation_tapped", "deep_link_opened", "playback_started", "playback_heartbeat", diff --git a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/OnboardingAnalyticsTracks.kt b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/OnboardingAnalyticsTracks.kt index 9b94978cd..6a092b358 100644 --- a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/OnboardingAnalyticsTracks.kt +++ b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/OnboardingAnalyticsTracks.kt @@ -68,7 +68,7 @@ internal object OnboardingAnalyticsTracks { "total_onboarding_time_seconds" to totalOnboardingTimeSeconds, ), ) - setOnboardingPerson(method = "skip", intent = "casual_browser") + setOnboardingPerson(method = "skip", intent = "casual_browser", initialSubscribed = 0) } fun trackOnboardingAiTurnSubmitted(turnNumber: Int, selectedOptions: Set, customInputText: String, timeSpentSeconds: Float,) { diff --git a/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/GlossaryRecipeIdTest.kt b/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/GlossaryRecipeIdTest.kt index 4e9a15007..d7e798a23 100644 --- a/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/GlossaryRecipeIdTest.kt +++ b/core/analytics/src/test/java/cx/aswin/boxlore/core/analytics/GlossaryRecipeIdTest.kt @@ -48,7 +48,7 @@ class GlossaryRecipeIdTest { val withEpisodeId = csvProperties.filterValues { props -> props.any { it.startsWith("episode_id:") } }.keys assertEquals(AnalyticsGlossary.EPISODE_ID_EVENTS, withEpisodeId) - assertEquals(28, AnalyticsGlossary.EPISODE_ID_EVENTS.size) + assertEquals(29, AnalyticsGlossary.EPISODE_ID_EVENTS.size) assertTrue(AnalyticsGlossary.ALL_ALLOWED_EVENTS.containsAll(AnalyticsGlossary.EPISODE_ID_EVENTS)) } diff --git a/core/catalog/README.md b/core/catalog/README.md index 7783a8ee5..549979774 100644 --- a/core/catalog/README.md +++ b/core/catalog/README.md @@ -174,7 +174,7 @@ Main Kotlin files should remain below 1000 lines; extracted helpers keep reposit ## Testing notes -- Install referrer: Play's `utm_source=(not set)` placeholder is channel `unknown`, not `utm` (`InstallReferrerManagerTest`). `EngagementPromptCoordinator.shouldShowPromoterReview` waits out the review prompt's quiet days after installing or importing. +- Install referrer: Play's `utm_source=(not set)` placeholder is channel `unknown`, not `utm` (`InstallReferrerManagerTest`). `EngagementPromptCoordinator.shouldShowPromoterReview` waits out the review prompt's quiet days after installing or importing, and `reserveSessionForPrompt` holds the session for a prompt that waits for its screen. `UserSyncCoordinator` calls `onFirstAccountPull` (by default `markLibraryImported`) before a phone's first pull for an account, a sign-in or an account switch, so another phone's synced listening starts the quiet days too (`UserSyncCoordinatorTest`). - Saved feed addresses: `FeedAddressUpdaterTest` (Robolectric Room: one update writes both addresses, keeps the old one, resets every validator, marks the row dirty, re-registers and reports once; undo; `rss:` and non-HTTPS refused; a changed row left alone; fills, cloud addresses and unfollowed shows aren't corrections and an unfollowed row stays clean; a failed registration request of any kind keeps the address; a registration stays pending until acknowledged and is asked for again, also after the process died; no address written when its request couldn't be saved; a redirect from an address other than the saved one is never adopted; a re-follow keeps a corrected address), `FeedAddressDailyCheckTest` (501 followed shows make two calls, nothing again the same day; only confirmed moves, read before they are saved; an unreadable registry address never saved and tried again the next day; a swapped show stamped by its swap; never back to the address the saved one replaced, whatever replaced it; follows changed on the phone wait; a failed call or an unavailable registry stamps nothing, so the `/podcast` fallback runs once, and never for a show the check reached; the switch), `FeedAddressRecordedAnswersTest` (the API's own `/v2/shows/feeds` and `/v2/feed-report` answers, `fixtures/v2`, through the app's client: a registry move, a dump address, an unknown show, an http-only show, an unavailable registry and a refused report), `FeedReportsTest` and `SharedPreferencesFeedReportStampsTest` (once per show a day, 20 a day in all, limiters sharing one store counting each other's reports, a failed or refused send counting nothing, kept across processes, never offline, for `rss:` shows, without a read or with the switch off; only a readable new address offered back), `PodcastNotificationRegistrarTest` (the same node and topic, the shared node never written without its address, nothing without notifications, retry and refused outcomes, a cloud pull that changes only the address re-registering on the same node and topic, a pulled previous address never undoing a registry correction, and a merge never writing an older copy's address over a newer one), `SubscribedEpisodeCatalogTest` (moves followed on every refresh path, never for `rss:` shows or with the switch off, caller-handled requests left alone), and `DirectFeedRecoveryTest` (every failed read reported, the once-a-day `/podcast` re-check sharing the daily stamp, the report's address tried before the `/podcast` re-check, a passing failure keeping its address, a candidate replacing only the address that failed). - `PodcastRepositoryPlayerPrefsTest` checks the player preferences (and their legacy-file migration) open on first use, not while the repository is constructed on the main thread at startup. - Unit tests live under `core/catalog/src/test`. diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/EngagementPromptCoordinator.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/EngagementPromptCoordinator.kt index fa190870a..94fbe793d 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/EngagementPromptCoordinator.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/EngagementPromptCoordinator.kt @@ -33,6 +33,11 @@ class EngagementPromptCoordinator( /** Returns true when playback is idle and no proactive prompt has shown this session. */ fun canShowProactivePrompt(isPlaying: Boolean): Boolean = !isPlaying && !sessionProactivePromptShown + /** A prompt was decided and waits for its screen: no other proactive prompt this session; nothing saved yet. */ + fun reserveSessionForPrompt() { + sessionProactivePromptShown = true + } + /** Marks the current session as having shown a proactive prompt and persists cooldown. */ fun recordProactivePromptShown() { sessionProactivePromptShown = true diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/sync/UserSyncCoordinator.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/sync/UserSyncCoordinator.kt index 20921f37b..ae33e7666 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/sync/UserSyncCoordinator.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/sync/UserSyncCoordinator.kt @@ -79,6 +79,11 @@ open class UserSyncCoordinator( private val playedShowDao: PlayedShowDao? = null, /** Daily listening per phone for the cloud sync (Room v40); null syncs no listening, as older builds. */ private val listeningInsightsMaintenance: ListeningInsightsMaintenance? = null, + /** + * Runs before this phone's first pull for an account (a sign-in or an account switch), which can + * bring in another phone's finished episodes: starts the review prompt's quiet days. + */ + private val onFirstAccountPull: suspend () -> Unit = { userPreferencesRepository?.markLibraryImported() }, ) { private val syncMutex = Mutex() @@ -120,6 +125,7 @@ open class UserSyncCoordinator( return handleAccountSwitch(userId, token) } if (lastSyncedUser == null) { + onFirstAccountPull() requireBoxcastPrefs.setLastSyncedUserId(userId) } @@ -156,6 +162,7 @@ open class UserSyncCoordinator( } private suspend fun handleAccountSwitch(userId: String, token: String?): SyncResult { + onFirstAccountPull() purgeLocalDataForAccountSwitch() requireBoxcastPrefs.setLastSyncTimestamp(0L) resetListeningSync() diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/sync/UserSyncCoordinatorTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/sync/UserSyncCoordinatorTest.kt index cac4ee127..a3ca4cc27 100644 --- a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/sync/UserSyncCoordinatorTest.kt +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/sync/UserSyncCoordinatorTest.kt @@ -787,6 +787,40 @@ class UserSyncCoordinatorTest { } } + @Test + fun firstSyncForAnAccountStartsTheReviewQuietDaysOnce() = runTest(testDispatcher) { + // Regression: a sign-in pulled another phone's finished episodes, which could cross a review + // milestone at once. + var firstPulls = 0 + val customCoordinator = UserSyncCoordinator( + boxLoreApi = fakeBoxLoreApi, + publicKey = "test-public-key", + authUserIdProvider = { currentUserId }, + tokenProvider = { currentToken }, + podcastDao = podcastDao, + listeningHistoryDao = listeningHistoryDao, + queueSyncPort = fakeQueueSyncPort, + subscriptionSyncResolver = subscriptionSyncResolver, + historySyncResolver = historySyncResolver, + queueSyncResolver = queueSyncResolver, + boxcastPrefs = prefs, + ioDispatcher = testDispatcher, + onFirstAccountPull = { firstPulls++ }, + ) + syncPushHandler = { _, _, _ -> FakeCall(Response.success(SyncPushResponse(status = "ok", syncedAt = 4000L))) } + syncPullHandler = { _, _, _ -> + FakeCall(Response.success(SyncPullResponse(subscriptions = emptyList(), history = emptyList(), queue = null, syncedAt = 5000L))) + } + + customCoordinator.syncNow() + customCoordinator.syncNow() + assertEquals(1, firstPulls) + + currentUserId = "user-B" + customCoordinator.syncNow() + assertEquals(2, firstPulls) + } + @Test fun executePull_handoffsNewestRemoteSessionToIdlePlaybackPort() = runTest(testDispatcher) { val fakePlayback = FakeActivePlaybackSyncPort() diff --git a/core/playback/README.md b/core/playback/README.md index ef57dfc6e..561b18959 100644 --- a/core/playback/README.md +++ b/core/playback/README.md @@ -156,7 +156,7 @@ Files under `core/data/service` are compatibility stubs for old service class na - Media ID prefixes such as `episode:`, `queue:`, and `learn:` are session and Android Auto contracts. - SharedPreferences file `boxcast_player` stores playback session flags. - SharedPreferences file `boxlore_listen_note` holds the note of the listen in progress (`PrefsListenNoteStore`); it is empty between listens and left out of backups and device transfers, so a restore never reports a listen again. -- SharedPreferences file `boxlore_install_milestones` holds `first_play_claimed` (`FirstPlayClaim`, `PrefsFirstPlayStore`): `first_episode_played` is claimed once per install when a listen starts, whatever started it, with the episode and show. The file is left out of backups and device transfers, so a restored or new phone counts its own first play; an install updated from an older build that already sent it from the screen stays quiet (`FirstPlayClaimTest`). +- SharedPreferences file `boxlore_install_milestones` holds `first_play_claimed` (`FirstPlayClaim`, `PrefsFirstPlayStore`): `first_episode_played` is claimed once per install when a listen starts, whatever started it, with the episode and show. The file is left out of backups and device transfers, so a restored or new phone counts its own first play; an install updated from an older build that already sent it from the screen stays quiet. The claim is taken when the listen starts (on the main-thread scope, in start order), not after the show lookups, so the first play is the one that started first; the app's first launch notes once whether the install came from an older build (`FirstPlayInstallOrigin.note`, `updated_from_older_build`), so updating a restored phone before its first listen can't hide it (`FirstPlayClaimTest`). - SharedPreferences file `android_auto_artwork_sources` maps Android Auto artwork content keys to remote HTTPS URLs or sandboxed local paths (identity for Auto collage ContentProvider; do not rename lightly). - Preference key `device_uuid` is a stable install identifier and must not be logged raw. - Queue, history, and download rows are persisted by `:core:database` and `:core:downloads`. @@ -166,7 +166,7 @@ Media-session bitmap loading classifies transient transport failures separately ## Testing notes -- Analytics labels: `labelCarPlay` (`service/auto/CarPlayLabel.kt`) names a play `android_auto_*` only when Android Auto's controller asked for it. The app's own plays reach `onAddMediaItems` too (Media3 sends `setMediaItems` through it), and labelling them replaced the app's entry point, so phone plays were counted as car plays (`CarPlayLabelTest`). `QueueRepository.clearQueue` reports `queue_modified` "clear" only with a `listenerSource` (the Lore conflict dialog); starting a new episode or mix replaces the queue silently (`QueueRepositoryTest`). `analyticsEntryPointContext` keeps only `EntryPointContext.ALLOWED_KEYS` from a media item's extras (`CastMediaItemConverterTest`). Auto-advance and skips start listens as `queue_auto_advance` and `queue_skip`, which are now named entry points. +- Analytics labels: `labelCarPlay` (`service/auto/CarPlayLabel.kt`) names a play `android_auto_*` only when Android Auto's controller asked for it. The app's own plays reach `onAddMediaItems` too (Media3 sends `setMediaItems` through it), and labelling them replaced the app's entry point, so phone plays were counted as car plays (`CarPlayLabelTest`). `QueueRepository.clearQueue` reports `queue_modified` "clear" only with a `listenerSource` (the Lore conflict dialog); starting a new episode or mix replaces the queue silently (`QueueRepositoryTest`). `analyticsEntryPointContext` keeps only `EntryPointContext.ALLOWED_KEYS` from a media item's extras (`CastMediaItemConverterTest`). Auto-advance and skips start listens as `queue_auto_advance` and `queue_skip`, which are now named entry points; when the queue's items carry the surface that started it, that becomes `source_entry_point` (`itemEntry`, `ItemEntryTest`). - `PlaybackIntroOutroControllerTest` covers the 1 ms / 100 ms automatic-transition regression, explicit zero, intro trim, stale cutoff, explicit History resume, completed episodes, delayed readiness, and manual seeks. `QueueManagerPlaybackTest`, `QueueRepositoryTest`, `SmartQueueRefillCoordinatorTest`, and `ContextQueueContinuationCoordinatorTest` cover context replacement, ordered mixed-show playback, transactional rollback, completion-before-refill, long lists, and delayed results after a new queue or pause. - Review regressions also cover abandoned positions after a different activation, current-item transport seeks, live Auto resume, Room prefix trimming during candidate loading, rejected upcoming queue edits, and natural-advance telemetry signal expiry. diff --git a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/FirstPlayClaim.kt b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/FirstPlayClaim.kt index 52268e75b..304caf941 100644 --- a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/FirstPlayClaim.kt +++ b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/FirstPlayClaim.kt @@ -12,25 +12,50 @@ import kotlinx.coroutines.sync.withLock */ internal class FirstPlayClaim( private val store: FirstPlayStore, - /** True when an install updated from an older boxlore already sent the event from the screen. */ - private val loggedBeforeUpdate: suspend () -> Boolean, + /** True when this install was updated after it was installed; read only if the first launch didn't note it. */ + private val isUpdatedInstall: () -> Boolean, + /** The flag older builds set when they sent the event from the screen. */ + private val loggedByOlderBuild: suspend () -> Boolean, ) { private val lock = Mutex() /** True exactly once per install: for the first play that should send the event. */ suspend fun claim(): Boolean = lock.withLock { if (store.isClaimed()) return@withLock false - val alreadySent = runCatching { loggedBeforeUpdate() }.getOrDefault(false) + val updated = store.updatedFromOlderBuild() ?: isUpdatedInstall() + // Only an install that ran an older build can have sent it; a restored copy of that flag can't count. + val alreadySent = updated && runCatching { loggedByOlderBuild() }.getOrDefault(false) store.markClaimed() !alreadySent } + + companion object { + /** See [FirstPlayInstallOrigin]. */ + fun noteInstallOrigin(store: FirstPlayStore, isUpdatedInstall: () -> Boolean) { + if (store.updatedFromOlderBuild() == null) store.setUpdatedFromOlderBuild(isUpdatedInstall()) + } + } } -/** Where [FirstPlayClaim] keeps its flag: SharedPreferences in the app, a fake in tests. */ +/** The app-start hook for [FirstPlayClaim]. */ +object FirstPlayInstallOrigin { + /** + * Notes once, on this build's first launch, whether the install came from an older build, so + * updating a restored phone before its first listen can't hide that listen. Call off the main thread. + */ + fun note(context: Context) = FirstPlayClaim.noteInstallOrigin(PrefsFirstPlayStore(context)) { context.isUpdatedInstall() } +} + +/** Where [FirstPlayClaim] keeps its flags: SharedPreferences in the app, a fake in tests. */ internal interface FirstPlayStore { fun isClaimed(): Boolean fun markClaimed() + + /** Null until the first launch of a build with [FirstPlayClaim] notes it. */ + fun updatedFromOlderBuild(): Boolean? + + fun setUpdatedFromOlderBuild(updated: Boolean) } /** [FirstPlayStore] in `boxlore_install_milestones.xml`, which the backup rules exclude. */ @@ -43,9 +68,17 @@ internal class PrefsFirstPlayStore(context: Context) : FirstPlayStore { prefs.edit().putBoolean(KEY_FIRST_PLAY, true).apply() } + override fun updatedFromOlderBuild(): Boolean? = + if (prefs.contains(KEY_UPDATED_FROM_OLDER_BUILD)) prefs.getBoolean(KEY_UPDATED_FROM_OLDER_BUILD, false) else null + + override fun setUpdatedFromOlderBuild(updated: Boolean) { + prefs.edit().putBoolean(KEY_UPDATED_FROM_OLDER_BUILD, updated).apply() + } + private companion object { const val PREFS_NAME = "boxlore_install_milestones" const val KEY_FIRST_PLAY = "first_play_claimed" + const val KEY_UPDATED_FROM_OLDER_BUILD = "updated_from_older_build" } } diff --git a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackTelemetrySession.kt b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackTelemetrySession.kt index be53d1613..1a3c97851 100644 --- a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackTelemetrySession.kt +++ b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackTelemetrySession.kt @@ -159,8 +159,10 @@ internal class PlaybackTelemetrySession( val contextMap = pendingEntryPoint.filterKeys { it != "entry_point" } entryPointContext = contextMap.ifEmpty { null } } else { - entryPoint = extras?.getString("entry_point") - entryPointContext = analyticsEntryPointContext(extras) + val (itemEntryPoint, itemContext) = + itemEntry(extras?.getString("entry_point"), analyticsEntryPointContext(extras), fallbackEntryPoint) + entryPoint = itemEntryPoint + entryPointContext = itemContext } if (entryPoint == null) { @@ -175,6 +177,9 @@ internal class PlaybackTelemetrySession( listenContext()?.let(listens::started) val listenStart = ListenStart(listenId = listen.listenId, controlledBy = listens.controlledBy) + // Claimed now, in the order listens start (the scope runs on the main thread), not when the + // slower show lookups finish, so the first play is the one that started first. + val firstPlayClaim = firstPlay?.let { claim -> scope.async { claim.claim() } } // Download or stream right away, not after the slower show lookups, so a short listen knows it. val modeLookup = scope.async { playbackModeOf(episodeId) } scope.launch { @@ -182,7 +187,7 @@ internal class PlaybackTelemetrySession( withContext(mainDispatcher) { listens.resolvedPlaybackMode(listen.listenId, mode) } } scope.launch { - enrich(episodeId, currentItem, genre, listenStart, modeLookup) + enrich(episodeId, currentItem, genre, listenStart, modeLookup, firstPlayClaim) } } @@ -270,6 +275,7 @@ internal class PlaybackTelemetrySession( genre: String?, listen: ListenStart, modeLookup: Deferred, + firstPlayClaim: Deferred?, ) { try { val queueItem = database.queueDao().getQueueItemByEpisodeId(episodeId) @@ -340,7 +346,7 @@ internal class PlaybackTelemetrySession( speed = speed, listen = listen, ) - if (firstPlay?.claim() == true) AnalyticsHelper.trackFirstEpisodePlayed(episodeId, resolvedPodcastId) + if (firstPlayClaim?.await() == true) AnalyticsHelper.trackFirstEpisodePlayed(episodeId, resolvedPodcastId) } /** Download when any stored ID of the episode holds a finished download, else stream. */ @@ -679,6 +685,18 @@ internal class PlaybackTelemetrySession( } } +/** + * A listen's entry point from its media item. When the queue moved on by itself or by a skip + * ([transition], `queue_auto_advance` or `queue_skip`), that names it, and the surface that started + * the queue (copied onto every item) becomes its `source_entry_point` unless the item names one. + */ +internal fun itemEntry(itemEntryPoint: String?, itemContext: Map?, transition: String?,): Pair?> = + if (transition != null && itemEntryPoint != null) { + transition to (mapOf("source_entry_point" to itemEntryPoint) + itemContext.orEmpty()) + } else { + itemEntryPoint to itemContext + } + /** The entry-point details in a media item's extras; its artwork links, stream address and Auto keys stay out. */ internal fun analyticsEntryPointContext(extras: Bundle?): Map? { if (extras == null) return null diff --git a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/BoxLorePlaybackService.kt b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/BoxLorePlaybackService.kt index 83ad9d217..962a65771 100644 --- a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/BoxLorePlaybackService.kt +++ b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/BoxLorePlaybackService.kt @@ -188,9 +188,11 @@ open class BoxLorePlaybackService : session.listens.isAppInBackground = { !PlaybackUiVisibility.isForeground.value } session.listens.listenNotes = cx.aswin.boxlore.core.playback.ListenNoteKeeper(cx.aswin.boxlore.core.playback.PrefsListenNoteStore(this)) session.firstPlay = - cx.aswin.boxlore.core.playback.FirstPlayClaim(cx.aswin.boxlore.core.playback.PrefsFirstPlayStore(this)) { - isUpdatedInstall() && userPreferencesRepository.hasLoggedFirstPlay.first() - } + cx.aswin.boxlore.core.playback.FirstPlayClaim( + store = cx.aswin.boxlore.core.playback.PrefsFirstPlayStore(this), + isUpdatedInstall = ::isUpdatedInstall, + loggedByOlderBuild = { userPreferencesRepository.hasLoggedFirstPlay.first() }, + ) } } diff --git a/core/playback/src/test/java/cx/aswin/boxlore/core/playback/FirstPlayClaimTest.kt b/core/playback/src/test/java/cx/aswin/boxlore/core/playback/FirstPlayClaimTest.kt index 177f11b91..23ebab7b0 100644 --- a/core/playback/src/test/java/cx/aswin/boxlore/core/playback/FirstPlayClaimTest.kt +++ b/core/playback/src/test/java/cx/aswin/boxlore/core/playback/FirstPlayClaimTest.kt @@ -10,18 +10,24 @@ import org.junit.jupiter.api.Test /** `first_episode_played` is claimed once per install, from the player. */ class FirstPlayClaimTest { - private class MemoryStore(var claimed: Boolean = false) : FirstPlayStore { + private class MemoryStore(var claimed: Boolean = false, var updated: Boolean? = null) : FirstPlayStore { override fun isClaimed() = claimed override fun markClaimed() { claimed = true } + + override fun updatedFromOlderBuild() = updated + + override fun setUpdatedFromOlderBuild(updated: Boolean) { + this.updated = updated + } } @Test fun `the first play claims once and later plays never do`() = runTest { val store = MemoryStore() - val claim = FirstPlayClaim(store) { false } + val claim = FirstPlayClaim(store, isUpdatedInstall = { false }) { false } assertTrue(claim.claim()) assertFalse(claim.claim()) @@ -30,7 +36,7 @@ class FirstPlayClaimTest { @Test fun `plays starting together still claim once`() = runTest { - val claim = FirstPlayClaim(MemoryStore()) { false } + val claim = FirstPlayClaim(MemoryStore(), isUpdatedInstall = { false }) { false } val results = List(5) { async { claim.claim() } }.awaitAll() @@ -40,15 +46,34 @@ class FirstPlayClaimTest { @Test fun `an updated install that already sent it from the screen stays quiet`() = runTest { val store = MemoryStore() - val claim = FirstPlayClaim(store) { true } + val claim = FirstPlayClaim(store, isUpdatedInstall = { true }) { true } assertFalse(claim.claim()) assertTrue(store.claimed) } + @Test + fun `a restored phone updated before its first listen still sends it`() = runTest { + // Regression: the restored older-build flag and the later update together hid the new phone's first play. + val store = MemoryStore() + FirstPlayClaim.noteInstallOrigin(store) { false } + val claim = FirstPlayClaim(store, isUpdatedInstall = { true }) { true } + + assertTrue(claim.claim()) + } + + @Test + fun `the install origin is noted once`() { + val store = MemoryStore() + FirstPlayClaim.noteInstallOrigin(store) { true } + FirstPlayClaim.noteInstallOrigin(store) { false } + + assertEquals(true, store.updated) + } + @Test fun `a failed legacy read still claims`() = runTest { - val claim = FirstPlayClaim(MemoryStore()) { error("datastore unavailable") } + val claim = FirstPlayClaim(MemoryStore(), isUpdatedInstall = { true }) { error("datastore unavailable") } assertTrue(claim.claim()) } diff --git a/core/playback/src/test/java/cx/aswin/boxlore/core/playback/ItemEntryTest.kt b/core/playback/src/test/java/cx/aswin/boxlore/core/playback/ItemEntryTest.kt new file mode 100644 index 000000000..9a8419914 --- /dev/null +++ b/core/playback/src/test/java/cx/aswin/boxlore/core/playback/ItemEntryTest.kt @@ -0,0 +1,32 @@ +package cx.aswin.boxlore.core.playback + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +/** Which entry point a listen from a media item reports. */ +class ItemEntryTest { + @Test + fun `a queue moving on names the move and keeps where the queue started`() { + // Regression: every item of a queue started from a show page carried "podcast_detail", so + // auto-advance and skips were never reported as such. + val (entryPoint, context) = itemEntry("podcast_detail", null, transition = "queue_auto_advance") + + assertEquals("queue_auto_advance", entryPoint) + assertEquals(mapOf("source_entry_point" to "podcast_detail"), context) + } + + @Test + fun `an item's own source wins over the queue's starting surface`() { + val (_, context) = itemEntry("podcast_detail", mapOf("source_entry_point" to "library_latest_episodes"), transition = "queue_skip") + + assertEquals("library_latest_episodes", context?.get("source_entry_point")) + } + + @Test + fun `the first item of a queue keeps its own entry point`() { + val (entryPoint, context) = itemEntry("podcast_detail", mapOf("screen" to "podcast"), transition = null) + + assertEquals("podcast_detail", entryPoint) + assertEquals(mapOf("screen" to "podcast"), context) + } +} diff --git a/core/prefs/README.md b/core/prefs/README.md index c495bf34e..f1b5d9331 100644 --- a/core/prefs/README.md +++ b/core/prefs/README.md @@ -81,7 +81,7 @@ src/main/java/cx/aswin/boxlore/core/prefs/ - Preference keys defined in `UserPreferenceKeys` and `BoxcastPrefs` are persisted user identity and must not be renamed casually. - `subscription_manual_order` and `home_pinned_podcast_ids` live in the same `user_preferences` DataStore (do not rename the file). Unsubscribe drops that id from both lists. - `seen_listening_story` (`seenListeningStoryStream` / `setSeenListeningStory`, added 2026-10-31): the last monthly listening story opened, as `yyyy-MM`, so Listening history stops calling it New. Local to the phone. -- Review prompt (`EngagementPreferences`): a milestone (5, 15 or 30 episodes finished in boxlore on this install; Home counts them) is stored as `review_milestone_pending` and, once a prompt shows, as `review_milestone_consumed`, so the same milestone never asks again. `shouldShowReviewPrompt` and `isInReviewQuietPeriod` take Android's install time (a backup doesn't carry it) and keep a 2-day quiet period (`REVIEW_QUIET_DAYS`) after it and after `library_imported_at` (`markLibraryImported`). `review_first_launch_at` (the old lazily set install day) is no longer read. `has_logged_first_play` is read only so an updated install doesn't send `first_episode_played` again; the player claims it now. `UserPreferencesRepositoryTest` covers the quiet days after install and import and a used milestone never coming back. +- Review prompt (`EngagementPreferences`): a milestone (5, 15 or 30 episodes finished in boxlore on this install; Home counts them) is stored as `review_milestone_pending` and, once a prompt shows, as `review_milestone_consumed`, so the same milestone never asks again. `shouldShowReviewPrompt` and `isInReviewQuietPeriod` take Android's install time (a backup doesn't carry it) and keep a 2-day quiet period (`REVIEW_QUIET_DAYS`) after it and after `library_imported_at` (`markLibraryImported`). `review_first_launch_at` (the old lazily set install day) is no longer read. The pending milestone follows the count, so one a backup brought onto a phone with no listening behind it is dropped; on the first sync after updating, an install already prompted counts milestone 5 (or the one it has reached) as used (`ReviewPromptPreferencesTest`). `has_logged_first_play` is read only so an updated install doesn't send `first_episode_played` again; the player claims it now. `UserPreferencesRepositoryTest` covers the quiet days after install and import and a used milestone never coming back. - `legacy_rss_repair_*` keys are a crash-recovery contract; keep them until every shipping build that can begin the repair has aged out. ## Testing notes diff --git a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/EngagementPreferences.kt b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/EngagementPreferences.kt index aeec5e390..e62b5449f 100644 --- a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/EngagementPreferences.kt +++ b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/EngagementPreferences.kt @@ -205,8 +205,11 @@ internal class ReviewPromptPreferences(private val dataStore: DataStore= 30 -> 30 completedCount >= 15 -> 15 completedCount >= 5 -> 5 - else -> return + else -> null } dataStore.edit { pref -> if (pref[AnalyticsKeys.REVIEW_HAS_REVIEWED] == true) return@edit - if (milestone <= (pref[AnalyticsKeys.REVIEW_MILESTONE_CONSUMED] ?: 0)) return@edit - val current = pref[AnalyticsKeys.REVIEW_MILESTONE_PENDING] - if (current == null || milestone > current) { + if (pref[AnalyticsKeys.REVIEW_MILESTONE_CONSUMED] == null && (pref[AnalyticsKeys.REVIEW_PROMPT_COUNT] ?: 0) > 0) { + pref[AnalyticsKeys.REVIEW_MILESTONE_CONSUMED] = maxOf(FIRST_MILESTONE, milestone ?: 0) + } + if (milestone == null || milestone <= (pref[AnalyticsKeys.REVIEW_MILESTONE_CONSUMED] ?: 0)) { + pref.remove(AnalyticsKeys.REVIEW_MILESTONE_PENDING) + } else { pref[AnalyticsKeys.REVIEW_MILESTONE_PENDING] = milestone } } @@ -334,3 +340,5 @@ private fun isEngagementCooldownElapsed(pref: Preferences): Boolean { } private const val DAY_MS = 24L * 60 * 60 * 1000 + +private const val FIRST_MILESTONE = 5 diff --git a/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/ReviewPromptPreferencesTest.kt b/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/ReviewPromptPreferencesTest.kt index 278526a88..ed4cc048b 100644 --- a/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/ReviewPromptPreferencesTest.kt +++ b/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/ReviewPromptPreferencesTest.kt @@ -2,6 +2,7 @@ package cx.aswin.boxlore.core.prefs import android.content.Context import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey import androidx.test.core.app.ApplicationProvider import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -115,14 +116,38 @@ class ReviewPromptPreferencesTest { repository.syncReviewMilestonePending(30) assertEquals(30, repository.reviewMilestonePending()) - // Lower milestone does not downgrade. + // The pending milestone follows the count. repository.syncReviewMilestonePending(15) - assertEquals(30, repository.reviewMilestonePending()) + assertEquals(15, repository.reviewMilestonePending()) repository.clearReviewMilestonePending() assertNull(repository.reviewMilestonePending()) } + @Test + fun restoredMilestoneWithoutListeningHereIsDropped() = runTest { + // Regression: a backup brought a pending milestone onto a phone with no finished episodes. + context.userPreferencesDataStore.edit { it[intPreferencesKey("review_milestone_pending")] = 30 } + repository.markNpsSurveyFired() + + repository.syncReviewMilestonePending(0) + + assertNull(repository.reviewMilestonePending()) + assertFalse(repository.shouldShowReviewPrompt(isPlaying = false, installedAtMs = INSTALLED_LONG_AGO)) + } + + @Test + fun anInstallPromptedBeforeTheUpdateDoesNotRepeatItsMilestone() = runTest { + // Regression: builds before the used-milestone record could ask for milestone 5 again. + context.userPreferencesDataStore.edit { it[intPreferencesKey("review_prompt_count")] = 1 } + + repository.syncReviewMilestonePending(7) + assertNull(repository.reviewMilestonePending()) + + repository.syncReviewMilestonePending(16) + assertEquals(15, repository.reviewMilestonePending()) + } + @Test fun reviewMilestoneNotStoredAfterReview() = runTest { repository.markReviewed() diff --git a/docs/ANALYTICS_EVENT_GLOSSARY.md b/docs/ANALYTICS_EVENT_GLOSSARY.md index 7fefa96b1..7ee4ef2f8 100644 --- a/docs/ANALYTICS_EVENT_GLOSSARY.md +++ b/docs/ANALYTICS_EVENT_GLOSSARY.md @@ -138,7 +138,7 @@ Neither event carries an address. `show_id` is the Podcast Index ID or the hashe | `learn_card_action` | User acted on a Learn card | action:string; episode_id:string; podcast_id:string | recipe_id:string; position_index:int | none | | `learn_screen_viewed` | Learn tab/screen opened | — | cards_available:int | none | | `explore_screen_viewed` | Explore opened | — | tab:string | none | -| `explore_recommendation_tapped` | For You/trending card tapped (taps only) | — | podcast_id:string; position_index:int; rail:string | none | +| `explore_recommendation_tapped` | For You/trending card tapped (taps only) | — | podcast_id:string; episode_id:string; recipe_id:string; position_index:int; rail:string | none | | `explore_surface_impression` | Explore rail became visible | surface_component:string | items_count:int; episode_ids:list; recipe_ids:list | none | | `queue_modified` | Queue add/remove/reorder/clear (clear only when the listener chose it) | action:string | episode_id:string; recipe_id:string; podcast_id:string; queue_size:int; source:string; items_count:int | none | | `episode_liked_toggled` | Like/unlike episode | episode_id:string; podcast_id:string; is_liked:bool | recipe_id:string; surface:string | none | diff --git a/docs/analytics/event_glossary.csv b/docs/analytics/event_glossary.csv index 2b9f9055f..59544a51d 100644 --- a/docs/analytics/event_glossary.csv +++ b/docs/analytics/event_glossary.csv @@ -47,7 +47,7 @@ search_result_tapped,Search result tapped,User tapped a search result,B,Search learn_card_action,Learn card action,User acted on a Learn card,B,Learn session→play/queue; dismiss vs play,action:string:Y;episode_id:string:Y;recipe_id:string:N;podcast_id:string:Y;position_index:int:N,learn_card_dismissed;learn_card_queued;learn_card_info_clicked;learn_card_play_clicked;learn_card_podcast_clicked,discovery;learn,,none,action: play|queue|info|dismiss|podcast,always,0.93 learn_screen_viewed,Learn screen viewed,Learn tab/screen opened,B,Learn reach,cards_available:int:N,learn_screen_viewed;learn_screen_session,discovery;learn,,none,Session props optional; visits only; the leaving summary is screen_left,always,0.92 explore_screen_viewed,Explore screen viewed,Explore opened,B,Explore reach,tab:string:N,explore_screen_viewed;explore_screen_session,discovery;explore,,none,visits only; the leaving summary is screen_left,always,4.74 -explore_recommendation_tapped,Explore recommendation tapped,For You/trending card tapped,B,Explore→play,podcast_id:string:N;position_index:int:N;rail:string:N,explore_recommendation_card_tapped,discovery;explore,,none,Taps only; the rail being shown is explore_surface_impression,always,0.15 +explore_recommendation_tapped,Explore recommendation tapped,For You/trending card tapped,B,Explore→play,podcast_id:string:N;episode_id:string:N;recipe_id:string:N;position_index:int:N;rail:string:N,explore_recommendation_card_tapped,discovery;explore,,none,Taps only; the rail being shown is explore_surface_impression,always,0.15 explore_surface_impression,Explore surface impression,Explore rail became visible,B,Explore reach; impression to tap,surface_component:string:Y;items_count:int:N;episode_ids:list:N;recipe_ids:list:N,explore_recommendations_impression,discovery;explore,,none,Split from explore_recommendation_tapped (94% of its rows were impressions),always,3.93 queue_modified,Queue modified,Queue add/remove/reorder/clear,B,Intentional queue use,action:string:Y;episode_id:string:N;recipe_id:string:N;podcast_id:string:N;queue_size:int:N;source:string:N;items_count:int:N,queue_reordered;smart_queue_episode_skipped;smart_queue_refilled;lore_queue_conflict_shown;lore_queue_conflict_result,library;queue,,none,action: add|remove|reorder|clear|skip|refill|conflict_*,always,8.70 episode_liked_toggled,Episode liked toggled,Like/unlike episode,B,Liked replay,episode_id:string:Y;recipe_id:string:N;podcast_id:string:Y;is_liked:bool:Y;surface:string:N,,library,,none,New consolidated,always,0.22 diff --git a/feature/home/README.md b/feature/home/README.md index d9fe34ac4..9fcfa2ff4 100644 --- a/feature/home/README.md +++ b/feature/home/README.md @@ -124,7 +124,7 @@ Main Kotlin files should remain below 1000 lines; extracted Home feed, ViewModel ## Testing notes -- Review prompt: Home counts only episodes played to the end in boxlore since this install (`countFinishedSince` on `HomeListeningHistoryItem.finishedAt`, with `HomeViewModel.installedAtMs` from Android's install time), so marked-played episodes and a restore or sync of older listening never trigger it, and the sheet's "You've finished N episodes" uses the same count. A prompt is recorded when it shows (`markReviewPromptShown`), so closing boxlore mid-prompt can't bring it back, and it sends `engagement_prompt_shown`, not the NPS survey's trigger. The promoter hand-off also waits out the 2 quiet days after installing or importing. The category chips send `home_surface_tapped` (`category_filter`). `HomeListeningHistoryItemTest` covers the count. +- Review prompt: Home counts only episodes played to the end after this install (`countFinishedSince` on `HomeListeningHistoryItem.finishedAt`, with `HomeViewModel.installedAtMs` from Android's install time; synced listening from another phone after that counts too), so marked-played episodes and a restore of older listening never trigger it, and the sheet's "You've finished N episodes" uses the same count. A decided prompt waits in `ReviewPromptRecorder` and is recorded only once its sheet is on screen with Home resumed (`onReviewPromptDisplayed`: milestone, cooldown, promoter hand-off and `engagement_prompt_shown`, not the NPS survey's trigger); deciding only reserves the session (`reserveSessionForPrompt`), so a prompt decided behind another screen isn't used up if boxlore closes first (`ReviewPromptRecorderTest`). The promoter hand-off also waits out the 2 quiet days after installing or importing. The category chips send `home_surface_tapped` (`category_filter`). `HomeListeningHistoryItemTest` covers the count. - `HomeRecommendationsApplyTest` covers `applyHomeRecommendations`: a failed request keeps the restored picks and saves nothing; fresh picks replace them once per episode ID and title and are saved with their fallback flag; an empty answer that did not fail clears them. - `HomeSerialLogicTest` covers serial progress IDs and picks, and an oldest-first show switched to its feed continuing after the episode its old show was playing, found under the ID the show lists. `HomeSelectedPodcastLogicTest` checks such a show's last play, and `HomeCatchUpLogicTest` that its latest episode played under the old ID reads as played. - Recommendation entry points share an immutable columns/tablet layout value. Selected-show episode rendering has focused private playback, action and layout inputs; the parent crossfade retains each show's presentation and scroll owner. `HomeDiscoveryFeedSpacingTest` checks recommendation caps, order and episode/browse attribution while switching between the phone grid and tablet rail. `HomeConnectedRailTest` also checks selected-show playback, episode and browse callbacks across sidebar resizing, loading/empty-to-content recovery, and a 200-show library keeping a bounded lazy sidebar where every show stays reachable. `HomeArtworkHandoffTest` also checks that hidden tablet artwork waits, phone round trips keep the tablet target, and artwork sized only by its own content loads. diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeListeningHistoryItem.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeListeningHistoryItem.kt index 075ad4c5b..d67c9c9bc 100644 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeListeningHistoryItem.kt +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeListeningHistoryItem.kt @@ -17,8 +17,10 @@ data class HomeListeningHistoryItem( ) /** - * Episodes played to the end in boxlore since it was installed on this device. Marked-played rows - * and rows restored or synced from before the install don't count. + * Episodes played to the end after boxlore was installed here, on this phone or another one synced to + * the same account. Marked-played rows and listening from before the install don't count; a first + * sign-in or a library import starts the review prompt's quiet days, so synced history can't + * trigger it at once. */ internal fun List.countFinishedSince(installedAtMs: Long): Int = count { it.isCompleted && it.finishedAt?.let { at -> at >= installedAtMs } == true } diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeScreen.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeScreen.kt index 25d566d4d..e68810a57 100644 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeScreen.kt +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import cx.aswin.boxlore.core.designsystem.analytics.TrackScreenView @@ -155,6 +156,7 @@ data class HomeScreenCallbacks( val onForceReviewPrompt: () -> Unit = {}, val onForceSurveyNps: () -> Unit = {}, val onDismissReviewPrompt: () -> Unit = {}, + val onReviewPromptDisplayed: () -> Unit = {}, val onDismissPostReview: () -> Unit = {}, val onDismissFeedback: () -> Unit = {}, val onNavigateToPlayStoreReview: () -> Unit = {}, @@ -352,6 +354,7 @@ fun HomeRoute( onForceReviewPrompt = viewModel::forceReviewPrompt, onForceSurveyNps = viewModel::forceSurveyNps, onDismissReviewPrompt = viewModel::dismissReviewPrompt, + onReviewPromptDisplayed = viewModel::onReviewPromptDisplayed, onDismissPostReview = viewModel::dismissPostReview, onDismissFeedback = viewModel::dismissFeedback, onNavigateToPlayStoreReview = { @@ -490,6 +493,10 @@ private fun HomeScreenBottomSheets( onDismissChangePodcastSheet: () -> Unit, ) { if (sheets.showReviewPrompt) { + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(lifecycleOwner) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { callbacks.onReviewPromptDisplayed() } + } cx.aswin.boxlore.feature.home.components.ReviewPromptSheet( completedCount = uiState.completedEpisodeCount, variant = sheets.reviewPromptVariant, diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModel.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModel.kt index 897049415..e8b93d49b 100644 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModel.kt +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModel.kt @@ -100,6 +100,7 @@ class HomeViewModel( internal val _showReviewPrompt = MutableStateFlow(false) val showReviewPrompt = _showReviewPrompt.asStateFlow() + internal val reviewPromptRecorder = ReviewPromptRecorder() internal val _reviewPromptVariant = MutableStateFlow(cx.aswin.boxlore.feature.home.components.ReviewPromptVariant.Milestone) @@ -584,6 +585,25 @@ class HomeViewModel( playbackRepository.togglePlayPause(entryPointContext) } + /** + * The review sheet is on screen with Home resumed: the prompt's milestone, cooldown and promoter + * hand-off are used up now, and `engagement_prompt_shown` sent once. + */ + fun onReviewPromptDisplayed() { + val record = reviewPromptRecorder.displayed() ?: return + viewModelScope.launch { + engagementCoordinator.recordProactivePromptShown() + if (record.isPromoterHandoff) engagementCoordinator.clearPromoterReviewPending() + userPrefs.markReviewPromptShown() + cx.aswin.boxlore.core.analytics.AnalyticsHelper.trackEngagementPromptShown( + promptType = record.promptType, + source = record.source, + completedEpisodes = record.completedEpisodes, + npsScore = record.npsScore, + ) + } + } + fun dismissReviewPrompt() { _showReviewPrompt.value = false _reviewPromptVariant.value = diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModelLoadData.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModelLoadData.kt index df1ba6a28..e9aabbc8c 100644 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModelLoadData.kt +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeViewModelLoadData.kt @@ -216,14 +216,15 @@ internal fun HomeViewModel.loadData() { if (!_showReviewPrompt.value && !_showFeedback.value && !_showPostReview.value) { val isPlayingNow = playerState.value.isPlaying if (engagementCoordinator.shouldShowPromoterReview(isPlayingNow, installedAtMs)) { - engagementCoordinator.recordProactivePromptShown() - engagementCoordinator.clearPromoterReviewPending() - // Counted when shown, so closing the app mid-prompt can't bring it back. - userPrefs.markReviewPromptShown() - cx.aswin.boxlore.core.analytics.AnalyticsHelper.trackEngagementPromptShown( - promptType = "promoter_review", - source = "nps_handoff", - npsScore = userPrefs.npsLastScore(), + // Recorded once the sheet is on screen (onReviewPromptDisplayed). + engagementCoordinator.reserveSessionForPrompt() + reviewPromptRecorder.decided( + ReviewPromptRecord( + promptType = "promoter_review", + source = "nps_handoff", + npsScore = userPrefs.npsLastScore(), + isPromoterHandoff = true, + ), ) _reviewPromptVariant.value = cx.aswin.boxlore.feature.home.components.ReviewPromptVariant.PromoterHandoff @@ -232,12 +233,13 @@ internal fun HomeViewModel.loadData() { userPrefs.syncReviewMilestonePending(completedCount) val shouldPrompt = userPrefs.shouldShowReviewPrompt(isPlayingNow, installedAtMs) if (shouldPrompt) { - engagementCoordinator.recordProactivePromptShown() - userPrefs.markReviewPromptShown() - cx.aswin.boxlore.core.analytics.AnalyticsHelper.trackEngagementPromptShown( - promptType = "milestone_review", - source = "episode_milestone", - completedEpisodes = completedCount, + engagementCoordinator.reserveSessionForPrompt() + reviewPromptRecorder.decided( + ReviewPromptRecord( + promptType = "milestone_review", + source = "episode_milestone", + completedEpisodes = completedCount, + ), ) _reviewPromptVariant.value = cx.aswin.boxlore.feature.home.components.ReviewPromptVariant.Milestone diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/ReviewPromptRecorder.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/ReviewPromptRecorder.kt new file mode 100644 index 000000000..3a64671d3 --- /dev/null +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/ReviewPromptRecorder.kt @@ -0,0 +1,26 @@ +package cx.aswin.boxlore.feature.home + +import java.util.concurrent.atomic.AtomicReference + +/** What a decided review prompt reports and uses up once the listener can see it. */ +internal data class ReviewPromptRecord( + val promptType: String, + val source: String, + val completedEpisodes: Int? = null, + val npsScore: Int? = null, + val isPromoterHandoff: Boolean = false, +) + +/** + * Holds a decided review prompt until its sheet is on screen. Home can decide one while another + * screen is on top or the app is in the background; recording it then would use up the milestone + * (and its cooldown) for a prompt nobody saw if boxlore closed before Home came back. + */ +internal class ReviewPromptRecorder { + private val decided = AtomicReference(null) + + fun decided(record: ReviewPromptRecord) = decided.set(record) + + /** The decided prompt, once: null when it was already recorded or nothing was decided. */ + fun displayed(): ReviewPromptRecord? = decided.getAndSet(null) +} diff --git a/feature/home/src/test/java/cx/aswin/boxlore/feature/home/ReviewPromptRecorderTest.kt b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/ReviewPromptRecorderTest.kt new file mode 100644 index 000000000..0922ec367 --- /dev/null +++ b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/ReviewPromptRecorderTest.kt @@ -0,0 +1,25 @@ +package cx.aswin.boxlore.feature.home + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class ReviewPromptRecorderTest { + @Test + fun `a decided prompt is recorded once, when it shows`() { + // Regression: the milestone was used up when the prompt was decided, even if boxlore closed + // before Home (and the sheet) came back. + val recorder = ReviewPromptRecorder() + val record = ReviewPromptRecord(promptType = "milestone_review", source = "episode_milestone", completedEpisodes = 5) + + recorder.decided(record) + + assertEquals(record, recorder.displayed()) + assertNull(recorder.displayed()) + } + + @Test + fun `a prompt shown without a decision records nothing`() { + assertNull(ReviewPromptRecorder().displayed()) + } +} diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsScreen.kt index 635a89325..1ed5a643b 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsScreen.kt @@ -773,9 +773,7 @@ internal fun AppearanceActions.trackedForAnalytics(currentState: () -> Appearanc onSetHeroTideProgress(shape) }, onApplyTheme = { selection, custom -> - // A theme is its brand and surface together; either changing is a change. - val state = currentState() - if (state == null || state.currentThemeBrand != selection.brand || state.currentSurfaceStyle != selection.surfaceStyle) { + if (currentState()?.hasSameTheme(selection) != true) { AnalyticsHelper.trackSettingsInteraction("surface_style_changed", selection.surfaceStyle) } onApplyTheme(selection, custom) diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceSettingsPage.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceSettingsPage.kt index acea4ef8c..83f329cba 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceSettingsPage.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AppearanceSettingsPage.kt @@ -74,7 +74,12 @@ data class AppearanceUiState( val fullPlayerStyle: String = FullPlayerStyle.STANDARD, /** Read-only here: Navigation hides Explore's opening tab without For you. Library > Recommendations sets it. */ val discoverySections: DiscoverySections = DiscoverySections(), -) +) { + /** A theme is its brand, surface and wallpaper colours together; [selection] is the same only if all match. */ + fun hasSameTheme(selection: ThemeSelection): Boolean = currentThemeBrand == selection.brand && + currentSurfaceStyle == selection.surfaceStyle && + isDynamicColorEnabled == selection.wallpaperColors +} /** Callbacks for [AppearanceSettingsPage], grouped to keep the page's parameter count small. */ data class AppearanceActions( From e054596436c3d28c3831372e9788bfd0366160a7 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Sun, 1 Nov 2026 19:26:34 +0530 Subject: [PATCH 4/9] style: fix ktlint findings across modules --- .../aswin/boxlore/sync/FeedFirstSwitchTest.kt | 2 +- .../aswin/boxlore/sync/FeedFixSwitchesTest.kt | 12 ++++- .../aswin/boxlore/sync/SavedKillSwitchTest.kt | 2 +- .../aswin/boxlore/ui/HistoryTabsStateTest.kt | 4 +- .../boxlore/ui/HistoryTimelineGroupingTest.kt | 2 +- .../aswin/boxlore/ui/StoryPlayerResizeTest.kt | 6 +-- .../cx/aswin/boxlore/ui/TabletAppShellTest.kt | 1 + .../database/ListeningInsightsMaintenance.kt | 1 + .../auto/AutoPlaybackResumptionHandler.kt | 1 + .../playback/ListeningSessionDetailsTest.kt | 2 +- .../SwitchedShowPlaybackWritesTest.kt | 4 +- .../boxlore/core/rss/RssPodcastRepository.kt | 44 +++++++++---------- .../core/rss/EpisodeSupplementTipLogicTest.kt | 10 ++++- .../boxlore/core/rss/ShowSwitchRoomTest.kt | 1 - 14 files changed, 53 insertions(+), 39 deletions(-) diff --git a/app/src/test/java/cx/aswin/boxlore/sync/FeedFirstSwitchTest.kt b/app/src/test/java/cx/aswin/boxlore/sync/FeedFirstSwitchTest.kt index 345b0a414..2502587e2 100644 --- a/app/src/test/java/cx/aswin/boxlore/sync/FeedFirstSwitchTest.kt +++ b/app/src/test/java/cx/aswin/boxlore/sync/FeedFirstSwitchTest.kt @@ -1,5 +1,6 @@ package cx.aswin.boxlore.sync +import java.io.File import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -7,7 +8,6 @@ import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder -import java.io.File class FeedFirstSwitchTest { @get:Rule diff --git a/app/src/test/java/cx/aswin/boxlore/sync/FeedFixSwitchesTest.kt b/app/src/test/java/cx/aswin/boxlore/sync/FeedFixSwitchesTest.kt index 3f5daec21..5870ce3e9 100644 --- a/app/src/test/java/cx/aswin/boxlore/sync/FeedFixSwitchesTest.kt +++ b/app/src/test/java/cx/aswin/boxlore/sync/FeedFixSwitchesTest.kt @@ -9,7 +9,11 @@ class FeedFixSwitchesTest { @Test fun aFixRunsUnlessItsOffFlagIsOn() { val asked = mutableListOf() - assertTrue(FeedFixSwitches.isOn(FeedFixSwitches.MATCH_BASELINE_OFF) { asked += it; false }) + val recordAsked: (String) -> Boolean = { flag -> + asked += flag + false + } + assertTrue(FeedFixSwitches.isOn(FeedFixSwitches.MATCH_BASELINE_OFF, recordAsked)) assertFalse(FeedFixSwitches.isOn(FeedFixSwitches.MATCH_BASELINE_OFF) { true }) assertEquals(listOf("feed-fix-match-baseline-off"), asked) } @@ -29,7 +33,11 @@ class FeedFixSwitchesTest { ) flags.forEach { (flag, key) -> val asked = mutableListOf() - assertTrue(FeedFixSwitches.isOn(flag) { asked += it; false }) + val recordAsked: (String) -> Boolean = { asking -> + asked += asking + false + } + assertTrue(FeedFixSwitches.isOn(flag, recordAsked)) assertTrue(FeedFixSwitches.isOn(flag) { error("PostHog not started") }) assertFalse(FeedFixSwitches.isOn(flag) { it == key }) assertEquals(listOf(key), asked) diff --git a/app/src/test/java/cx/aswin/boxlore/sync/SavedKillSwitchTest.kt b/app/src/test/java/cx/aswin/boxlore/sync/SavedKillSwitchTest.kt index b0546d6bd..32a35a4fc 100644 --- a/app/src/test/java/cx/aswin/boxlore/sync/SavedKillSwitchTest.kt +++ b/app/src/test/java/cx/aswin/boxlore/sync/SavedKillSwitchTest.kt @@ -1,5 +1,6 @@ package cx.aswin.boxlore.sync +import java.io.File import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -7,7 +8,6 @@ import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder -import java.io.File class SavedKillSwitchTest { @get:Rule diff --git a/app/src/test/java/cx/aswin/boxlore/ui/HistoryTabsStateTest.kt b/app/src/test/java/cx/aswin/boxlore/ui/HistoryTabsStateTest.kt index 59c5d4511..c6b6cf49f 100644 --- a/app/src/test/java/cx/aswin/boxlore/ui/HistoryTabsStateTest.kt +++ b/app/src/test/java/cx/aswin/boxlore/ui/HistoryTabsStateTest.kt @@ -2,6 +2,7 @@ package cx.aswin.boxlore.ui import android.app.Application import androidx.activity.ComponentActivity +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.semantics.getOrNull import androidx.compose.ui.test.SemanticsNodeInteraction @@ -16,7 +17,7 @@ import cx.aswin.boxlore.feature.history.HistoryViewModel import cx.aswin.boxlore.feature.history.InsightsUiState import cx.aswin.boxlore.feature.history.TimelineUiState import cx.aswin.boxlore.feature.history.insights.INSIGHTS_LIST_TAG -import androidx.compose.runtime.CompositionLocalProvider +import java.time.ZoneId import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Rule @@ -25,7 +26,6 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode -import java.time.ZoneId /** Insights keeps its place while Timeline is open: back on Insights, the list is where it was left. */ @RunWith(RobolectricTestRunner::class) diff --git a/app/src/test/java/cx/aswin/boxlore/ui/HistoryTimelineGroupingTest.kt b/app/src/test/java/cx/aswin/boxlore/ui/HistoryTimelineGroupingTest.kt index d65a1558b..776e8916c 100644 --- a/app/src/test/java/cx/aswin/boxlore/ui/HistoryTimelineGroupingTest.kt +++ b/app/src/test/java/cx/aswin/boxlore/ui/HistoryTimelineGroupingTest.kt @@ -28,6 +28,7 @@ import cx.aswin.boxlore.feature.history.HistoryViewModel import cx.aswin.boxlore.feature.history.TimelineUiState import cx.aswin.boxlore.feature.history.timeline.TimelineFilter import cx.aswin.boxlore.feature.history.timeline.TimelineGrouping +import java.time.ZoneId import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Rule @@ -36,7 +37,6 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode -import java.time.ZoneId /** * Days|Shows on Timeline: Jump back in, above the toggle, folds away and back, so the toggle slides diff --git a/app/src/test/java/cx/aswin/boxlore/ui/StoryPlayerResizeTest.kt b/app/src/test/java/cx/aswin/boxlore/ui/StoryPlayerResizeTest.kt index 51a9de097..eff860cde 100644 --- a/app/src/test/java/cx/aswin/boxlore/ui/StoryPlayerResizeTest.kt +++ b/app/src/test/java/cx/aswin/boxlore/ui/StoryPlayerResizeTest.kt @@ -24,6 +24,9 @@ import cx.aswin.boxlore.feature.history.story.STORY_FRAME_TAG import cx.aswin.boxlore.feature.history.story.StoryCard import cx.aswin.boxlore.feature.history.story.StoryLogic import cx.aswin.boxlore.feature.history.story.StoryPlayer +import java.time.LocalDate +import java.time.YearMonth +import java.time.ZoneOffset import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue @@ -33,9 +36,6 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode -import java.time.LocalDate -import java.time.YearMonth -import java.time.ZoneOffset /** * The open story keeps its place while the window changes: scrolled down in a short window (a phone diff --git a/app/src/test/java/cx/aswin/boxlore/ui/TabletAppShellTest.kt b/app/src/test/java/cx/aswin/boxlore/ui/TabletAppShellTest.kt index 6a3110090..facbf5735 100644 --- a/app/src/test/java/cx/aswin/boxlore/ui/TabletAppShellTest.kt +++ b/app/src/test/java/cx/aswin/boxlore/ui/TabletAppShellTest.kt @@ -53,6 +53,7 @@ class TabletAppShellTest { private val allowed = mutableStateOf(true) private val navigationVisible = mutableStateOf(true) private var paneBounds: cx.aswin.boxlore.core.designsystem.layout.BoxloreLayoutBounds? = null + /** Each rail tap with what the shell reported: whether it only closed a detail covering the primary pane. */ private val tabTaps = mutableListOf>() private lateinit var navigation: TabletDetailNavigation diff --git a/core/database/src/main/java/cx/aswin/boxlore/core/database/ListeningInsightsMaintenance.kt b/core/database/src/main/java/cx/aswin/boxlore/core/database/ListeningInsightsMaintenance.kt index b2b297efb..d8a00077e 100644 --- a/core/database/src/main/java/cx/aswin/boxlore/core/database/ListeningInsightsMaintenance.kt +++ b/core/database/src/main/java/cx/aswin/boxlore/core/database/ListeningInsightsMaintenance.kt @@ -144,6 +144,7 @@ class ListeningInsightsMaintenance( recordPushedDays(pushed) } else { val sentTotals = origins.pushed.flatMap { pushes.getPushedDays(it) }.associate { Triple(it.deviceId, it.localDay, it.episodeId) to it.totals } + // The totals this phone sent where the cloud keeps none, else just the release date time it sent. fun ListeningDayTotals.asCloudHolds(sent: ListeningDayTotals) = if (cloudStoresTotals) copy(releaseKnownMs = sent.releaseKnownMs) else sent recordPushedDays(pushed.map { day -> sentTotals[day.key]?.let { day.copy(totals = day.totals.asCloudHolds(it)) } ?: day }) diff --git a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoPlaybackResumptionHandler.kt b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoPlaybackResumptionHandler.kt index 9ed4043e4..e032f6eb4 100644 --- a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoPlaybackResumptionHandler.kt +++ b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoPlaybackResumptionHandler.kt @@ -34,6 +34,7 @@ internal class AutoPlaybackResumptionHandler( oldName = PrefsFileMigrator.LegacyFiles.PLAYER, ) } + /** * The last played episode as its projected state, so an episode stored under several IDs * resumes from its shared progress (own-episode-IDs P4, plan section 8). diff --git a/core/playback/src/test/java/cx/aswin/boxlore/core/playback/ListeningSessionDetailsTest.kt b/core/playback/src/test/java/cx/aswin/boxlore/core/playback/ListeningSessionDetailsTest.kt index cab676d4f..2dd3e5064 100644 --- a/core/playback/src/test/java/cx/aswin/boxlore/core/playback/ListeningSessionDetailsTest.kt +++ b/core/playback/src/test/java/cx/aswin/boxlore/core/playback/ListeningSessionDetailsTest.kt @@ -3,10 +3,10 @@ package cx.aswin.boxlore.core.playback import android.media.AudioDeviceInfo import cx.aswin.boxlore.core.database.ListeningSessionValues import cx.aswin.boxlore.core.playback.service.ListeningOutputRoute +import java.time.ZoneOffset import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test -import java.time.ZoneOffset /** What a timed play records besides its time (Room v40): how it started, where it was heard, speed time, release date. */ class ListeningSessionDetailsTest { diff --git a/core/playback/src/test/java/cx/aswin/boxlore/core/playback/SwitchedShowPlaybackWritesTest.kt b/core/playback/src/test/java/cx/aswin/boxlore/core/playback/SwitchedShowPlaybackWritesTest.kt index 55733bc9e..5b5eda18b 100644 --- a/core/playback/src/test/java/cx/aswin/boxlore/core/playback/SwitchedShowPlaybackWritesTest.kt +++ b/core/playback/src/test/java/cx/aswin/boxlore/core/playback/SwitchedShowPlaybackWritesTest.kt @@ -4,10 +4,10 @@ import android.content.Context import androidx.test.core.app.ApplicationProvider import cx.aswin.boxlore.core.database.BoxLoreDatabase import cx.aswin.boxlore.core.database.EpisodeAliasProvenance +import cx.aswin.boxlore.core.database.ListeningSessionEntity +import cx.aswin.boxlore.core.database.PlayedShowEntity import cx.aswin.boxlore.core.database.PodcastEntity import cx.aswin.boxlore.core.database.episodeListenerStore -import cx.aswin.boxlore.core.database.PlayedShowEntity -import cx.aswin.boxlore.core.database.ListeningSessionEntity import cx.aswin.boxlore.core.model.ListeningPeriod import cx.aswin.boxlore.core.playback.EpisodeAliasFixtures.SHOW import cx.aswin.boxlore.core.playback.EpisodeAliasFixtures.alias diff --git a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/RssPodcastRepository.kt b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/RssPodcastRepository.kt index 79192f815..d1c6745ad 100644 --- a/core/rss/src/main/java/cx/aswin/boxlore/core/rss/RssPodcastRepository.kt +++ b/core/rss/src/main/java/cx/aswin/boxlore/core/rss/RssPodcastRepository.kt @@ -206,30 +206,28 @@ class RssPodcastRepository private constructor( } } - private suspend fun refreshEpisodesOnce(podcastId: String, manual: Boolean): Result { - return try { - val row = podcastDao.getPodcast(podcastId) ?: error(ERROR_RSS_SUBSCRIPTION_NOT_FOUND) - val outcome = episodeCatalog.refresh( - cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort.RefreshRequest( - podcastId, - row.feedUrl.orEmpty(), - reason = if (manual) { - cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort.RefreshReason.MANUAL - } else { - cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort.RefreshReason.NORMAL - }, - ), - ) - when (outcome) { - is cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort.RefreshOutcome.Success -> Result.success(outcome.itemCount) - is cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort.RefreshOutcome.Unchanged -> Result.success(0) - is cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort.RefreshOutcome.Failure -> Result.failure(IllegalStateException(outcome.message)) - } - } catch (error: kotlinx.coroutines.CancellationException) { - throw error - } catch (error: Exception) { - Result.failure(error) + private suspend fun refreshEpisodesOnce(podcastId: String, manual: Boolean): Result = try { + val row = podcastDao.getPodcast(podcastId) ?: error(ERROR_RSS_SUBSCRIPTION_NOT_FOUND) + val outcome = episodeCatalog.refresh( + cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort.RefreshRequest( + podcastId, + row.feedUrl.orEmpty(), + reason = if (manual) { + cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort.RefreshReason.MANUAL + } else { + cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort.RefreshReason.NORMAL + }, + ), + ) + when (outcome) { + is cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort.RefreshOutcome.Success -> Result.success(outcome.itemCount) + is cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort.RefreshOutcome.Unchanged -> Result.success(0) + is cx.aswin.boxlore.core.domain.ports.LocalEpisodeCatalogPort.RefreshOutcome.Failure -> Result.failure(IllegalStateException(outcome.message)) } + } catch (error: kotlinx.coroutines.CancellationException) { + throw error + } catch (error: Exception) { + Result.failure(error) } /** Existing imports are repaired in place and all feeds fetch episodes, including HEAD-less feeds. */ diff --git a/core/rss/src/test/java/cx/aswin/boxlore/core/rss/EpisodeSupplementTipLogicTest.kt b/core/rss/src/test/java/cx/aswin/boxlore/core/rss/EpisodeSupplementTipLogicTest.kt index 15565f659..eb757903d 100644 --- a/core/rss/src/test/java/cx/aswin/boxlore/core/rss/EpisodeSupplementTipLogicTest.kt +++ b/core/rss/src/test/java/cx/aswin/boxlore/core/rss/EpisodeSupplementTipLogicTest.kt @@ -158,8 +158,14 @@ class EpisodeSupplementTipLogicTest { .copy(description = "From the feed", imageUrl = "https://img/feed.jpg") val full = Episode( - id = "42", title = "Fresh", description = "From Podcast Index", audioUrl = "https://cdn.example/fresh.mp3", - imageUrl = "https://img/pi.jpg", duration = 1800, podcastId = "pi-1", publishedDate = 200L, + id = "42", + title = "Fresh", + description = "From Podcast Index", + audioUrl = "https://cdn.example/fresh.mp3", + imageUrl = "https://img/pi.jpg", + duration = 1800, + podcastId = "pi-1", + publishedDate = 200L, ) val tip = EpisodeSupplementTipLogic.resolveNewestTip(feed, "pi-1", listOf(full), null, null, null, null) diff --git a/core/rss/src/test/java/cx/aswin/boxlore/core/rss/ShowSwitchRoomTest.kt b/core/rss/src/test/java/cx/aswin/boxlore/core/rss/ShowSwitchRoomTest.kt index 263639dbb..611c7904d 100644 --- a/core/rss/src/test/java/cx/aswin/boxlore/core/rss/ShowSwitchRoomTest.kt +++ b/core/rss/src/test/java/cx/aswin/boxlore/core/rss/ShowSwitchRoomTest.kt @@ -18,7 +18,6 @@ import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test From ce6d00ff73943635d46d9c02f587cc24ef53124e Mon Sep 17 00:00:00 2001 From: Aswin C Date: Sun, 1 Nov 2026 19:29:54 +0530 Subject: [PATCH 5/9] fix(analytics): tighten review, car label and batch queue events store_review_opened is sent once Play hands back a review flow. A car request that finds nothing to play drops its label, so a later phone play can't take it. A batch add reports what was added even if a later add fails. FeedbackViewModelTest covers feedback_sent. --- .../boxlore/navigation/NavGraphWiring.kt | 5 +- .../service/auto/AutoBrowseLibraryCallback.kt | 68 +++++++++++-------- docs/ANALYTICS_EVENT_GLOSSARY.md | 2 +- docs/analytics/event_glossary.csv | 2 +- .../feature/info/PodcastInfoEpisodeActions.kt | 27 ++++---- .../feedback/FeedbackViewModelTest.kt | 33 +++++++++ 6 files changed, 92 insertions(+), 45 deletions(-) diff --git a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphWiring.kt b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphWiring.kt index 89a79ddac..5691f2a5e 100644 --- a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphWiring.kt +++ b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphWiring.kt @@ -393,11 +393,12 @@ internal fun miniPlayerBottomPadding(isPlayerVisible: Boolean): androidx.compose internal fun launchInAppReview(context: android.content.Context) { val activity = context as? androidx.activity.ComponentActivity ?: return - cx.aswin.boxlore.core.analytics.AnalyticsHelper - .trackStoreReviewOpened(method = "in_app_review", source = "review_prompt") val reviewManager = com.google.android.play.core.review.ReviewManagerFactory.create(activity) reviewManager.requestReviewFlow().addOnCompleteListener { task -> if (task.isSuccessful) { + // Play can still skip showing the sheet (its quota), so this means boxlore launched the flow. + cx.aswin.boxlore.core.analytics.AnalyticsHelper + .trackStoreReviewOpened(method = "in_app_review", source = "review_prompt") reviewManager.launchReviewFlow(activity, task.result) } } diff --git a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseLibraryCallback.kt b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseLibraryCallback.kt index 611190140..dc40f4f7a 100644 --- a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseLibraryCallback.kt +++ b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseLibraryCallback.kt @@ -13,6 +13,7 @@ import com.google.common.collect.ImmutableList import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture import cx.aswin.boxlore.core.analytics.AnalyticsHelper +import cx.aswin.boxlore.core.analytics.PendingEntryPoint import cx.aswin.boxlore.core.database.episodeListenerStore import cx.aswin.boxlore.core.playback.service.CarConnectionState import kotlinx.coroutines.guava.future @@ -627,41 +628,50 @@ internal class AutoBrowseLibraryCallback( val fromCar = isAndroidAuto(mediaSession, controller) return host.serviceScope.future { - if (mediaItems.size > 1) { - labelCarPlay(fromCar, "android_auto_play_all") - return@future mediaItems.map { mediaResolver.resolveMediaItem(it) }.toMutableList() - } + // A car request that found nothing to play drops its label, so a later phone play can't take it. + itemsToAdd(mediaSession, mediaItems, fromCar).also { if (fromCar && it.isEmpty()) PendingEntryPoint.consume() } + } + } - val selectedItem = mediaItems.first() - val searchQuery = selectedItem.requestMetadata.searchQuery + private suspend fun itemsToAdd( + mediaSession: MediaSession, + mediaItems: MutableList, + fromCar: Boolean, + ): MutableList { + if (mediaItems.size > 1) { + labelCarPlay(fromCar, "android_auto_play_all") + return mediaItems.map { mediaResolver.resolveMediaItem(it) }.toMutableList() + } - if (!searchQuery.isNullOrBlank()) { - android.util.Log.d("AutoBrowse", "Voice play request: '$searchQuery'") - labelCarPlay(fromCar, "android_auto_voice") - return@future voiceSearch.handleVoiceSearchQuery(searchQuery, fromCar) - } + val selectedItem = mediaItems.first() + val searchQuery = selectedItem.requestMetadata.searchQuery - when (selectedItem.mediaId) { - PLAY_ALL_NEW_EPISODES_ID -> return@future voiceSearch.handlePlayAllNewEpisodes(fromCar) - AutoBrowseContract.PLAY_ALL_LIKED_ID -> return@future voiceSearch.handlePlayAllLiked(fromCar) - AutoBrowseContract.PLAY_ALL_DOWNLOADS_ID -> return@future voiceSearch.handlePlayAllDownloads(fromCar) - AutoBrowseContract.PLAY_ALL_DRIVE_ID -> return@future voiceSearch.handlePlayAllDrivePicks(fromCar) - AutoBrowseContract.PLAY_ALL_MIXTAPE_ID -> return@future voiceSearch.handlePlayAllMixtape(fromCar) - } + if (!searchQuery.isNullOrBlank()) { + android.util.Log.d("AutoBrowse", "Voice play request: '$searchQuery'") + labelCarPlay(fromCar, "android_auto_voice") + return voiceSearch.handleVoiceSearchQuery(searchQuery, fromCar) + } - val source = - selectedItem.mediaMetadata.extras - ?.getString(AutoBrowseContract.EXTRA_SOURCE) - ?: AutoBrowseContract.SOURCE_DISCOVER - if (selectedItem.mediaId.startsWith(AutoBrowseContract.QUEUE_PREFIX)) { - return@future voiceSearch.handlePlayFromQueue(selectedItem.mediaId.stripEpisodePrefix(), fromCar) - } - if (source == AutoBrowseContract.SOURCE_MIXTAPE) { - return@future voiceSearch.handlePlayFromMixtape(selectedItem.mediaId.stripEpisodePrefix(), fromCar) - } + when (selectedItem.mediaId) { + PLAY_ALL_NEW_EPISODES_ID -> return voiceSearch.handlePlayAllNewEpisodes(fromCar) + AutoBrowseContract.PLAY_ALL_LIKED_ID -> return voiceSearch.handlePlayAllLiked(fromCar) + AutoBrowseContract.PLAY_ALL_DOWNLOADS_ID -> return voiceSearch.handlePlayAllDownloads(fromCar) + AutoBrowseContract.PLAY_ALL_DRIVE_ID -> return voiceSearch.handlePlayAllDrivePicks(fromCar) + AutoBrowseContract.PLAY_ALL_MIXTAPE_ID -> return voiceSearch.handlePlayAllMixtape(fromCar) + } - handleSingleMediaItemSelection(mediaSession, selectedItem, source, fromCar) + val source = + selectedItem.mediaMetadata.extras + ?.getString(AutoBrowseContract.EXTRA_SOURCE) + ?: AutoBrowseContract.SOURCE_DISCOVER + if (selectedItem.mediaId.startsWith(AutoBrowseContract.QUEUE_PREFIX)) { + return voiceSearch.handlePlayFromQueue(selectedItem.mediaId.stripEpisodePrefix(), fromCar) } + if (source == AutoBrowseContract.SOURCE_MIXTAPE) { + return voiceSearch.handlePlayFromMixtape(selectedItem.mediaId.stripEpisodePrefix(), fromCar) + } + + return handleSingleMediaItemSelection(mediaSession, selectedItem, source, fromCar) } private suspend fun handleSingleMediaItemSelection( diff --git a/docs/ANALYTICS_EVENT_GLOSSARY.md b/docs/ANALYTICS_EVENT_GLOSSARY.md index 7ee4ef2f8..68f908218 100644 --- a/docs/ANALYTICS_EVENT_GLOSSARY.md +++ b/docs/ANALYTICS_EVENT_GLOSSARY.md @@ -152,7 +152,7 @@ Neither event carries an address. `show_id` is the Podcast Index ID or the hashe | `backup_restore_result` | Library backup or restore finished | action:string; success:bool | item_count:int; format:string; error_message:string | none | | `feedback_submitted` | NPS survey trigger (eligible or manual); the survey shows on any of these | feedback_type:string | score:int; source:string | none | | `engagement_prompt_shown` | A Home review prompt was shown | prompt_type:string; source:string | completed_episodes:int; nps_score:int | none | -| `store_review_opened` | Play review sheet or store page opened from boxlore | method:string; source:string | — | none | +| `store_review_opened` | boxlore launched Play's review flow (Play may still not show the sheet) or opened the store page | method:string; source:string | — | none | | `feedback_sent` | In-app feedback form accepted by the server | category:string; with_email:bool; with_diagnostics:bool | — | none | | `library_destination_viewed` | Library hub or sub-destination opened | destination:string | item_count:int | none | | `podcast_detail_viewed` | Podcast info screen opened | podcast_id:string | is_subscribed:bool | none | diff --git a/docs/analytics/event_glossary.csv b/docs/analytics/event_glossary.csv index 59544a51d..ab1029dbe 100644 --- a/docs/analytics/event_glossary.csv +++ b/docs/analytics/event_glossary.csv @@ -61,7 +61,7 @@ share_content,Share content,User shared podcast/episode/app,B,Share/deeplink gro backup_restore_result,Backup/restore result,Library backup or restore finished,B,Import/migrate retention,action:string:Y;success:bool:Y;item_count:int:N;format:string:N;error_message:string:N,,library,,none,action: backup|restore|opml_export|opml_import,always,0.08 feedback_submitted,Feedback submitted,NPS survey trigger (eligible or manual),B,Promoters; delight,feedback_type:string:Y;score:int:N;source:string:N,survey_nps_eligible;survey_nps_manual_trigger;promoter_review_handoff;feedback_submitted,company_health;delight,,none,Only nps_eligible and nps_manual: the NPS survey shows on any feedback_submitted,always,0.05 engagement_prompt_shown,Engagement prompt shown,A Home review prompt was shown,B,Review funnel reach,prompt_type:string:Y;source:string:Y;completed_episodes:int:N;nps_score:int:N,,company_health;delight,,none,promoter_review or milestone_review; completed_episodes counts episodes finished on this install,always,0.05 -store_review_opened,Store review opened,Play review sheet or store page opened from boxlore,B,Review funnel,method:string:Y;source:string:Y,,company_health;delight,,none,method in_app_review or store_page,always,0.02 +store_review_opened,Store review opened,Play review flow launched (Play may not show it) or store page opened,B,Review funnel,method:string:Y;source:string:Y,,company_health;delight,,none,method in_app_review or store_page,always,0.02 feedback_sent,Feedback sent,In-app feedback form accepted by the server,B,Feedback volume,category:string:Y;with_email:bool:Y;with_diagnostics:bool:Y,,company_health;delight,,none,Never the message text or email,always,0.02 library_destination_viewed,Library destination viewed,Library hub or sub-destination opened,B,Library destinations that drive listening,destination:string:Y;item_count:int:N,library_hub_viewed;library_hub_session;library_subscriptions_viewed;library_subscriptions_session;library_liked_viewed;library_liked_session;library_downloads_viewed;library_downloads_session;library_history_viewed;library_history_session;library_subscriptions_sort_changed;library_subscriptions_layout_toggled;library_subscriptions_genre_filtered,library,,none,destination: hub|subscriptions|liked|downloads|history; visits only; the leaving summary is screen_left,always,9.63 podcast_detail_viewed,Podcast detail viewed,Podcast info screen opened,B,Visit→subscribe/play,podcast_id:string:Y;is_subscribed:bool:N,podcast_info_screen_viewed;podcast_info_screen_session,discovery;detail,,none,visits only; the leaving summary is screen_left,always,6.71 diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoEpisodeActions.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoEpisodeActions.kt index 97ed5b061..8c7a78e88 100644 --- a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoEpisodeActions.kt +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/PodcastInfoEpisodeActions.kt @@ -250,19 +250,22 @@ internal class PodcastInfoEpisodeActions( fun addEpisodesToQueue(episodes: List) { val currentState = uiState.value as? PodcastInfoUiState.Success ?: return scope.launch { - val added = - episodes.distinctBy(Episode::id).count { episode -> - playbackRepository.addToQueue(episode = episode, podcast = currentState.podcast) + var added = 0 + try { + episodes.distinctBy(Episode::id).forEach { episode -> + if (playbackRepository.addToQueue(episode = episode, podcast = currentState.podcast)) added++ + } + } finally { + // One event for the whole selection, with what was added even if a later add failed. + if (added > 0) { + AnalyticsHelper.trackQueueModified( + action = "add", + podcastId = currentState.podcast.id, + queueSize = playbackRepository.playerState.value.queue.size, + source = "episode_selection", + itemsCount = added, + ) } - // One event for the whole selection, not one per episode. - if (added > 0) { - AnalyticsHelper.trackQueueModified( - action = "add", - podcastId = currentState.podcast.id, - queueSize = playbackRepository.playerState.value.queue.size, - source = "episode_selection", - itemsCount = added, - ) } } } diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackViewModelTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackViewModelTest.kt index ad7b52f72..d4f00ef92 100644 --- a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackViewModelTest.kt +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackViewModelTest.kt @@ -199,6 +199,39 @@ class FeedbackViewModelTest { assertNull(prefs.getFeedbackDraft()) } + @Test + fun onSubmit_sends_feedback_sent_only_when_the_server_accepts_it() = runTest(testDispatcher) { + val events = mutableListOf>>() + val restore = cx.aswin.boxlore.core.analytics.AnalyticsHelper.installRecordingSink(events) + try { + var accepted = false + val vm = FeedbackViewModel( + dummyPodcastRepository, + prefs, + context, + ioDispatcher = testDispatcher, + submitFeedbackAction = { _, _, _, _, _, _ -> accepted }, + ) + vm.onCategorySelected(FeedbackCategory.AUDIO) + vm.onMessageChanged("Stream cuts off at end of track") + vm.onEmailChanged("dev@example.com") + + vm.onSubmit() + advanceUntilIdle() + assertTrue(events.none { it.first == "feedback_sent" }) + + accepted = true + vm.onSubmit() + advanceUntilIdle() + val sent = events.single { it.first == "feedback_sent" }.second + assertEquals("bug", sent["category"]) + assertEquals(true, sent["with_email"]) + assertTrue(sent.values.none { it == "Stream cuts off at end of track" || it == "dev@example.com" }) + } finally { + restore() + } + } + @Test fun onSubmit_with_audio_category_maps_to_bug_on_wire() = runTest(testDispatcher) { var capturedCategory: String? = null From 397b1e5e1c847f0417c17acf830950b95f9bff35 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Sun, 1 Nov 2026 19:33:09 +0530 Subject: [PATCH 6/9] fix(settings): say accounts are optional on the Privacy page --- feature/settings/README.md | 1 + .../boxlore/feature/settings/pages/PrivacySettingsPage.kt | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/feature/settings/README.md b/feature/settings/README.md index e18bbcfc0..b7499e53a 100644 --- a/feature/settings/README.md +++ b/feature/settings/README.md @@ -50,6 +50,7 @@ Owns the unified Settings hub, category pages (Account, Sync & Backups, Appearan - Dialogs: `AddRssFeedDialog`, `ResetAnalyticsDialog`, `LogsPreviewDialog`. - Automatic downloads uses the shared two-line settings header and solid grouped surfaces for Downloads, Storage, and While boxlore is closed. A connected 1/2/3/5 selector replaces scrolling quota chips; a short introduction explains per-show activation. **Backup background checks** is explicitly described as optional recovery when show notifications are off or do not arrive; notifications can already trigger downloads while closed. Its network/charging controls appear only when the off-by-default switch is enabled, retaining their preferences when hidden. Concise state-dependent copy explains foreground/push discovery or battery/data costs and Android delays; the header help dialog holds the full network, consent, discovery and retention explanation. Per-show auto-download never enables background checking. +- **Privacy page:** the opening card says analytics never includes the listener's name or email, and that an account is optional, only backs up and syncs the library, and is never linked to analytics (1 Nov 2026; it used to say there was no account). Wording changes here need the maintainer's approval of the exact text. - **Page views** (`TrackScreenView`, `:core:designsystem`): `settings` sends `settings_interaction` `screen_viewed` through the helper; every page sends `screen_viewed` (`account` with `mode`, `sync_and_backups`, `library_settings`, `recommendations_settings`, `appearance` and its `appearance_theme`/`_home`/`_player`/`_navigation`/`_text`, `playback_settings`, `downloads_settings`, `smart_downloads_settings`, `auto_download_settings`, `privacy`, `about`, `feedback`), as do its dialogs. `support` keeps `support_page_viewed`. Account actions send `settings_interaction`: `account_sign_in_email_link`, `account_sign_in_google`, `account_sign_out`, `account_sync_now`, `account_delete_confirmed`. ## Internal structure diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/PrivacySettingsPage.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/PrivacySettingsPage.kt index 56b8d4f93..d3964746b 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/PrivacySettingsPage.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/PrivacySettingsPage.kt @@ -80,8 +80,9 @@ internal fun PrivacySettingsPage( Text( text = "Analytics can suggest a rough sketch (rough place, device, podcast taste) — " + - "never a precise profile, and never age or gender. We don’t ask for name or " + - "email, and there’s no account; your library stays on this device.", + "never a precise profile, and never age or gender. Analytics never includes your " + + "name or email. An account is optional and only backs up and syncs your library; " + + "it’s never linked to your analytics.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) From 2dfb659f1ca65f2e7bd8527a4f6c025498440bf1 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Sun, 1 Nov 2026 19:35:04 +0530 Subject: [PATCH 7/9] refactor(playback): take car requests as a read-only list --- .../core/playback/service/auto/AutoBrowseLibraryCallback.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseLibraryCallback.kt b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseLibraryCallback.kt index dc40f4f7a..429c7e200 100644 --- a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseLibraryCallback.kt +++ b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseLibraryCallback.kt @@ -635,7 +635,7 @@ internal class AutoBrowseLibraryCallback( private suspend fun itemsToAdd( mediaSession: MediaSession, - mediaItems: MutableList, + mediaItems: List, fromCar: Boolean, ): MutableList { if (mediaItems.size > 1) { From 1af411ba7916afeffc4e6aa771137195ff796f46 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Sun, 1 Nov 2026 20:14:02 +0530 Subject: [PATCH 8/9] fix(playback): keep first play retryable and ignore empty car requests The first-play claim is a ticket saved only once the event is sent, so a listen whose lookups fail or are cancelled hands it to a later one. An empty media-item request returns nothing instead of throwing. The database and rss READMEs note the ktlint-only changes. --- core/database/README.md | 1 + core/playback/README.md | 2 +- .../boxlore/core/playback/FirstPlayClaim.kt | 36 +++++++++++++++---- .../core/playback/PlaybackTelemetrySession.kt | 19 ++++++---- .../service/auto/AutoBrowseLibraryCallback.kt | 1 + .../core/playback/FirstPlayClaimTest.kt | 31 ++++++++++++---- core/rss/README.md | 1 + 7 files changed, 72 insertions(+), 19 deletions(-) diff --git a/core/database/README.md b/core/database/README.md index 94a76ce74..ba35c51da 100644 --- a/core/database/README.md +++ b/core/database/README.md @@ -134,6 +134,7 @@ src/main/java/cx/aswin/boxlore/core/database/ ## Testing notes +- Style only (1 Nov 2026): `ListeningInsightsMaintenance` gained a blank line before a local function's comment for ktlint; no behaviour change. - Unit tests live under `core/database/src/test`. - `BriefingArtworkCompatibilityTest` covers upgrade/reopen, restored rows, all region aliases, recursive-trigger safety, unchanged non-Brief artwork and preservation of playback/download/queue state. Opening recreates trigger definitions transactionally, including after a database comes from another application variant, so future restores use the current authority and aliases. - `PodcastDaoExtendedTest.setFeedUrlMarksACorrectedAddressForTheNextPush` covers a corrected address marking the row dirty and an unchanged one staying clean. diff --git a/core/playback/README.md b/core/playback/README.md index 561b18959..82ed0bb22 100644 --- a/core/playback/README.md +++ b/core/playback/README.md @@ -156,7 +156,7 @@ Files under `core/data/service` are compatibility stubs for old service class na - Media ID prefixes such as `episode:`, `queue:`, and `learn:` are session and Android Auto contracts. - SharedPreferences file `boxcast_player` stores playback session flags. - SharedPreferences file `boxlore_listen_note` holds the note of the listen in progress (`PrefsListenNoteStore`); it is empty between listens and left out of backups and device transfers, so a restore never reports a listen again. -- SharedPreferences file `boxlore_install_milestones` holds `first_play_claimed` (`FirstPlayClaim`, `PrefsFirstPlayStore`): `first_episode_played` is claimed once per install when a listen starts, whatever started it, with the episode and show. The file is left out of backups and device transfers, so a restored or new phone counts its own first play; an install updated from an older build that already sent it from the screen stays quiet. The claim is taken when the listen starts (on the main-thread scope, in start order), not after the show lookups, so the first play is the one that started first; the app's first launch notes once whether the install came from an older build (`FirstPlayInstallOrigin.note`, `updated_from_older_build`), so updating a restored phone before its first listen can't hide it (`FirstPlayClaimTest`). +- SharedPreferences file `boxlore_install_milestones` holds `first_play_claimed` (`FirstPlayClaim`, `PrefsFirstPlayStore`): `first_episode_played` is claimed once per install when a listen starts, whatever started it, with the episode and show. The file is left out of backups and device transfers, so a restored or new phone counts its own first play; an install updated from an older build that already sent it from the screen stays quiet. The claim is taken when the listen starts (on the main-thread scope, in start order), not after the show lookups, so the first play is the one that started first; it's a ticket that is saved only once the event is sent, so a listen whose lookups fail or are cancelled hands it to a later one; the app's first launch notes once whether the install came from an older build (`FirstPlayInstallOrigin.note`, `updated_from_older_build`), so updating a restored phone before its first listen can't hide it (`FirstPlayClaimTest`). - SharedPreferences file `android_auto_artwork_sources` maps Android Auto artwork content keys to remote HTTPS URLs or sandboxed local paths (identity for Auto collage ContentProvider; do not rename lightly). - Preference key `device_uuid` is a stable install identifier and must not be logged raw. - Queue, history, and download rows are persisted by `:core:database` and `:core:downloads`. diff --git a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/FirstPlayClaim.kt b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/FirstPlayClaim.kt index 304caf941..35c1f1313 100644 --- a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/FirstPlayClaim.kt +++ b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/FirstPlayClaim.kt @@ -19,14 +19,38 @@ internal class FirstPlayClaim( ) { private val lock = Mutex() - /** True exactly once per install: for the first play that should send the event. */ - suspend fun claim(): Boolean = lock.withLock { - if (store.isClaimed()) return@withLock false + @Volatile private var held = false + + /** + * A [Ticket] for the listen that should send the event, or null: it was sent, an older build sent + * it, or another listen holds the ticket. Saved only once the event is sent ([Ticket.sent]), so a + * listen whose lookups fail hands it to a later one ([Ticket.releaseIfUnsent]). + */ + suspend fun claim(): Ticket? = lock.withLock { + if (held || store.isClaimed()) return@withLock null val updated = store.updatedFromOlderBuild() ?: isUpdatedInstall() // Only an install that ran an older build can have sent it; a restored copy of that flag can't count. - val alreadySent = updated && runCatching { loggedByOlderBuild() }.getOrDefault(false) - store.markClaimed() - !alreadySent + if (updated && runCatching { loggedByOlderBuild() }.getOrDefault(false)) { + store.markClaimed() + return@withLock null + } + held = true + Ticket() + } + + inner class Ticket internal constructor() { + @Volatile private var done = false + + /** The event went out: no later listen sends it. */ + fun sent() { + store.markClaimed() + done = true + } + + /** The listen ended without sending it: a later listen may. */ + fun releaseIfUnsent() { + if (!done) held = false + } } companion object { diff --git a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackTelemetrySession.kt b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackTelemetrySession.kt index 1a3c97851..41abf1e80 100644 --- a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackTelemetrySession.kt +++ b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackTelemetrySession.kt @@ -177,9 +177,6 @@ internal class PlaybackTelemetrySession( listenContext()?.let(listens::started) val listenStart = ListenStart(listenId = listen.listenId, controlledBy = listens.controlledBy) - // Claimed now, in the order listens start (the scope runs on the main thread), not when the - // slower show lookups finish, so the first play is the one that started first. - val firstPlayClaim = firstPlay?.let { claim -> scope.async { claim.claim() } } // Download or stream right away, not after the slower show lookups, so a short listen knows it. val modeLookup = scope.async { playbackModeOf(episodeId) } scope.launch { @@ -187,7 +184,14 @@ internal class PlaybackTelemetrySession( withContext(mainDispatcher) { listens.resolvedPlaybackMode(listen.listenId, mode) } } scope.launch { - enrich(episodeId, currentItem, genre, listenStart, modeLookup, firstPlayClaim) + // Claimed first, in the order listens start (the scope runs on the main thread), not when + // the slower show lookups finish, so the first play is the one that started first. + val firstPlayTicket = firstPlay?.claim() + try { + enrich(episodeId, currentItem, genre, listenStart, modeLookup, firstPlayTicket) + } finally { + firstPlayTicket?.releaseIfUnsent() + } } } @@ -275,7 +279,7 @@ internal class PlaybackTelemetrySession( genre: String?, listen: ListenStart, modeLookup: Deferred, - firstPlayClaim: Deferred?, + firstPlayTicket: FirstPlayClaim.Ticket?, ) { try { val queueItem = database.queueDao().getQueueItemByEpisodeId(episodeId) @@ -346,7 +350,10 @@ internal class PlaybackTelemetrySession( speed = speed, listen = listen, ) - if (firstPlayClaim?.await() == true) AnalyticsHelper.trackFirstEpisodePlayed(episodeId, resolvedPodcastId) + firstPlayTicket?.let { ticket -> + AnalyticsHelper.trackFirstEpisodePlayed(episodeId, resolvedPodcastId) + ticket.sent() + } } /** Download when any stored ID of the episode holds a finished download, else stream. */ diff --git a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseLibraryCallback.kt b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseLibraryCallback.kt index 429c7e200..0e0423341 100644 --- a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseLibraryCallback.kt +++ b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseLibraryCallback.kt @@ -638,6 +638,7 @@ internal class AutoBrowseLibraryCallback( mediaItems: List, fromCar: Boolean, ): MutableList { + if (mediaItems.isEmpty()) return mutableListOf() if (mediaItems.size > 1) { labelCarPlay(fromCar, "android_auto_play_all") return mediaItems.map { mediaResolver.resolveMediaItem(it) }.toMutableList() diff --git a/core/playback/src/test/java/cx/aswin/boxlore/core/playback/FirstPlayClaimTest.kt b/core/playback/src/test/java/cx/aswin/boxlore/core/playback/FirstPlayClaimTest.kt index 23ebab7b0..018930073 100644 --- a/core/playback/src/test/java/cx/aswin/boxlore/core/playback/FirstPlayClaimTest.kt +++ b/core/playback/src/test/java/cx/aswin/boxlore/core/playback/FirstPlayClaimTest.kt @@ -5,6 +5,8 @@ import kotlinx.coroutines.awaitAll import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -29,9 +31,26 @@ class FirstPlayClaimTest { val store = MemoryStore() val claim = FirstPlayClaim(store, isUpdatedInstall = { false }) { false } - assertTrue(claim.claim()) - assertFalse(claim.claim()) + val ticket = claim.claim() + assertNotNull(ticket) + assertNull(claim.claim()) + ticket!!.sent() + ticket.releaseIfUnsent() + assertTrue(store.claimed) + assertNull(claim.claim()) + } + + @Test + fun `a listen whose lookups fail hands the first play to a later one`() = runTest { + // Regression: the claim was saved before the event was sent, so a failed lookup lost it for good. + val store = MemoryStore() + val claim = FirstPlayClaim(store, isUpdatedInstall = { false }) { false } + + claim.claim()!!.releaseIfUnsent() + + assertFalse(store.claimed) + assertNotNull(claim.claim()) } @Test @@ -40,7 +59,7 @@ class FirstPlayClaimTest { val results = List(5) { async { claim.claim() } }.awaitAll() - assertEquals(1, results.count { it }) + assertEquals(1, results.count { it != null }) } @Test @@ -48,7 +67,7 @@ class FirstPlayClaimTest { val store = MemoryStore() val claim = FirstPlayClaim(store, isUpdatedInstall = { true }) { true } - assertFalse(claim.claim()) + assertNull(claim.claim()) assertTrue(store.claimed) } @@ -59,7 +78,7 @@ class FirstPlayClaimTest { FirstPlayClaim.noteInstallOrigin(store) { false } val claim = FirstPlayClaim(store, isUpdatedInstall = { true }) { true } - assertTrue(claim.claim()) + assertNotNull(claim.claim()) } @Test @@ -75,6 +94,6 @@ class FirstPlayClaimTest { fun `a failed legacy read still claims`() = runTest { val claim = FirstPlayClaim(MemoryStore(), isUpdatedInstall = { true }) { error("datastore unavailable") } - assertTrue(claim.claim()) + assertNotNull(claim.claim()) } } diff --git a/core/rss/README.md b/core/rss/README.md index fd514d469..79511f651 100644 --- a/core/rss/README.md +++ b/core/rss/README.md @@ -123,6 +123,7 @@ src/main/java/cx/aswin/boxlore/core/rss/ ## Testing notes +- Style only (1 Nov 2026): `RssPodcastRepository.refreshEpisodesOnce` became an expression body for ktlint, and two tests had their imports and arguments tidied; no behaviour change. - Unit tests live under `core/rss/src/test`. - Existing coverage includes deterministic ID contracts, source matching heuristics, exact legacy URL/GUID upgrade gates, feed-client helpers, episode-supplement matching (including empty titles and distant same-title dates), list merge, tip-resolution (PI id vs negative feed id, blank metadata, match-only baseline rows filled from the feed), supplement artwork fallback (item / channel / show), repository require/failure mapping, disconnect / auto-opt-in predicates, `restoreDirectFeedOptIn` / `listDirectFeedOptIns` (HTTPS stub, ignore http/`rss:`, keep existing extras), local-catalog refresh outcomes (unchanged validators, not-ready validator bypass, empty parse, blank/`rss:` ids), bounded `getWindow`, TTL recheck on expiry sweep, structural ready cutover, restored-listener remap/merge behavior, sticky RSS tip reuse when a stored id differs from the minted id, entity toPodcast notification/download/custom-genre flag preservation, dead-address classification and report statuses (`FeedReadFailureTest`, `LocalEpisodeCatalogRepositoryTest.refreshFailureSaysWhetherTheSavedAddressLooksGone`, `refreshFailsWhenParsedFeedHasNoEpisodes`), moved feeds (`FeedMoveTest`: redirect chains, `itunes:new-feed-url`, same-show check; `refreshReportsAPermanentMoveOnlyWhenAskedAndTheShowMatches`), and episode ID clashes (`LocalEpisodeCollisionGuardTest`: cross-show, same-show other key, within one batch, stable reserved IDs, exhaustion, legacy keys ending in a newline, tab, CR LF, space plus newline, no-break space, U+2028 or U+180E (written as escapes and trimmed with the recipe's set, so the JVM agrees with Android), the unique (show, key) index modelled as Room's REPLACE, synthetic and audio-address legacy heals whether the row arrives with its own mint or another ID (a recipe or listener-held ID), and the cases kept apart; `LocalEpisodeCatalogRepositoryTest`: two shows refreshing one feed keep their own rows, a full refresh over legacy keys keeps one row per episode on its stored ID, and a legacy row holding the item's own mint heals to the real key while its unresolved alias becomes trusted with the heal's provenance). - `ListenerStateMovesTest` covers the narrowed state moves: trusted and ambiguous IDs are never rematched, the catalog row that joins a trusted orphan by key, exact and title-and-date rematches, a moved ID's alias, the latest like decision in an orphan merge, completion kept unless the group settings cleared it later, and the switch's links (trusted for an exact, unique key, which a weaker match from another old ID doesn't undo, and for a lookalike show only with the same audio address or title too; an old ID without a usable key settled by an exact, unique audio address of an episode with a GUID, and not when two old IDs or two episodes share it, the episode has no GUID or the unresolved alias is another show's; ambiguous for a shared key or a title match alone; nothing for ambiguous old IDs, unmatched rows or IDs the show already holds; an ambiguous link turned trusted later, never the reverse, and no other alias touched). `ListenerStateMovesRoomTest` runs the rematch through an in-memory Room database (Robolectric): an evicted episode read again under a new ID joins its episode with nothing moved and reads once; only unresolved and alias-less IDs move, and only an exact match records an alias; moved listening merges into the episode's owner. `OrphanRematchRoomTest` runs it after the real refresh: an ID another show's catalog row holds is never rematched; a stored row's new alias joins a trusted orphan's episode, which then reads one state with progress going to the orphan's ID; an unresolved orphan moves into the owner of the row that took its recipe ID and joins that episode; an ID a listener match adopted owns its episode and takes a later orphan; an orphan merged into a feed show's owner row keeps that row's show. `ShowSwitchRoomTest` switches a show with history, a like, a completion, a queue entry, a completed download, a session and a rollup under its old IDs: nothing moves, the feed's episodes get trusted or ambiguous aliases, the trusted ones read the projected like, completion and progress, write progress to the old owner and play its download, an ID played before the show was followed and a v39 address-keyed ID join by their exact audio address, another show's look-alike rows get no alias, listening the feed show held before the switch reads as one episode, switching again or following the Podcast Index show again (with its catalog evicted and read again) keeps every row, alias and ID, and that read never takes a linked feed ID, even one that came to own its episode. It also covers a lookalike show trusting a key only with a second signal, an ambiguous link turned trusted once its old ID gains the key, adding a followed show's feed by its address, unfollowing the feed show and adding it again, restoring the feed show with its link (by backup relink and by `restoreSubscription`), and the RSS follow upgrade stopping before it touches a switched show's Podcast Index show. From a8886f43baebbc09bb4f3b12a4a667bfc5a44fd8 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Sun, 1 Nov 2026 20:26:59 +0530 Subject: [PATCH 9/9] fix(playback): drop only a car label for an empty car request --- .../boxlore/core/analytics/PendingEntryPoint.kt | 6 ++++++ .../service/auto/AutoBrowseLibraryCallback.kt | 5 ++--- .../core/playback/service/auto/CarPlayLabel.kt | 10 ++++++++++ .../core/playback/service/auto/CarPlayLabelTest.kt | 12 ++++++++++++ 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/PendingEntryPoint.kt b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/PendingEntryPoint.kt index 542ab32c2..d9291c6b8 100644 --- a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/PendingEntryPoint.kt +++ b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/PendingEntryPoint.kt @@ -61,6 +61,12 @@ object PendingEntryPoint { @Volatile internal var clock: () -> Long = { System.nanoTime() / NANOS_PER_MS } + /** Drops the pending context only when [matches] says it's the one to drop; anything else stays. */ + @Synchronized + fun discardIf(matches: (Map) -> Boolean) { + if (pending?.let(matches) == true) pending = null + } + @Synchronized fun consume(): Map? { val result = pending diff --git a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseLibraryCallback.kt b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseLibraryCallback.kt index 0e0423341..ead49f6f5 100644 --- a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseLibraryCallback.kt +++ b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseLibraryCallback.kt @@ -13,7 +13,6 @@ import com.google.common.collect.ImmutableList import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture import cx.aswin.boxlore.core.analytics.AnalyticsHelper -import cx.aswin.boxlore.core.analytics.PendingEntryPoint import cx.aswin.boxlore.core.database.episodeListenerStore import cx.aswin.boxlore.core.playback.service.CarConnectionState import kotlinx.coroutines.guava.future @@ -628,8 +627,8 @@ internal class AutoBrowseLibraryCallback( val fromCar = isAndroidAuto(mediaSession, controller) return host.serviceScope.future { - // A car request that found nothing to play drops its label, so a later phone play can't take it. - itemsToAdd(mediaSession, mediaItems, fromCar).also { if (fromCar && it.isEmpty()) PendingEntryPoint.consume() } + // A car request that found nothing to play drops its own label, so a later phone play can't take it. + itemsToAdd(mediaSession, mediaItems, fromCar).also { if (fromCar && it.isEmpty()) discardCarPlayLabel() } } } diff --git a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/CarPlayLabel.kt b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/CarPlayLabel.kt index d83da50f1..62aeccf22 100644 --- a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/CarPlayLabel.kt +++ b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/CarPlayLabel.kt @@ -10,3 +10,13 @@ import cx.aswin.boxlore.core.analytics.PendingEntryPoint internal fun labelCarPlay(fromCar: Boolean, entryPoint: String) { if (fromCar) PendingEntryPoint.set(mapOf("entry_point" to entryPoint)) } + +/** + * Drops a car label left by a request that found nothing to play. A request that set none (an empty + * or stale one) leaves whatever is pending, such as a phone play's own entry point. + */ +internal fun discardCarPlayLabel() { + PendingEntryPoint.discardIf { (it["entry_point"] as? String)?.startsWith(CAR_ENTRY_PREFIX) == true } +} + +private const val CAR_ENTRY_PREFIX = "android_auto_" diff --git a/core/playback/src/test/java/cx/aswin/boxlore/core/playback/service/auto/CarPlayLabelTest.kt b/core/playback/src/test/java/cx/aswin/boxlore/core/playback/service/auto/CarPlayLabelTest.kt index aeafc64db..06c8c64e4 100644 --- a/core/playback/src/test/java/cx/aswin/boxlore/core/playback/service/auto/CarPlayLabelTest.kt +++ b/core/playback/src/test/java/cx/aswin/boxlore/core/playback/service/auto/CarPlayLabelTest.kt @@ -27,4 +27,16 @@ class CarPlayLabelTest { assertEquals("android_auto_liked", PendingEntryPoint.consume()?.get("entry_point")) } + + @Test + fun `an empty car request drops only its own label`() { + // Regression: an empty car request cleared a phone play's pending entry point it never set. + PendingEntryPoint.set(mapOf("entry_point" to "home_hero_resume")) + discardCarPlayLabel() + assertEquals("home_hero_resume", PendingEntryPoint.consume()?.get("entry_point")) + + labelCarPlay(fromCar = true, entryPoint = "android_auto_new_episodes") + discardCarPlayLabel() + assertEquals(null, PendingEntryPoint.consume()) + } }