Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ The application module owns the Android app shell: `BoxLoreApplication`, `MainAc
- **API sessions (`session/`, 2026-10-17).** `BoxLoreApplication.onCreate` calls `ApiSessionSetup.install` before anything else, so the shared `/auth/session` exchange (`:core:network` `ApiSessionManager`) starts behind the splash and no request goes out before the manager exists. This build sends only `X-Boxlore-Session`, never the bundled app key. The setup sets `X-App-Version` and `X-App-Channel` (`InstallChannel`: `play` for the Play build or a Play install, else `github`; the same rule picks the update flow in `createAppUpdates`), installs App Check (debug provider in debug, Play Integrity in release) with background auto-refresh off, and supplies App Check only to the exchange (`FirebaseAppCheckTokenSource`, failure categories from `AppCheckFailureReason`). The old blocking App Check provider, which made every request wait up to 5 s, is gone. `observe` passes process foreground/background (`ProcessLifecycleOwner`) and connectivity to the manager, which retries App Check for limited sessions only in the foreground with a network. `ApiSessionAnalyticsListener` forwards session events to `api_session_status` and each App Check attempt to `app_check_status` with its reason.
- **Turnstile step-up.** When a limited session reaches an AI onboarding route, the manager asks `TurnstileStepUpController` for a token. `TurnstileStepUpHost`, composed once in `BoxLoreAppRoot` inside the window-layout provider, loads `<API base>/auth/turnstile` (a static page on the API host) in a locked-down WebView (`TurnstileWebViewPage`, following Cloudflare's mobile guide: JavaScript, DOM storage, cookies including Turnstile's third-party frame, and the default user agent; but no file or content access, no new windows, top-level navigation limited to the API host, `challenges.cloudflare.com` and `about:blank` / `about:srcdoc`, and an `androidx.webkit` message listener that accepts the `BoxloreTurnstile` bridge only from the API origin). The page runs invisibly inside a zero-size parent (laid out but never drawn, touched or announced) while onboarding keeps its normal thinking state. Only if Turnstile asks for interaction does the same page move into the shared `BoxloreModalBottomSheet` (a phone sheet; a centered dialog on expanded and folded windows) with Echo on its stage beside its line (“I’m the only robot allowed in this chat.”, Echo's curious asking pose, its eyes glancing down at the check), a plain line saying what to tap and why, the check inside a `surfaceContainerHigh` card, and **Skip**. The phone sheet opens fully, Echo and the gaps shrink on windows under 480dp tall, and the content scrolls if it still doesn't fit, so the check and Skip stay reachable. A run with no answer within 20 s (or 2 minutes once interactive), a page error, Skip, Back, a swipe or an outside tap fails it, and onboarding carries on with its non-AI path. The step-up runs only while the PostHog flag `api-turnstile-step-up` is on (on for everyone since 2026-10-31); with the flag off or missing, limited sessions go straight to the non-AI path. Copy lives in `res/values/session_check.xml`.
- Authentication & Deep Links: `MainActivity` handles incoming App Link intents for `https://boxlore.in/auth` (and `https://aswin.cx/boxlore/auth` from links sent by older builds) to complete passwordless email link sign-in via `authRepository.signInWithEmailLink`. It prevents one-time link replay across activity recreation by nullifying intent data and tracking consumed links, and prompts for the user's email via `CrossDeviceEmailDialog` if a magic link is opened cross-device without local pending auth state. Sign-in goes through `AppContainer.emailLinkSignIn` instead of toasts: each new link raises an Account page request, and `BoxLoreAppRoot` opens Settings on Account & sync (`ACCOUNT_PAGE_ROUTE`, unless `isAccountPage` says it is already showing; before onboarding finishes Settings shows the onboarding version), where the page shows Signing you in, then the first sync, or why the link failed. `accountPageRequestAction` decides each request (ignore one already handled, including across recreation, since the handled value is saved with `rememberSaveable`; only mark it handled when Account & sync is already showing; otherwise open it), and `AccountPageRouteTest` covers it and the route check. `BoxLoreApplication` configures `NetworkModule.authenticator` with `FirebaseAuthAuthenticator` at startup to automatically refresh STS tokens on HTTP 401.
- Links: `MainActivity` provides `SafeUriHandler` (`:core:designsystem`) as `LocalUriHandler`, so every `openUri` in the app (privacy, account deletion, feedback, briefing sources, credits) shows a note instead of crashing when no app can open the link. The updates panel's notes link, the Play listing and the import card's notification settings start through `startFirstAvailable` / `openLink`, fallbacks included.
- Install attribution: `AppContainer` wires `InstallReferrerManager.onInstallReferrerResolved` → analytics person properties (`install_channel`). Catalog stays free of `:core:analytics`.
- FCM (`BoxLoreFcmService` + `NewEpisodeFcmLogic`) owns notification_received / tap extras (`notification_type`, podcast/episode ids for snake+camel keys). Push and episode notifications use bounded slot allocation (16 bounded slots for new episodes, 4 for announcements) with normalized intents (`Intent.ACTION_VIEW`, component set to `MainActivity`, `data = null`, and routes passed in `target_route`) so `Intent.filterEquals()` returns `true` across notifications. This ensures Android's `system_server` reuses `PendingIntentRecord`s via `FLAG_UPDATE_CURRENT` instead of leaking up to the 1,000 UID quota. PendingIntent creations are guarded with `try-catch (e: SecurityException)` falling back to posting notifications without `contentIntent` if quota was exhausted prior to reboot. Download scheduling and notification display fail independently. Payloads without a usable episode ID initially open the podcast page; durable hydration replaces that route with the exact local episode when matched.
- **Feedback replies (support conversations).** `AppContainer.supportRepository` keeps its local state in SharedPreferences `boxlore_support` (`fcm/SupportPrefsStore`: whether the phone has conversations, a reply waiting, the push topics it joined, the last check). After every successful refresh (`SupportRepository.refreshed`, even when the list didn't change) `SupportPushTopicSync` joins and leaves FCM topics to match that refresh's `meta.pushTopics` (none without conversations), one plan at a time, recording only what Firebase confirmed, so a topic that failed is planned again next time. A `support_reply` data push marks a reply waiting (Home's Feedback button lights up through `LocalFeedbackReplyWaiting`) and posts a notification in the **Feedback replies** channel (`boxlore_feedback_replies_v1`, one of 50 slots per conversation) whose tap opens `feedback?thread=<id>` (allowed by `PushTargetRouteAllowlist`; an id that isn't 24 hex characters opens Feedback); on a tablet it first closes an open detail (`pushClosesDetails`), so Feedback isn't opened beneath an episode. The push also tells an open conversation or list to reload. On every app start `checkSupportReplies` refreshes at most every 30 minutes, and only for phones with conversations. The Feedback route keeps the mini player in its small form, as Lore does (`keepsCompactPlayer`).
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/cx/aswin/boxlore/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,8 @@ class MainActivity : ComponentActivity() {
androidx.compose.runtime.CompositionLocalProvider(
cx.aswin.boxlore.updates.LocalPlayUpdateAction provides { playAppUpdateHelper.startUpdate() },
cx.aswin.boxlore.core.designsystem.layout.LocalBoxloreWindowLayout provides windowLayout,
// Every openUri in the app: no app to open a link shows a note instead of crashing.
androidx.compose.ui.platform.LocalUriHandler provides androidx.compose.runtime.remember { cx.aswin.boxlore.core.designsystem.links.SafeUriHandler(this@MainActivity) },
) {
cx.aswin.boxlore.ui.TabletAlphaStartupGate(
pending = tabletAlphaNoticePending,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,13 @@ private fun androidx.navigation.NavGraphBuilder.addFeedbackRoute(w: NavGraphWiri
// `feedback?thread=<id>` opens one conversation (a reply notification's tap); plain `feedback` still works.
composable(
route = "feedback?thread={thread}",
arguments = listOf(navArgument("thread") { type = NavType.StringType; nullable = true; defaultValue = null }),
arguments = listOf(
navArgument("thread") {
type = NavType.StringType
nullable = true
defaultValue = null
},
),
) { entry ->
cx.aswin.boxlore.feature.settings.feedback.FeedbackScreen(
supportRepository = container.supportRepository,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import cx.aswin.boxlore.R
import cx.aswin.boxlore.core.analytics.AnalyticsHelper
import cx.aswin.boxlore.core.designsystem.links.startFirstAvailable
import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight

internal fun areAppNotificationsEnabled(context: Context): Boolean {
Expand Down Expand Up @@ -69,15 +70,11 @@ internal fun openAppNotificationSettings(context: Context) {
}
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
try {
context.startActivity(intent)
} catch (_: Exception) {
val fallback = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", context.packageName, null)
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
context.startActivity(fallback)
val fallback = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", context.packageName, null)
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
context.startFirstAvailable(intent, fallback)
}

/** After a backup restore: shows with alerts or auto-downloads need notifications, one tap to allow them. */
Expand Down
4 changes: 2 additions & 2 deletions app/src/main/java/cx/aswin/boxlore/updates/AppUpdatesPanel.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package cx.aswin.boxlore.updates

import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
Expand Down Expand Up @@ -57,6 +56,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cx.aswin.boxlore.R
import cx.aswin.boxlore.core.designsystem.analytics.TrackScreenView
import cx.aswin.boxlore.core.designsystem.components.BoxloreDialog as Dialog
import cx.aswin.boxlore.core.designsystem.links.openLink
import cx.aswin.boxlore.ui.NativeDialogSystemBars

@Composable
Expand Down Expand Up @@ -213,7 +213,7 @@ private fun ReleaseNotes(manifest: UpdateManifest) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.updates_notes), style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f))
IconButton(onClick = { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(manifest.notesUrl))) }) {
IconButton(onClick = { context.openLink(Uri.parse(manifest.notesUrl)) }) {
Icon(Icons.Rounded.OpenInNew, stringResource(R.string.updates_read_notes), Modifier.size(20.dp))
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package cx.aswin.boxlore.updates

import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import cx.aswin.boxlore.core.designsystem.links.startFirstAvailable

private const val PLAY_STORE_PACKAGE = "com.android.vending"

Expand All @@ -18,9 +18,5 @@ internal fun playStoreWebUri(packageName: String): Uri = Uri.parse("https://play
*/
internal fun openPlayListing(context: Context) {
val app = Intent(Intent.ACTION_VIEW, playStoreAppUri(context.packageName)).setPackage(PLAY_STORE_PACKAGE)
try {
context.startActivity(app)
} catch (_: ActivityNotFoundException) {
context.startActivity(Intent(Intent.ACTION_VIEW, playStoreWebUri(context.packageName)))
}
context.startFirstAvailable(app, Intent(Intent.ACTION_VIEW, playStoreWebUri(context.packageName)))
}
1 change: 1 addition & 0 deletions core/designsystem/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Owns shared Compose visual primitives: theme, typography, shapes, motion, loader
- `ProgressiveSearchScrollLogic` decides when a progressive Find-a-show list should pin to the top (query change, new top hit, or Matches header). Used by onboarding search and Explore Find-a-show; Ask anything does not use it.
- `LazyListKeyPolicy` provides safe deduplication and collision-free key generation for LazyColumn, LazyRow, and LazyGrid lists, preventing `IllegalArgumentException: Key was already used` crashes from duplicate backend feeds or search hits.
- `share.ShareManager` for composite share cards and the system share sheet; emits glossary `share_content` via `:core:analytics`. `ShareBottomSheet` presents a clear content preview, a switch-style timestamp option, a primary artwork share action, and separate link/story actions.
- `links.startFirstAvailable` starts the first of several intents an app on the phone handles (the Play app, then the Play web page; a per-app settings page, then the app's details) and returns false instead of throwing when none can; `links.openLink` opens a link that way and shows "No app could open this link" when nothing can; `links.SafeUriHandler` is the app's Compose link handler built on it, because Compose's own `UriHandler` throws when no app can open a link. A phone without a browser, store or a settings page its ROM removed never crashes the app. `SafeLinksTest` covers the first handled intent winning, none handled returning false without throwing, a new task outside an Activity, and the note.
- Episode share links come from `ShareManager.episodeShareLink` (the address and whether it opens the show; `episodeShareUrl` is the address alone), which asks `ShareManager.episodeShareTargets` (an `EpisodeShareTargetSource` from `:core:model`; the app installs one that reads aliases and registers the episode, with a short wait) which ID and show the link names. When the installed source fails, the episode's own fields decide. `shareEpisode` builds its text and card after that lookup, off the main thread, and leaves out the start time or clip when the link opens the show. `EpisodeShareLinkTest` covers episode and show targets, the fallback for a failing source, and `t`/`start`/`end` beside `&show=`. The sheet's Copy link takes the shared `episode` and copies through `ShareManager.copyEpisodeLink` in the share scope, so closing the sheet doesn't cancel it; podcast links and callers that pass no episode copy at once as before. The sheet's layout is unchanged.
- `ShareManager.shareImage` shares a card a screen rendered (Listening history's story share card) with its text: the PNG is written off the main thread beside the other share cards in the cache, cleared after a day, and the text goes alone if it can't be written.
- `share.ShareCardRenderer` builds the share-card bitmaps used by `ShareManager`. Stories and square artwork separate episode/show details from a compact “Listen on” label and upright boxlore wordmark.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package cx.aswin.boxlore.core.designsystem.links

import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.compose.ui.platform.UriHandler
import cx.aswin.boxlore.core.designsystem.R

/**
* Starts the first of [intents] an app on this phone can handle, trying the next when none can (the
* Play app, then the Play web page, say). Returns false, without throwing, when none can: a phone
* without a browser, a store or a settings page a ROM removed must never crash the app.
*/
fun Context.startFirstAvailable(vararg intents: Intent): Boolean {
val fromActivity = findActivity() != null
for (intent in intents) {
val launch = if (fromActivity) intent else Intent(intent).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try {
startActivity(launch)
return true
} catch (_: ActivityNotFoundException) {
// Nothing handles this one: try the next.
} catch (_: SecurityException) {
// The only handler isn't open to other apps: try the next.
}
}
return false
}

/** Opens [uri] in whatever app handles it, or says briefly that no app could. Returns whether one opened it. */
fun Context.openLink(uri: Uri): Boolean {
val opened = startFirstAvailable(Intent(Intent.ACTION_VIEW, uri))
if (!opened) Toast.makeText(this, R.string.link_unavailable, Toast.LENGTH_SHORT).show()
return opened
}

/**
* Compose's link handler for the whole app: Compose's own throws when no app can open a link, which
* crashed every `openUri` caller on such a phone. This one opens links with [openLink].
*/
class SafeUriHandler(private val context: Context) : UriHandler {
override fun openUri(uri: String) {
context.openLink(Uri.parse(uri))
}
}

private fun Context.findActivity(): Activity? {
var current: Context? = this
while (current is ContextWrapper) {
if (current is Activity) return current
current = current.baseContext
}
return null
}
2 changes: 2 additions & 0 deletions core/designsystem/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,6 @@
<string name="repair_progress_body">Please keep boxlore open until this finishes.</string>
<string name="feed_media_new">New</string>
<string name="share_listen_on">Listen on</string>
<!-- Shown briefly when no app on the phone can open a link (no browser, say). -->
<string name="link_unavailable">No app could open this link</string>
</resources>
Loading
Loading