Skip to content
Merged
8 changes: 7 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ These values are part of the shipping product. Renames or recreations break upgr
:app
:core:model | :core:auth | :core:network | :core:domain | :core:database | :core:prefs
:core:analytics | :core:catalog | :core:rss | :core:downloads | :core:playback | :core:ranking
:core:designsystem | :core:testing
:core:designsystem | :core:testing | :core:support
:feature:home | :feature:settings | :feature:history | :feature:player | :feature:info | :feature:explore
:feature:library | :feature:onboarding | :feature:briefing | :feature:widgets
```
Expand All @@ -57,6 +57,7 @@ On disk, the folder path matches the Gradle id (`core/playback` → `:core:playb
| `:app` | `Application`, `AppContainer`, navigation host, FCM, `WorkerFactory` | [`app/README.md`](app/README.md) |
| `:core:model` | Shared models and enums | [`core/model/README.md`](core/model/README.md) |
| `:core:auth` | Authentication domain, `AuthRepository`, and Firebase Auth implementation | [`core/auth/README.md`](core/auth/README.md) |
| `:core:support` | Support conversations with the API (`SupportRepository`): switches, sending, photo upload, old-form fallback | [`core/support/README.md`](core/support/README.md) |
| `:core:network` | HTTP clients (`BoxLoreApi`, `BoxLoreApiV2` for `/v2` / `NetworkModule`) and network DTOs | [`core/network/README.md`](core/network/README.md) |
| `:core:domain` | Thin ports and small result types (no Room or repositories) | [`core/domain/README.md`](core/domain/README.md) |
| `:core:database` | Main Room database, entities, DAOs, migrations | [`core/database/README.md`](core/database/README.md) |
Expand Down Expand Up @@ -92,6 +93,7 @@ On disk, the folder path matches the Gradle id (`core/playback` → `:core:playb
| `:core:playback` | `cx.aswin.boxlore.core.playback` |
| `:core:database` | `cx.aswin.boxlore.core.database` |
| `:core:catalog` | `cx.aswin.boxlore.core.catalog` |
| `:core:support` | `cx.aswin.boxlore.core.support` |

## Dependency direction

Expand All @@ -118,6 +120,7 @@ flowchart TB
domain[:core:domain]
database[:core:database]
network[:core:network]
support[:core:support]
design[:core:designsystem]
model[:core:model]

Expand All @@ -140,6 +143,9 @@ flowchart TB
catalog --> database
catalog --> ranking
catalog --> network
features --> support
app --> support
support --> network
catalog --> model
features --> analytics
features --> ranking
Expand Down
1 change: 1 addition & 0 deletions app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ The application module owns the Android app shell: `BoxLoreApplication`, `MainAc
- 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.
- 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`).
- Library backup/import analytics (`trackBackupRestoreResult`, import failed) use allowlisted error codes from `LibraryBackupAnalyticsErrors` — never raw exception text.
- `MainActivity` / `BoxLoreAppRoot` own deep-link and session-restore analytics at the shell layer.
- `BoxLoreAppRoot` collects the default-off miniplayer seek preference from `:core:prefs` and supplies the same value to Appearance settings and `PlayerSheetLayout`. Appearance callbacks persist it without a feature-to-feature dependency.
Expand Down
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ dependencies {
implementation(libs.androidx.media3.cast)
implementation(libs.androidx.media3.session)
implementation(projects.core.rss)
implementation(projects.core.support)
implementation(projects.feature.home)
implementation(projects.feature.settings)
implementation(project(":feature:player"))
Expand Down
31 changes: 31 additions & 0 deletions app/src/main/java/cx/aswin/boxlore/AppContainer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,12 @@ import cx.aswin.boxlore.core.rss.RenumberedShows
import cx.aswin.boxlore.core.rss.RssPodcastRepository
import cx.aswin.boxlore.core.rss.SharedPreferencesRenumberedShows
import cx.aswin.boxlore.core.rss.ports.DownloadCacheRelinker
import cx.aswin.boxlore.core.support.SupportLocalStore
import cx.aswin.boxlore.core.support.SupportPushTopics
import cx.aswin.boxlore.core.support.SupportRepository
import cx.aswin.boxlore.fcm.PodcastNotificationRegistrationWorker
import cx.aswin.boxlore.fcm.SupportPrefsStore
import cx.aswin.boxlore.fcm.SupportPushTopicSync
import cx.aswin.boxlore.share.EpisodeShareRegistrationWorker
import cx.aswin.boxlore.sync.AnalyticsFeedTelemetry
import cx.aswin.boxlore.sync.CloudSyncTriggerCoordinator
Expand Down Expand Up @@ -367,6 +372,30 @@ class AppContainer(

val boxcastPrefs: BoxcastPrefs by lazy { BoxcastPrefs(appContext) }

private val supportLocalStore: SupportLocalStore by lazy { SupportPrefsStore(appContext) }

/**
* Feedback conversations; falls back to the old one-way form while `/support` is unavailable.
* After every successful refresh the reply-push topics are brought in line with the server's.
*/
val supportRepository: SupportRepository by lazy {
SupportRepository(
api = podcastRepository.supportApi,
legacySubmit = { category, message, version, diagnostics, logs ->
podcastRepository.submitFeedback(category, message, version, null, diagnostics, logs)
},
local = supportLocalStore,
).also { repository ->
val topicSync = SupportPushTopicSync(repository, SupportPushTopics(supportLocalStore))
syncScope.launch { repository.refreshed.collect { meta -> topicSync.sync(meta) } }
}
}

/** On each app start: look for replies a push missed, for phones with conversations, at most every 30 minutes. */
fun checkSupportReplies() {
syncScope.launch { runCatching { supportRepository.refreshIfDue(SUPPORT_CHECK_INTERVAL_MS) } }
}

override val deviceIdentityPort: DeviceIdentityPort by lazy {
DeviceIdentityPort { boxcastPrefs.getOrCreateSyncDeviceId() }
}
Expand Down Expand Up @@ -770,3 +799,5 @@ class AppContainer(
)
}
}

private const val SUPPORT_CHECK_INTERVAL_MS = 30 * 60_000L
9 changes: 9 additions & 0 deletions app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,12 @@ class BoxLoreApplication :
cx.aswin.boxlore.core.model.EpisodeShareTargetSource { container.episodeSharePreparer.targetFor(it) }
NetworkModule.authenticator =
FirebaseAuthAuthenticator(container.authRepository)
// Support requests carry the account's cached token, so conversations follow the account.
NetworkModule.accountToken = {
kotlinx.coroutines.runBlocking {
kotlinx.coroutines.withTimeoutOrNull(ACCOUNT_TOKEN_WAIT_MS) { container.authRepository.getIdToken(forceRefresh = false) }
}
}
cx.aswin.boxlore.lifecycle.AutoDownloadLifecycle(this, container, applicationScope).start()
HomeScreenWidgetsInstaller.install(
context = this,
Expand Down Expand Up @@ -514,3 +520,6 @@ internal object CastSessionTransferPolicy {
}

private const val CAST_SESSION_RECHECK_DELAY_MS = 15_000L

/** How long a support request waits for the account's token before going without it. */
private const val ACCOUNT_TOKEN_WAIT_MS = 3_000L
11 changes: 11 additions & 0 deletions app/src/main/java/cx/aswin/boxlore/fcm/BoxLoreFcmService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ class BoxLoreFcmService : FirebaseMessagingService() {
handleNewEpisodeMessage(data, message.priority == RemoteMessage.PRIORITY_HIGH)
return
}
if (type == "support_reply") {
handleSupportReply(data)
return
}

val parsed = FcmPayloadParser.parse(data)
if (applicationContext.shouldSuppressAnnouncement(parsed.toAnnouncement())) return
Expand All @@ -74,6 +78,13 @@ class BoxLoreFcmService : FirebaseMessagingService() {
}
}

/** The boxlore team replied: Home shows it, and a notification opens the conversation. */
private fun handleSupportReply(data: Map<String, String>) {
runCatching { (application as BoxLoreApplication).container.supportRepository.onReplyPushed(data["threadId"]) }
.onFailure { android.util.Log.w("BoxLoreFcmService", "Reply not recorded", it) }
SupportReplyNotifications.show(this, data["threadId"], data["title"], data["body"])
}

private fun handleNewEpisodeMessage(data: Map<String, String>, highPriority: Boolean) {
val podcastId = FcmPayloadParser.podcastId(data) ?: return
val subscriptionRepository = try {
Expand Down
110 changes: 110 additions & 0 deletions app/src/main/java/cx/aswin/boxlore/fcm/SupportReplies.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package cx.aswin.boxlore.fcm

import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import com.google.firebase.messaging.FirebaseMessaging
import cx.aswin.boxlore.core.support.SupportLocalStore
import cx.aswin.boxlore.core.support.SupportMeta
import cx.aswin.boxlore.core.support.SupportPushTopics
import cx.aswin.boxlore.core.support.SupportRepository

/** [SupportLocalStore] in its own preferences file (`boxlore_support`). */
class SupportPrefsStore(context: Context) : SupportLocalStore {
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)

override var hasConversations: Boolean
get() = prefs.getBoolean(KEY_HAS_CONVERSATIONS, false)
set(value) = prefs.edit().putBoolean(KEY_HAS_CONVERSATIONS, value).apply()

override var replyWaiting: Boolean
get() = prefs.getBoolean(KEY_REPLY_WAITING, false)
set(value) = prefs.edit().putBoolean(KEY_REPLY_WAITING, value).apply()

override var subscribedTopics: Set<String>
get() = prefs.getStringSet(KEY_TOPICS, emptySet()).orEmpty().toSet()
set(value) = prefs.edit().putStringSet(KEY_TOPICS, value).apply()

override var lastCheckedAt: Long
get() = prefs.getLong(KEY_LAST_CHECKED, 0L)
set(value) = prefs.edit().putLong(KEY_LAST_CHECKED, value).apply()

override var lastPushAt: Long
get() = prefs.getLong(KEY_LAST_PUSH, 0L)
set(value) = prefs.edit().putLong(KEY_LAST_PUSH, value).apply()

companion object {
const val PREFS_NAME = "boxlore_support"
private const val KEY_HAS_CONVERSATIONS = "has_conversations"
private const val KEY_REPLY_WAITING = "reply_waiting"
private const val KEY_TOPICS = "push_topics"
private const val KEY_LAST_CHECKED = "last_checked_at"
private const val KEY_LAST_PUSH = "last_push_at"
}
}

/** The team's reply as a notification in its own channel; a tap opens that conversation. */
object SupportReplyNotifications {
const val CHANNEL_ID = "boxlore_feedback_replies_v1"
private const val NOTIFICATION_ID_BASE = 7_300
private const val SLOTS = 50
private val THREAD_ID = Regex("[0-9a-f]{24}")

/** Where a tap goes: Feedback, on the conversation when the id is one. */
fun routeFor(threadId: String?): String = if (threadId != null && THREAD_ID.matches(threadId)) "feedback?thread=$threadId" else "feedback"

fun show(context: Context, threadId: String?, title: String?, body: String?) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) {
return
}
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.createNotificationChannel(
NotificationChannel(CHANNEL_ID, "Feedback replies", NotificationManager.IMPORTANCE_DEFAULT).apply {
description = "Replies from the boxlore team to your feedback"
},
)
val slot = Math.floorMod((threadId ?: "").hashCode(), SLOTS)
val intent = NewEpisodeFcmLogic.createNormalizedPushIntent(context, routeFor(threadId), notificationType = "support_reply")
val tap = runCatching {
PendingIntent.getActivity(context, NOTIFICATION_ID_BASE + slot, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
}.getOrNull()
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(cx.aswin.boxlore.R.drawable.ic_notification_custom)
.setColor(android.graphics.Color.parseColor("#5B5BD6"))
.setContentTitle(title?.takeIf { it.isNotBlank() } ?: "New reply to your feedback")
.setContentText(body.orEmpty())
.setStyle(NotificationCompat.BigTextStyle().bigText(body.orEmpty()))
.setAutoCancel(true)
.apply { if (tap != null) setContentIntent(tap) }
.build()
runCatching { NotificationManagerCompat.from(context).notify(NOTIFICATION_ID_BASE + slot, notification) }
.onFailure { Log.w("SupportReplies", "Reply notification not shown", it) }
}
}

/**
* After each successful refresh, joins and leaves reply-push topics to match the server's list
* (none for a phone without conversations), recording only what Firebase confirmed.
*/
class SupportPushTopicSync(private val repository: SupportRepository, private val topics: SupportPushTopics) {
/** [meta] is a successful refresh's; one plan at a time, and topics Firebase didn't confirm are planned again next time. */
@Synchronized
fun sync(meta: SupportMeta) {
val plan = topics.planFor(meta, repository.hasConversations)
if (plan.isEmpty) return
val messaging = runCatching { FirebaseMessaging.getInstance() }.getOrNull() ?: return
plan.subscribe.forEach { topic -> messaging.subscribeToTopic(topic).addOnSuccessListener { topics.joined(topic) } }
plan.unsubscribe.forEach { topic -> messaging.unsubscribeFromTopic(topic).addOnSuccessListener { topics.left(topic) } }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -394,11 +394,17 @@ private fun androidx.navigation.NavGraphBuilder.addFeedbackRoute(w: NavGraphWiri
val navController = w.navController
val container = w.container

composable("feedback") {
// `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 }),
) { entry ->
cx.aswin.boxlore.feature.settings.feedback.FeedbackScreen(
podcastRepository = container.podcastRepository,
supportRepository = container.supportRepository,
boxcastPrefs = container.boxcastPrefs,
onBack = { navController.popBackStack() },
bottomContentPadding = w.session.miniPlayerPadding,
openThreadId = entry.arguments?.getString("thread"),
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ object PushTargetRouteAllowlist {
"explore?",
"briefing",
"library/",
"feedback?",
)

private val appOrWebSchemes = listOf("http://", "https://", "boxlore://", "boxcast://")
Expand All @@ -56,3 +57,6 @@ object PushTargetRouteAllowlist {
return if (isAllowed(trimmed)) trimmed else null
}
}

/** Push targets that replace an open tablet detail instead of opening beneath it: Feedback (a reply notification). */
fun pushClosesDetails(route: String): Boolean = route == "feedback" || route.startsWith("feedback?")
7 changes: 5 additions & 2 deletions app/src/main/java/cx/aswin/boxlore/ui/AdaptivePlayerChrome.kt
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ internal class AdaptivePlayerChromeController(threshold: Float) {
private fun updateCompactness() {
// Lore borrows the compact presentation without changing ordinary browsing state.
if (!playerBusy || !enabled) {
isCompact = enabled && (route == "learn" || scroll.isCompact)
isCompact = enabled && (keepsCompactPlayer(route) || scroll.isCompact)
}
}

Expand All @@ -94,7 +94,7 @@ internal class AdaptivePlayerChromeController(threshold: Float) {
val before = preAvailable.takeIf { preSource == source }
preAvailable = null
preSource = null
if (route == "learn") {
if (keepsCompactPlayer(route)) {
scroll.endGesture()
updateCompactness()
return Offset.Zero
Expand All @@ -118,3 +118,6 @@ internal class AdaptivePlayerChromeController(threshold: Float) {
}
}
}

/** Lore, and Feedback (where the bottom buttons need the room), keep the mini player in its small form. */
internal fun keepsCompactPlayer(route: String?): Boolean = route == "learn" || route?.startsWith("feedback") == true
Loading
Loading