diff --git a/app/README.md b/app/README.md index a305fc001..f9e98350d 100644 --- a/app/README.md +++ b/app/README.md @@ -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 `/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=` (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`). diff --git a/app/src/main/java/cx/aswin/boxlore/MainActivity.kt b/app/src/main/java/cx/aswin/boxlore/MainActivity.kt index 92ec17682..d93acdbb2 100644 --- a/app/src/main/java/cx/aswin/boxlore/MainActivity.kt +++ b/app/src/main/java/cx/aswin/boxlore/MainActivity.kt @@ -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, 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 ac26146ba..7a23d4d1a 100644 --- a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt +++ b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt @@ -397,7 +397,13 @@ private fun androidx.navigation.NavGraphBuilder.addFeedbackRoute(w: NavGraphWiri // `feedback?thread=` 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, 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 5801129d9..d6c6ab065 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 @@ -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 { @@ -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. */ diff --git a/app/src/main/java/cx/aswin/boxlore/updates/AppUpdatesPanel.kt b/app/src/main/java/cx/aswin/boxlore/updates/AppUpdatesPanel.kt index 532eb3ac8..6bbcc9361 100644 --- a/app/src/main/java/cx/aswin/boxlore/updates/AppUpdatesPanel.kt +++ b/app/src/main/java/cx/aswin/boxlore/updates/AppUpdatesPanel.kt @@ -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 @@ -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 @@ -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)) } } diff --git a/app/src/main/java/cx/aswin/boxlore/updates/PlayStoreListing.kt b/app/src/main/java/cx/aswin/boxlore/updates/PlayStoreListing.kt index 1e3830b8e..e8bcb2d17 100644 --- a/app/src/main/java/cx/aswin/boxlore/updates/PlayStoreListing.kt +++ b/app/src/main/java/cx/aswin/boxlore/updates/PlayStoreListing.kt @@ -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" @@ -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))) } diff --git a/core/designsystem/README.md b/core/designsystem/README.md index c5e494e1f..e3f795905 100644 --- a/core/designsystem/README.md +++ b/core/designsystem/README.md @@ -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. diff --git a/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/links/SafeLinks.kt b/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/links/SafeLinks.kt new file mode 100644 index 000000000..b2a1f117b --- /dev/null +++ b/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/links/SafeLinks.kt @@ -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 +} diff --git a/core/designsystem/src/main/res/values/strings.xml b/core/designsystem/src/main/res/values/strings.xml index 554d68391..c0935ce98 100644 --- a/core/designsystem/src/main/res/values/strings.xml +++ b/core/designsystem/src/main/res/values/strings.xml @@ -5,4 +5,6 @@ Please keep boxlore open until this finishes. New Listen on + + No app could open this link diff --git a/core/designsystem/src/test/java/cx/aswin/boxlore/core/designsystem/links/SafeLinksTest.kt b/core/designsystem/src/test/java/cx/aswin/boxlore/core/designsystem/links/SafeLinksTest.kt new file mode 100644 index 000000000..1eb23501d --- /dev/null +++ b/core/designsystem/src/test/java/cx/aswin/boxlore/core/designsystem/links/SafeLinksTest.kt @@ -0,0 +1,81 @@ +package cx.aswin.boxlore.core.designsystem.links + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.net.Uri +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowToast + +/** + * Regression: tapping a link on a phone where no app could open it (an Instagram link with no browser) + * crashed the episode page with ActivityNotFoundException, and the same could happen from every other + * link, store and settings button. These start the first intent the phone handles, or none, never throwing. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class SafeLinksTest { + /** A phone whose apps open only what [opens] accepts. */ + private class Phone(private val opens: (Intent) -> Boolean) : ContextWrapper(RuntimeEnvironment.getApplication()) { + val started = mutableListOf() + + override fun startActivity(intent: Intent) { + if (!opens(intent)) throw ActivityNotFoundException("No app for $intent") + started += intent + } + } + + private val market = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=cx.aswin.boxlore")) + private val web = Intent(Intent.ACTION_VIEW, Uri.parse("https://play.google.com/store/apps/details?id=cx.aswin.boxlore")) + + @Test + fun theFirstIntentAnAppHandlesStarts() { + val phone = Phone { it.data?.scheme == "https" } + + assertTrue(phone.startFirstAvailable(market, web)) + assertEquals(listOf("https"), phone.started.map { it.data?.scheme }) + } + + @Test + fun noAppForAnyIntentReturnsFalseWithoutThrowing() { + val phone = Phone { false } + + assertFalse(phone.startFirstAvailable(market, web)) + assertTrue(phone.started.isEmpty()) + } + + @Test + fun outsideAnActivityTheLaunchStartsANewTask() { + val phone = Phone { true } + + phone.startFirstAvailable(web) + + assertTrue(phone.started.single().flags and Intent.FLAG_ACTIVITY_NEW_TASK != 0) + } + + @Test + fun aLinkNoAppCanOpenSaysSoInsteadOfCrashing() { + val phone = Phone { false } + + SafeUriHandler(phone).openUri("https://www.instagram.com/serialpodcast") + + assertEquals("No app could open this link", ShadowToast.getTextOfLatestToast()) + assertFalse((phone as Context).openLink(Uri.parse("https://example.com"))) + } + + @Test + fun aLinkAnAppCanOpenOpensIt() { + val phone = Phone { true } + + assertTrue(phone.openLink(Uri.parse("https://example.com/notes"))) + assertEquals(Intent.ACTION_VIEW, phone.started.single().action) + } +} diff --git a/core/support/src/main/java/cx/aswin/boxlore/core/support/SupportRepository.kt b/core/support/src/main/java/cx/aswin/boxlore/core/support/SupportRepository.kt index d7a7b88ec..25df15158 100644 --- a/core/support/src/main/java/cx/aswin/boxlore/core/support/SupportRepository.kt +++ b/core/support/src/main/java/cx/aswin/boxlore/core/support/SupportRepository.kt @@ -1,9 +1,9 @@ package cx.aswin.boxlore.core.support import cx.aswin.boxlore.core.network.SupportApi +import cx.aswin.boxlore.core.network.model.SupportEndingDto import cx.aswin.boxlore.core.network.model.SupportErrorResponse import cx.aswin.boxlore.core.network.model.SupportListResponse -import cx.aswin.boxlore.core.network.model.SupportEndingDto import cx.aswin.boxlore.core.network.model.SupportMessageDto import cx.aswin.boxlore.core.network.model.SupportNewThreadRequest import cx.aswin.boxlore.core.network.model.SupportReadRequest @@ -11,17 +11,17 @@ import cx.aswin.boxlore.core.network.model.SupportReplyRequest import cx.aswin.boxlore.core.network.model.SupportReplyResponse import cx.aswin.boxlore.core.network.model.SupportThreadDto import java.io.IOException +import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers -import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType diff --git a/core/support/src/test/java/cx/aswin/boxlore/core/support/SupportRepositoryTest.kt b/core/support/src/test/java/cx/aswin/boxlore/core/support/SupportRepositoryTest.kt index 07f36f183..3a07a053f 100644 --- a/core/support/src/test/java/cx/aswin/boxlore/core/support/SupportRepositoryTest.kt +++ b/core/support/src/test/java/cx/aswin/boxlore/core/support/SupportRepositoryTest.kt @@ -61,7 +61,13 @@ class SupportRepositoryTest { val meta = repo.refresh() assertEquals( SupportMeta( - true, "Slow this week", true, 5, 5_242_880, 4000, listOf("support_ab", "support_cd"), + true, + "Slow this week", + true, + 5, + 5_242_880, + 4000, + listOf("support_ab", "support_cd"), listOf(SupportCategory("bug", "Bug report"), SupportCategory("account", "Account and sync")), ), meta, diff --git a/feature/home/README.md b/feature/home/README.md index 2ceed7b21..51778b000 100644 --- a/feature/home/README.md +++ b/feature/home/README.md @@ -76,6 +76,7 @@ src/main/java/cx/aswin/boxlore/feature/home/ ``` Main Kotlin files should remain below 1000 lines; extracted Home feed, ViewModel, section-row, and logic files keep UI assembly and behavior testable. +- The Play Store page (review prompts and the post-review sheet) opens through `:core:designsystem`'s `startFirstAvailable`: the Play app, else the web page, else nothing, never a crash. ## Dependencies diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/NpsSurveyHost.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/NpsSurveyHost.kt index 28dee5ef0..b27a72aeb 100644 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/NpsSurveyHost.kt +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/NpsSurveyHost.kt @@ -12,6 +12,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.repeatOnLifecycle import cx.aswin.boxlore.core.analytics.AnalyticsHelper +import cx.aswin.boxlore.core.designsystem.links.startFirstAvailable import cx.aswin.boxlore.feature.home.components.NpsSurveySheet /** @@ -50,9 +51,8 @@ internal fun NpsSurveyHost(controller: NpsSurveyController, isPlaying: Boolean, /** boxlore's Play Store page: the Play app, or the web page when Play isn't there. */ internal fun openPlayStorePage(context: Context) { val pkgName = context.packageName - try { - context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$pkgName"))) - } catch (e: android.content.ActivityNotFoundException) { - context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://play.google.com/store/apps/details?id=$pkgName"))) - } + context.startFirstAvailable( + Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$pkgName")), + Intent(Intent.ACTION_VIEW, Uri.parse("https://play.google.com/store/apps/details?id=$pkgName")), + ) } 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 3bebf4be4..3da364495 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 @@ -19,6 +19,7 @@ import androidx.compose.ui.unit.dp import cx.aswin.boxlore.core.designsystem.analytics.TrackScreenView import cx.aswin.boxlore.core.designsystem.components.EngagementBottomSheetScaffold import cx.aswin.boxlore.core.designsystem.layout.tabletSheetScroll +import cx.aswin.boxlore.core.designsystem.links.startFirstAvailable import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight import kotlinx.coroutines.launch @@ -255,21 +256,10 @@ fun PostReviewSheet( cx.aswin.boxlore.core.analytics.AnalyticsHelper .trackStoreReviewOpened(method = "store_page", source = "post_review") val pkgName = context.packageName - try { - context.startActivity( - android.content.Intent( - android.content.Intent.ACTION_VIEW, - android.net.Uri.parse("market://details?id=$pkgName"), - ), - ) - } catch (e: Exception) { - context.startActivity( - android.content.Intent( - android.content.Intent.ACTION_VIEW, - android.net.Uri.parse("https://play.google.com/store/apps/details?id=$pkgName"), - ), - ) - } + context.startFirstAvailable( + android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse("market://details?id=$pkgName")), + android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse("https://play.google.com/store/apps/details?id=$pkgName")), + ) }, modifier = Modifier diff --git a/feature/home/src/test/java/cx/aswin/boxlore/feature/home/PlayStorePageLinkTest.kt b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/PlayStorePageLinkTest.kt new file mode 100644 index 000000000..3de43c1d5 --- /dev/null +++ b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/PlayStorePageLinkTest.kt @@ -0,0 +1,60 @@ +package cx.aswin.boxlore.feature.home + +import android.content.ActivityNotFoundException +import android.content.ContextWrapper +import android.content.Intent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +/** + * The review prompt's Play Store page: the Play app, else the Play web page, else nothing; never a crash. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class PlayStorePageLinkTest { + /** A phone whose apps open only what [opens] accepts; it records what it was asked to open. */ + private class Phone(private val opens: (Intent) -> Boolean) : ContextWrapper(RuntimeEnvironment.getApplication()) { + val asked = mutableListOf() + val started = mutableListOf() + + override fun startActivity(intent: Intent) { + asked += intent + if (!opens(intent)) throw ActivityNotFoundException("No app for $intent") + started += intent + } + } + + @Test + fun thePlayAppOpensWhenThePhoneHasIt() { + val phone = Phone { true } + + openPlayStorePage(phone) + + assertEquals("market://details?id=${phone.packageName}", phone.started.single().data.toString()) + } + + @Test + fun withoutThePlayAppTheWebPageOpens() { + val phone = Phone { it.data?.scheme == "https" } + + openPlayStorePage(phone) + + assertEquals("https://play.google.com/store/apps/details?id=${phone.packageName}", phone.started.single().data.toString()) + assertEquals(listOf("market", "https"), phone.asked.map { it.data?.scheme }) + } + + @Test + fun withNeitherNothingOpensAndNothingCrashes() { + val phone = Phone { false } + + openPlayStorePage(phone) + + assertTrue(phone.started.isEmpty()) + assertEquals(2, phone.asked.size) + } +} diff --git a/feature/info/README.md b/feature/info/README.md index 282f5283e..65435c168 100644 --- a/feature/info/README.md +++ b/feature/info/README.md @@ -19,6 +19,7 @@ Disclosure recognizes canonical RSS IDs even with stale source metadata and uses - RSS-only shows expose the same notification and auto-download toolbar controls as catalog shows. Both the bell and the auto-download notice’s **Turn on notifications** action open `RssNotificationDisclosureDialog` before Android permission handling: users confirm that turning notifications on will make the feed link public. The dialog advises keeping notifications off for private or premium feeds and explains that listening, refresh and downloads remain available. Cancelling leaves notifications off; auto-download works independently. Existing imported shows use the same disclosure. - `PodcastInfoScreen` and `PodcastInfoViewModel`. Long-pressing an episode enters multi-selection; the floating toolbar can download, mark completed (or mark unplayed when every selected episode is already complete), play, or append selected episodes to the queue. Its overflow can select only cards currently visible on screen, or every episode of the show for Select all / Select older / Select newer: `loadPodcastEpisodeSelectionWindow` reads the show page after page (`SELECTION_PAGE_SIZE`, the most one request returns) until it has no more, with no cap, and a feed-first page whose copy holds only the newest items reads the rest of the feed first (`ShowFeedLogic.ArchiveAction.SELECT_RANGE`), and fails the range when that read leaves the copy incomplete (offline, a failed read; `ShowFeedLogic.selectionReadsWholeShow`), while a page that fell back pages the server's list. While searching, the three act on the results on screen instead, newest first (`PodcastEpisodeSelectionLogic.searchRangeEpisodes`), and read nothing. A range adds to what is already selected. When a page fails to load, the selection stays as it was and a snackbar says "Couldn’t load every episode. Check your connection and try again."; part of a show is never selected as if it were all of it. Fetched episode metadata is retained only while selection is active. A selected card takes the theme's primary container on the regular page; on a Showcase page, whose accent is the show's own (often bright or pale) colour, it keeps its own surface tinted with 16% of the accent and its usual text (`episodeSelectionColors`), with the accent's ring and tick on both. Episode sorting remains available. Subscribed shows expose **Pin to Home screen** / **Unpin from Home screen** and **Change tag / genre** in the overflow menu (or by tapping the genre chip directly) to open `PodcastGenreEditSheet` with IME keyboard support, real-time genre search pairing names with their icons, cross-suggestions from existing subscription folder names, and a 31-icon Material Rounded palette. Unsubscribing clears custom genre tags and icons back to default. After a genre change, `PodcastInfoFolderSyncLogic` refreshes genre folders; with auto-organize on it passes the look saved on Auto-organize, so a folder it creates matches that look. Tapping the download action on an already-downloaded episode prompts with a Material 3 confirmation dialog (`RemoveDownloadConfirmationDialog`) before removing the download. Episode play taps and multi-selection play pass `podcast_detail` as the playback entry point context (allowing same-show continuation in Smart Queue) while preserving any spotlight route for video telemetry. Show notification toggles feature system permission awareness: when show notifications are active but device notifications are disabled, `EpisodeToolbar` renders the active bell icon in an error-container red accent with a red badge dot; tapping the icon reveals `ToolbarWarning.SYSTEM_PERMISSION_BLOCKED` explaining the status; the "Turn On" action prompts for `POST_NOTIFICATIONS` runtime permission when available, and otherwise opens system settings. +- Links and settings from the show page start through `:core:designsystem`'s `openLink` / `startFirstAvailable`: a host's or guest's link and the funding chip show "No app could open this link" when nothing can open them, and the notification-settings shortcut falls back to the app's details page, then does nothing, instead of crashing. - Podcast Info retains the centered cover/title/publisher, horizontally scrolling metadata, About disclosure and existing episode-toolbar position. Subscribe, its independent notification/auto-download toggles, Sort and Search form one native Material 3 connected row with uniform 3dp gaps, 48dp targets, consistent neutral surfaces, pressed shapes and rounded outer ends. Subscribe animates its own pressed shape (`subscribeButtonShape`) rather than Material's built-in morph, which could leave it at the pressed shape after the tap that changed the subscription; at rest it is always its own shape. It takes the pressed shape only while the button reports a press and a finger is still on it (`subscribePressTarget`, `trackFingerDown`), so a press whose end never reaches the button can't hold the pressed shape; `SubscribePressShapeTest` covers held presses that subscribe and unsubscribe, a press cut off mid-way, and a press that never reports its end. The existing order and callbacks are preserved; only notification and auto-download settings have checked states. Subscription confirmation retains the podcast's genre icon (or the original heart fallback), using a coordinated small tilt and scale spring with an 800ms hold before crossfading to Subscribed. A shared critically damped spring controls the settings slot and the subscription button width, with staggered bell/download fades; Search and Sort stay anchored. Unsubscribing reverses from the current animation position without snapping. Partially revealed or exiting settings are excluded from interaction and accessibility until fully visible, and system-disabled animations skip the confirmation hold. The subscription label stays on one line and ellipsizes at large text sizes, with its full action available to accessibility. Blocked notifications keep the error-container bell and badge. Metadata uses compact neutral pills, with a tonal genre chip and a quieter Play trailer action. Episode cards and collapsed trailers use solid 24dp rounded surfaces without idle outlines; selection retains its primary outline. Episode rows show a two-line title and description, a one-line ellipsized metadata summary and wrapping Bonus/Trailer/Video tags. Like, Download, Queue and a wider Play/Resume/Pause button form one connected row. Episode and subscription toggles reuse the Episode Info group's surface-container-highest idle colors, primary-container active colors and native circular checked shapes, while blocked notifications retain their error colors. Narrow widths and larger text use an accessible icon-only play action rather than clipping its label. Listening progress and remaining time share one vertically centered row below the actions: a continuous 6dp rounded track with no moving gap or end dot, followed by a single-line time label at the logical end. The time label uses its natural width up to 45% of the row, then ellipsizes for enlarged text or long translations while preserving the full text for accessibility. Without a time label the bar uses the full row; unstarted and completed episodes reserve no progress space. Search keeps its mark-played action as a separate pill. Playback, subscription, notification disclosure, auto-download, selection, grouping, pagination and navigation callbacks are unchanged. - A page opened from a link, push or Android Auto with only an episode ID (and maybe its show) resolves it through `PodcastRepository.resolveEpisodeLink`: stored row, trusted alias, the API, then one read of the show's feed for an episode only the feed lists. While that read runs the page shows `EpisodeInfoUiState.ReadingFeed` ("Finding this episode", "Checking the show’s feed. This can take a moment."); when nothing lists the episode it shows `NotFound` ("Episode not found" and Back; with the link's show, "It may have been removed from the show." and View show; without one, "It may have been removed, or this link may not open on this phone.", since such a link can name an ID only the sharer's phone holds). An unreachable source keeps the existing load error. A link resolved through an alias shows the episode under the phone's own ID; `Success.requestedEpisodeId` keeps the ID the page was opened with, which deep-link autoplay and artwork match on; download state and actions read the page's episode ID (`EpisodeLoadRequests.pageEpisode`). A share from the page fills in the page's show when the episode has none (`EpisodeLoadRequests.shareEpisode`), and a page built from history or a download carries its show, so its link keeps `&show=`. `EpisodeLoadRequests` skips a second load of the same ID unless it brings the show, so the route's and the screen's calls don't read a feed twice. `EpisodeLinkStatus` centres both states in the safe area at a readable width (480dp) on phones, tablets, split windows and folds. - `EpisodeInfoScreen` and `EpisodeInfoViewModel` (similar episodes use prefs `content_languages` + region). The artwork-first Material 3 layout keeps a visible blurred cover backdrop within a bounded, overscanned blur layer with a gradual alpha mask that reaches transparency before its lower edge, uses theme-based tonal surfaces and plain Back/Share icons with 48dp touch targets, and limits episode titles to three lines with an overflow-only arrow inside a small translucent circle at the trailing end of the third line. The first two title lines retain the full width; expanded titles keep all text visible and place the collapse arrow at the end. The generic Episode toolbar label is omitted; the episode title still appears when scrolled. The hero podcast name uses one line with ellipsis; Read more / Show less use visible filled-tonal pills. Like, Download and Queue use connected tonal toggle buttons with rounded outer ends, small gaps, and rounded selected states; a wider filled Play button completes the same 56dp-high row; listening progress and its time summary sit below the action group; Bonus/Trailer/Video tags sit inside the artwork at the top-start corner as compact opaque tonal pills with a 12dp inset; paired tags wrap for larger text or longer translations. Date, duration and season/episode numbers use middle-dot separators below the title in a single-line row. The completion pill stays fixed at the logical end; metadata takes the remaining width and marquees only when it overflows, with a 2.5-second initial pause, 2-second pauses between passes, and a 24dp/s scroll speed. Short metadata stays still, missing values do not leave stray separators, and the complete metadata remains available to accessibility. The pill label stays on one line, with ellipsis only when an unusually long translation or enlarged text exceeds the available width. The pill shows Mark played before completion and Played after completion; tapping it toggles the existing completion action, with Mark unplayed exposed to accessibility. Its guidance appears next to the metadata when requested. Chapters appear above About, use remote Podcast 2.0 chapters when valid, and fall back to parsed description chapters. Expanded About preserves publisher formatting and timestamp seeking. Resource pills size to their labels and pack independently into two horizontally scrolling rows; one link uses one row and two links stack one per row. Links with recognized brand icons come first, followed by generic links, retaining publisher order within each group. Priority fills top then bottom before moving across (first/second in the first pair, third/fourth in the next). Recognition uses the same domain matching as the icon and tint, so lookalike hosts do not gain priority. Recognized social profiles with a parsed username show only that username beside the platform icon; accessibility still names the profile and platform. Root pages, posts without a parsed username, unknown platforms, and video/podcast/support links retain descriptive destination or publisher-context labels. Bubbles use solid platform-tinted surfaces with contrast-adjusted logos for recognized social, video, podcast and support domains (with Material purpose icons as the fallback). TikTok uses a cyan seed with a softly tinted bubble and a contrast-adjusted cyan icon; X and Threads retain monochrome theme-foreground icons rather than inventing a brand hue. Cross-promotion cards appear only after the shared detector and resolver identify another show. Try these episodes reuses the Home `FeedMediaCard` expressive poster treatment with episode-title-only text and duration on the artwork. More from keeps the compact artwork/title/date/duration row cards and lists the five latest distinct episodes vertically, excluding the current episode. The show heads its own list as the first row of one connected group (24dp top corners, 6dp joins), on `secondaryContainer` with `onSecondaryContainer` text so it reads as the header rather than another episode row (the rows stay on `surfaceContainer`): its artwork (the show's cover, `moreFromShowPicture`), a small "More from" label over the show name (two lines at most) and a chevron. The row is the section heading for accessibility ("More from …") with a "View …" action; it and the Explore podcast button below the list both open the same show. Try these episodes leads with its icon in a solid Cookie9Sided badge on the page's primary-container pair, beside a bold heading. The section shares the page scroll and retains related-content scroll and click analytics. Both retain show identity for accessibility. Playback, download, queue, navigation, recommendation ranking and analytics callbacks remain owned by their existing layers. Tapping download on a downloaded episode prompts for confirmation before removing local media. diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/PodcastInfoChrome.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/PodcastInfoChrome.kt index 833a3a899..3865c3ce4 100644 --- a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/PodcastInfoChrome.kt +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/PodcastInfoChrome.kt @@ -66,6 +66,7 @@ import androidx.compose.ui.unit.dp import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import cx.aswin.boxlore.core.designsystem.analytics.TrackScreenView +import cx.aswin.boxlore.core.designsystem.links.startFirstAvailable import cx.aswin.boxlore.core.designsystem.theme.ExpressiveShapes import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight import cx.aswin.boxlore.core.model.Episode @@ -666,5 +667,10 @@ internal fun openAppNotificationSettings(context: android.content.Context) { } flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK } - context.startActivity(intent) + // Some ROMs drop the per-app notification page: fall back to the app's details, else do nothing. + val details = android.content.Intent( + android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + android.net.Uri.fromParts("package", context.packageName, null), + ) + context.startFirstAvailable(intent, details) } diff --git a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/PodcastInfoMetadataChips.kt b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/PodcastInfoMetadataChips.kt index 4df416e72..3e60d2620 100644 --- a/feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/PodcastInfoMetadataChips.kt +++ b/feature/info/src/main/java/cx/aswin/boxlore/feature/info/components/PodcastInfoMetadataChips.kt @@ -1,6 +1,5 @@ package cx.aswin.boxlore.feature.info.components -import android.content.Intent import android.net.Uri import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement @@ -36,6 +35,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cx.aswin.boxlore.core.designsystem.components.OptimizedImage import cx.aswin.boxlore.core.designsystem.icon.GenreIcons +import cx.aswin.boxlore.core.designsystem.links.openLink import cx.aswin.boxlore.core.designsystem.theme.ExpressiveShapes import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight import cx.aswin.boxlore.core.designsystem.theme.rememberGoogleSansFamily @@ -435,8 +435,8 @@ private fun openPersonLink( context: android.content.Context, person: Person, ) { - if (person.href.isNullOrBlank()) return - context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(person.href))) + val href = person.href?.takeIf { it.isNotBlank() } ?: return + context.openLink(Uri.parse(href)) } private fun LazyListScope.podcastIdentityChips( @@ -486,8 +486,7 @@ private fun LazyListScope.fundingChip( FundingChip( fundingMessage = podcast.fundingMessage, onClick = { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(podcast.fundingUrl)) - context.startActivity(intent) + podcast.fundingUrl?.let { context.openLink(Uri.parse(it)) } }, ) } diff --git a/feature/info/src/test/java/cx/aswin/boxlore/feature/info/components/NotificationSettingsLinkTest.kt b/feature/info/src/test/java/cx/aswin/boxlore/feature/info/components/NotificationSettingsLinkTest.kt new file mode 100644 index 000000000..0b06144e2 --- /dev/null +++ b/feature/info/src/test/java/cx/aswin/boxlore/feature/info/components/NotificationSettingsLinkTest.kt @@ -0,0 +1,63 @@ +package cx.aswin.boxlore.feature.info.components + +import android.content.ActivityNotFoundException +import android.content.ContextWrapper +import android.content.Intent +import android.provider.Settings +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +/** + * The show page's notification-settings shortcut: the app's notification page, else its details, else nothing; never a crash. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class NotificationSettingsLinkTest { + /** A phone whose apps open only what [opens] accepts; it records what it was asked to open. */ + private class Phone(private val opens: (Intent) -> Boolean) : ContextWrapper(RuntimeEnvironment.getApplication()) { + val asked = mutableListOf() + val started = mutableListOf() + + override fun startActivity(intent: Intent) { + asked += intent + if (!opens(intent)) throw ActivityNotFoundException("No app for $intent") + started += intent + } + } + + @Test + fun theAppsNotificationPageOpensWhenThePhoneHasIt() { + val phone = Phone { true } + + openAppNotificationSettings(phone) + + assertEquals(Settings.ACTION_APP_NOTIFICATION_SETTINGS, phone.started.single().action) + assertEquals(phone.packageName, phone.started.single().getStringExtra(Settings.EXTRA_APP_PACKAGE)) + } + + @Test + fun withoutThatPageTheAppsDetailsOpenInstead() { + val phone = Phone { it.action == Settings.ACTION_APPLICATION_DETAILS_SETTINGS } + + openAppNotificationSettings(phone) + + val details = phone.started.single() + assertEquals("package:${phone.packageName}", details.data.toString()) + assertEquals(listOf(Settings.ACTION_APP_NOTIFICATION_SETTINGS, Settings.ACTION_APPLICATION_DETAILS_SETTINGS), phone.asked.map { it.action }) + } + + @Test + fun withNeitherPageNothingOpensAndNothingCrashes() { + val phone = Phone { false } + + openAppNotificationSettings(phone) + + assertTrue(phone.started.isEmpty()) + assertEquals(2, phone.asked.size) + } +} diff --git a/feature/settings/README.md b/feature/settings/README.md index e1bc9b6a7..14d2746d5 100644 --- a/feature/settings/README.md +++ b/feature/settings/README.md @@ -140,6 +140,7 @@ src/main/java/cx/aswin/boxlore/feature/settings/ SupportEntryImpression.kt # Settings Support-entry impression SyncAndBackupsPage.kt ``` +- The app's notification settings (feedback's ask) open through `:core:designsystem`'s `startFirstAvailable`: the per-app page, else the app's details, else nothing, never a crash. ## Dependencies diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackNotificationAsk.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackNotificationAsk.kt index 376376091..56791482e 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackNotificationAsk.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackNotificationAsk.kt @@ -12,6 +12,7 @@ import android.provider.Settings import androidx.core.app.ActivityCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat +import cx.aswin.boxlore.core.designsystem.links.startFirstAvailable /** * The "turn on notifications" ask after sending feedback. Same rules as the show page and import @@ -37,7 +38,8 @@ internal fun canAskForNotifications(context: Context, askedBefore: Boolean): Boo val granted = sdk >= Build.VERSION_CODES.TIRAMISU && ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED val activity = context.findActivity() - val rationale = activity != null && sdk >= Build.VERSION_CODES.TIRAMISU && + val rationale = activity != null && + sdk >= Build.VERSION_CODES.TIRAMISU && ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.POST_NOTIFICATIONS) return canAskForNotifications(sdk, granted, askedBefore, rationale) } @@ -46,14 +48,11 @@ internal fun openAppNotificationSettings(context: Context) { val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - try { - context.startActivity(intent) - } catch (_: android.content.ActivityNotFoundException) { - context.startActivity( - Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), - ) - } + context.startFirstAvailable( + intent, + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) } private tailrec fun Context.findActivity(): Activity? = when (this) { 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 93fcb9239..00ef30b53 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 @@ -6,8 +6,8 @@ import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.ScrollState import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column @@ -24,7 +24,7 @@ import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackConversationsAdaptiveTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackConversationsAdaptiveTest.kt index 4dc7d686d..3dc497db6 100644 --- a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackConversationsAdaptiveTest.kt +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackConversationsAdaptiveTest.kt @@ -9,8 +9,8 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.Modifier -import androidx.compose.ui.test.junit4.StateRestorationTester import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.junit4.StateRestorationTester import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackNotificationSettingsTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackNotificationSettingsTest.kt new file mode 100644 index 000000000..103cfa652 --- /dev/null +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackNotificationSettingsTest.kt @@ -0,0 +1,63 @@ +package cx.aswin.boxlore.feature.settings.feedback + +import android.content.ActivityNotFoundException +import android.content.ContextWrapper +import android.content.Intent +import android.provider.Settings +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +/** + * Feedback's notification-settings link: the app's notification page, else its details, else nothing; never a crash. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class FeedbackNotificationSettingsTest { + /** A phone whose apps open only what [opens] accepts; it records what it was asked to open. */ + private class Phone(private val opens: (Intent) -> Boolean) : ContextWrapper(RuntimeEnvironment.getApplication()) { + val asked = mutableListOf() + val started = mutableListOf() + + override fun startActivity(intent: Intent) { + asked += intent + if (!opens(intent)) throw ActivityNotFoundException("No app for $intent") + started += intent + } + } + + @Test + fun theAppsNotificationPageOpensWhenThePhoneHasIt() { + val phone = Phone { true } + + openAppNotificationSettings(phone) + + assertEquals(Settings.ACTION_APP_NOTIFICATION_SETTINGS, phone.started.single().action) + assertEquals(phone.packageName, phone.started.single().getStringExtra(Settings.EXTRA_APP_PACKAGE)) + } + + @Test + fun withoutThatPageTheAppsDetailsOpenInstead() { + val phone = Phone { it.action == Settings.ACTION_APPLICATION_DETAILS_SETTINGS } + + openAppNotificationSettings(phone) + + val details = phone.started.single() + assertEquals("package:${phone.packageName}", details.data.toString()) + assertEquals(listOf(Settings.ACTION_APP_NOTIFICATION_SETTINGS, Settings.ACTION_APPLICATION_DETAILS_SETTINGS), phone.asked.map { it.action }) + } + + @Test + fun withNeitherPageNothingOpensAndNothingCrashes() { + val phone = Phone { false } + + openAppNotificationSettings(phone) + + assertTrue(phone.started.isEmpty()) + assertEquals(2, phone.asked.size) + } +} 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 a0e58cc07..8c98f5684 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 @@ -8,8 +8,8 @@ import cx.aswin.boxlore.core.prefs.BoxcastPrefs import cx.aswin.boxlore.core.prefs.FeedbackDraft import cx.aswin.boxlore.core.support.OutgoingFeedback import cx.aswin.boxlore.core.support.PhotoUpload -import cx.aswin.boxlore.core.support.SupportCategory import cx.aswin.boxlore.core.support.SendResult +import cx.aswin.boxlore.core.support.SupportCategory import cx.aswin.boxlore.core.support.SupportMeta import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers @@ -48,6 +48,7 @@ class FeedbackViewModelTest { val uploads = mutableListOf() var result: SendResult = SendResult.Sent("t1") var photoIds: (Int) -> PhotoUpload = { PhotoUpload.Uploaded("p$it") } + /** While set, send waits for it. */ var sendGate: CompletableDeferred? = null var refreshes = 0 @@ -239,8 +240,12 @@ class FeedbackViewModelTest { val saved = object : RetryKeyStore { var value: Pair? = null override fun load() = value - override fun save(fingerprint: String, key: String) { value = fingerprint to key } - override fun clear() { value = null } + override fun save(fingerprint: String, key: String) { + value = fingerprint to key + } + override fun clear() { + value = null + } } val support = FakeSupport(SupportMeta.Offline.copy(photosEnabled = true, maxPhotos = 3, maxPhotoBytes = 100)).apply { result = SendResult.Failed } val first = FeedbackViewModel(support.asSupport(), prefs, context, ioDispatcher = testDispatcher, readPhoto = { ByteArray(10) }, retryKeys = RetryKeys(store = saved)) diff --git a/release-notes/upcoming.md b/release-notes/upcoming.md index 34836362b..02f4c8af1 100644 --- a/release-notes/upcoming.md +++ b/release-notes/upcoming.md @@ -30,5 +30,6 @@ Announcement title: - On show pages, Select all selects every episode of the show instead of stopping at 100, and while you search it selects your search results. A selected episode on a show's own-style page now stays readable. - Your monthly story with Echo opens again from Listening history instead of closing the app. Echo now lands smoothly between cards, the story fills the status and navigation bars, and its card's title no longer runs under the show covers. +- Tapping a link no app on your phone can open, like a host's page or a funding link, now says so instead of closing boxlore. - Playback controls on the lock screen, in notifications and in Samsung's Now Bar show rewind, play and forward in that order, even when nothing is queued next, with a Next episode button beside them when there is. - Fixed a crash that could close boxlore in the background while the player notification loaded an episode's artwork.