Skip to content
Merged
2 changes: 2 additions & 0 deletions app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}

Expand All @@ -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) } },
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +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
import androidx.compose.runtime.setValue
Expand Down Expand Up @@ -59,22 +57,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,
Expand Down
17 changes: 3 additions & 14 deletions app/src/main/java/cx/aswin/boxlore/navigation/NavGraphWiring.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -190,9 +185,7 @@ data class NavHostSession(
val openedToLandingOnLaunch: androidx.compose.runtime.MutableState<Boolean>,
val currentEpisode: Episode?,
val miniPlayerPadding: androidx.compose.ui.unit.Dp,
val showFeatureDialog: Boolean,
val hasSeenMarkPlayedTip: Boolean,
val permissionLauncher: ManagedActivityResultLauncher<String, Boolean>,
val appInstanceId: String?,
)

Expand Down Expand Up @@ -398,18 +391,14 @@ 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
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)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,11 +31,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)
Expand Down
26 changes: 7 additions & 19 deletions app/src/main/java/cx/aswin/boxlore/ui/BoxLoreAppRoot.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -98,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
Expand Down Expand Up @@ -443,7 +442,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()
Expand Down Expand Up @@ -478,20 +476,19 @@ fun BoxLoreAppRoot(

var appInstanceId by remember { mutableStateOf<String?>(null) }
var isInitialHomeContentReady by remember { mutableStateOf(false) }
val permissionLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { isGranted ->
AnalyticsHelper.trackNotificationPermissionDecided(isGranted)
}

LaunchedEffect(Unit) {
FcmTopicHelper.subscribeDefaultTopics(activity)
}

var opmlImportState by remember { mutableStateOf<OpmlImportState>(OpmlImportState.Idle) }
var opmlImportSource by remember { mutableStateOf("welcome_screen") }
var importTriggerKey by remember { mutableStateOf(0L) }
// 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) {
scope.launch {
Expand Down Expand Up @@ -529,13 +526,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<String?>(null) }
LaunchedEffect(dismissedFeatureVersion) {
Expand Down Expand Up @@ -659,9 +649,7 @@ fun BoxLoreAppRoot(
} else {
miniPlayerPadding
},
showFeatureDialog = showFeatureDialog,
hasSeenMarkPlayedTip = hasSeenMarkPlayedTip,
permissionLauncher = permissionLauncher,
appInstanceId = appInstanceId,
),
opmlCallbacks =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Comment thread
ashwkun marked this conversation as resolved.
Resolved
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)) {
Expand Down Expand Up @@ -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)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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))
},
)
Expand Down Expand Up @@ -127,6 +127,7 @@ internal fun ImportNotificationPermissionCard(
Button(
onClick = {
if (canRequestRuntimePermission) {
AnalyticsHelper.trackNotificationPermissionRequested(IMPORT_SURFACE)
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
} else {
onOpenSettings()
Expand All @@ -142,3 +143,6 @@ internal fun ImportNotificationPermissionCard(
}
}
}

/** `surface` on the permission events: the import card. */
private const val IMPORT_SURFACE = "library_import"
Loading
Loading