From 0a1104d0df192e7f603472686aede3156d65e9a8 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Sat, 26 Sep 2026 15:55:54 +0530 Subject: [PATCH 01/21] feat(settings): add dedicated feedback screen with sticky drafts and diagnostics --- app/README.md | 1 + .../NavGraphSettingsDestinations.kt | 16 + .../navigation/NavGraphTabDestinations.kt | 1 + .../cx/aswin/boxlore/ui/BoxLoreAppRoot.kt | 41 +- core/prefs/README.md | 2 +- .../aswin/boxlore/core/prefs/BoxcastPrefs.kt | 54 ++ .../boxlore/core/prefs/BoxcastPrefsTest.kt | 20 + .../aswin/boxlore/feature/home/HomeScreen.kt | 3 +- feature/settings/README.md | 11 +- .../feature/settings/SettingsScreen.kt | 2 + .../settings/feedback/DiagnosticCollector.kt | 125 ++++ .../settings/feedback/FeedbackDialogs.kt | 176 +++++ .../settings/feedback/FeedbackScreen.kt | 672 ++++++++++++++++++ .../settings/feedback/FeedbackViewModel.kt | 281 ++++++++ .../settings/feedback/LogcatCollector.kt | 91 +++ .../settings/pages/AboutSettingsPage.kt | 8 + .../feedback/DiagnosticCollectorTest.kt | 58 ++ .../feedback/FeedbackViewModelTest.kt | 212 ++++++ .../settings/feedback/LogcatCollectorTest.kt | 72 ++ 19 files changed, 1802 insertions(+), 44 deletions(-) create mode 100644 feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/DiagnosticCollector.kt create mode 100644 feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackDialogs.kt create mode 100644 feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackScreen.kt create mode 100644 feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackViewModel.kt create mode 100644 feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/LogcatCollector.kt create mode 100644 feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/DiagnosticCollectorTest.kt create mode 100644 feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackViewModelTest.kt create mode 100644 feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/LogcatCollectorTest.kt diff --git a/app/README.md b/app/README.md index 7b14cf9d7..f8ede93eb 100644 --- a/app/README.md +++ b/app/README.md @@ -26,6 +26,7 @@ The application module owns the Android app shell: `BoxLoreApplication`, `MainAc - NPS survey UI (`surveys/`) applies `NpsSurveyBranching` after each answer so detractor / passive / promoter open-text paths end instead of falling through into the next score band (PostHog open-question `end` branching is not reliably persisted via API). - `BoxLoreNavHost` owns app route registration and delegates screen bodies to feature modules; stack-slide transitions follow the visible Home → Explore → Library → Lore order, and Home’s first committed content frame unlocks the floating Lore launch animation. - `NavGraphPodcastEpisodeDestinations` registers episode and podcast detail routes; episode full-path routes define `episodeDescription` as nullable with a default null value to safely handle empty, omitted, or special-character descriptions without argument bundle verification failures. +- `NavGraphSettingsDestinations` registers the settings hub, category pages, download settings, and the dedicated `"feedback"` full-page destination wired to `:feature:settings:feedback:FeedbackScreen`. - Cold-start destination precedence (see `StartDestinationResolver`): incomplete onboarding → offline Downloads (no deep link) → Appearance **Open app to** Subscriptions or Downloads (no deep link) → Home. Deep links and push `target_route` still win over Open app to. When cold start opens Subscriptions or Downloads via that pref, `openedToLandingOnLaunch` makes Back navigate to Home (not Library hub); Library-tile entry still pops to Library. Offline-forced Downloads without that pref still exits on Back. That Home navigation is owned by `PredictiveBackWrapper` (NavHost has nothing to pop), so peek scale must restore after the gesture — see `:core:designsystem`. - Process latest-episode sync (`SubscriptionForegroundSync`) and the versioned legacy RSS repair are started from `BoxLoreAppRoot` after onboarding. Repair is enabled only for upgraded installs running `0.0.18` through `0.0.30`, except `0.0.20`, starts only while online with at least one eligible pure `rss:` subscription, and admits at most one pass per app process. A fresh install on an allowed version writes the completed marker without inspecting or changing catalog rows, so updating a fresh `0.0.18` install to `0.0.19` cannot enable repair later. Unsupported versions and invalid package metadata fail closed. App foreground/screen-on events do not request another pass. While an eligible pass is active, the shell shows a persistent top popup with the boxlore loader; it cannot be dismissed and disappears when the pass settles. Returning to the app still requests normal subscription refresh (`SubscriptionResumeRefreshLogic` skips the first `ON_START` so Home keeps its 2s first-paint delay). Subscriptions-first launches still fetch because Library calls `requestRefresh` as soon as that screen is visible. Identified FCM payloads (guid/enclosure) that do not match a local episode no longer fall back to an unrelated newest tip. - Tab destinations in `NavGraphTabDestinations` wire Home / Explore / Learn / Library / player entry points to feature screens. Home’s Offline Mix “View all downloads” action navigates directly to Library Downloads through this shell-owned wiring. Lore history deep-links use `entryPoint=learn` (canonical glossary; legacy `learn_history` still normalizes to `learn`). Explore and Subscriptions apply Appearance **Default tabs** when the route does not already pick a tab (genre Explore, `tab=trending` / `for_you`, and `library/subscriptions?tab=0|1` still win). 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 1f0aac59b..c0ebc88b9 100644 --- a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt +++ b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt @@ -29,6 +29,7 @@ import kotlinx.coroutines.launch internal fun androidx.navigation.NavGraphBuilder.addSettingsDestination(w: NavGraphWiring) { addMainSettingsRoute(w) addDownloadSettingsRoutes(w) + addFeedbackRoute(w) } private fun androidx.navigation.NavGraphBuilder.addMainSettingsRoute(w: NavGraphWiring) { @@ -98,6 +99,7 @@ private fun androidx.navigation.NavGraphBuilder.addMainSettingsRoute(w: NavGraph appInstanceId = appInstanceId, initialPage = settingsPage, isOnboarding = isFromOnboarding, + onSendFeedback = { navController.navigate("feedback") }, ), regionSettings = RegionSettings( currentRegion = settingsState.currentRegion, @@ -303,6 +305,20 @@ private fun androidx.navigation.NavGraphBuilder.addDownloadSettingsRoutes(w: Nav } } +private fun androidx.navigation.NavGraphBuilder.addFeedbackRoute(w: NavGraphWiring) { + val navController = w.navController + val container = w.container + val application = w.application + + composable("feedback") { + cx.aswin.boxlore.feature.settings.feedback.FeedbackScreen( + podcastRepository = container.podcastRepository, + boxcastPrefs = cx.aswin.boxlore.core.prefs.BoxcastPrefs(application), + onBack = { navController.popBackStack() }, + ) + } +} + private suspend fun runLibraryExport( application: android.app.Application, uri: android.net.Uri, diff --git a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphTabDestinations.kt b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphTabDestinations.kt index dac61c3c1..afe6842f9 100644 --- a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphTabDestinations.kt +++ b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphTabDestinations.kt @@ -117,6 +117,7 @@ internal fun androidx.navigation.NavGraphBuilder.addHomeDestination(w: NavGraphW onNavigateToSettings = { navController.navigate("settings?page=hub") }, onNavigateToPlayStoreReview = { launchInAppReview(context) }, onSubmitFeedback = onSubmitFeedback, + onFeedbackClick = { w.actions.onShowFeedbackSheet() }, onNavigateToDebug = { navController.navigate("debug") }, onImportClick = { opmlCallbacks.onSourceChange("home_import_banner") diff --git a/app/src/main/java/cx/aswin/boxlore/ui/BoxLoreAppRoot.kt b/app/src/main/java/cx/aswin/boxlore/ui/BoxLoreAppRoot.kt index fcbcc38e3..81f9bd771 100644 --- a/app/src/main/java/cx/aswin/boxlore/ui/BoxLoreAppRoot.kt +++ b/app/src/main/java/cx/aswin/boxlore/ui/BoxLoreAppRoot.kt @@ -66,7 +66,6 @@ import cx.aswin.boxlore.core.playback.stopAndClearQueue import cx.aswin.boxlore.core.prefs.PrefsFileMigrator import cx.aswin.boxlore.fcm.FcmTopicHelper import cx.aswin.boxlore.feature.home.ModeSwitchState -import cx.aswin.boxlore.feature.home.components.FeedbackSheet import cx.aswin.boxlore.feature.onboarding.generateRecommendationsFromOpml import cx.aswin.boxlore.feature.onboarding.markOnboardingCompletedSilent import cx.aswin.boxlore.feature.player.v2.PlayerSheetActions @@ -183,7 +182,6 @@ fun BoxLoreAppRoot( val smartDownloadManager = container.smartDownloadManager val installReferrerManager = container.installReferrerManager - var showFeedbackSheet by remember { mutableStateOf(false) } val onSubmitFeedback: suspend (String, String, String, String) -> Boolean = remember(podcastRepository) { { category, message, version, email -> @@ -631,7 +629,7 @@ fun BoxLoreAppRoot( NavHostActions( onLoreQueueConflictEpisode = { loreQueueConflictEpisode = it }, queueLoreEpisode = queueLoreEpisode, - onShowFeedbackSheet = { showFeedbackSheet = true }, + onShowFeedbackSheet = { navController.navigate("feedback") }, onSubmitFeedback = onSubmitFeedback, ), settingsState = @@ -910,43 +908,6 @@ fun BoxLoreAppRoot( }, ), ) - - if (showFeedbackSheet) { - val versionStr = - remember { - try { - activity.packageManager.getPackageInfo(activity.packageName, 0).versionName - ?: "unknown" - } catch (_: Exception) { - "unknown" - } - } - FeedbackSheet( - appVersion = versionStr, - onSubmit = onSubmitFeedback, - onRateInstead = { - showFeedbackSheet = false - try { - activity.startActivity( - android.content.Intent( - android.content.Intent.ACTION_VIEW, - android.net.Uri.parse("market://details?id=${activity.packageName}"), - ), - ) - } catch (_: Exception) { - activity.startActivity( - android.content.Intent( - android.content.Intent.ACTION_VIEW, - android.net.Uri.parse( - "https://play.google.com/store/apps/details?id=${activity.packageName}", - ), - ), - ) - } - }, - onDismissRequest = { showFeedbackSheet = false }, - ) - } } } } diff --git a/core/prefs/README.md b/core/prefs/README.md index c369a017a..5d5027ce3 100644 --- a/core/prefs/README.md +++ b/core/prefs/README.md @@ -16,7 +16,7 @@ Owns user preference persistence and migration helpers: DataStore-backed user pr - `SubscriptionsTabStyle` defines and sanitizes Subscriptions tab layout choices: `top` (default) vs `floating` (FAB style). - `BoxcastPrefs` stores the permanent Home video-showcase dismissal in the canonical `boxlore_prefs` file (`featured_video_showcase_dismissed`). The showcase asks for confirmation before writing it and does not reappear afterward. - `Context.userPreferencesDataStore` defines the `user_preferences` DataStore delegate. -- `BoxcastPrefs` is the typed facade for `boxlore_prefs` values such as onboarding, genres, recommendation caches, time-of-day rotation slot keys (`cached_byl_slot`), Learn history, learner-log gates, pending magic link authentication email (`pending_auth_email`), the stable sync installation ID (`sync_device_id` via `getOrCreateSyncDeviceId()`), sync cursor state (`sync_last_timestamp`, `sync_last_user_id`, `sync_metadata_version`), and the notification-permission prompt gate (`has_requested_notification_permission`). `clearBylCacheIfPodcastId` invalidates a Because-you-like cache (including its cached slot key) when its seed show adopts a new catalog id. +- `BoxcastPrefs` is the typed facade for `boxlore_prefs` values such as onboarding, genres, recommendation caches, time-of-day rotation slot keys (`cached_byl_slot`), Learn history, learner-log gates, pending magic link authentication email (`pending_auth_email`), sticky feedback drafts (`FeedbackDraft` via `getFeedbackDraft()`, `saveFeedbackDraft()`, and `clearFeedbackDraft()`), the stable sync installation ID (`sync_device_id` via `getOrCreateSyncDeviceId()`), sync cursor state (`sync_last_timestamp`, `sync_last_user_id`, `sync_metadata_version`), and the notification-permission prompt gate (`has_requested_notification_permission`). `clearBylCacheIfPodcastId` invalidates a Because-you-like cache (including its cached slot key) when its seed show adopts a new catalog id. - `resolveLearnerLogEnabled(isDebugBuild)`: debug defaults on when unset; **release is always off** unless the user explicitly persisted `true` via the debug-screen toggle. - `UserPreferenceKeys` centralizes DataStore preference keys. - `PrefsFileMigrator` opens canonical SharedPreferences files and migrates from legacy file names. diff --git a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/BoxcastPrefs.kt b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/BoxcastPrefs.kt index d45a3e9a6..097f57de8 100644 --- a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/BoxcastPrefs.kt +++ b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/BoxcastPrefs.kt @@ -177,6 +177,11 @@ class BoxcastPrefs(context: Context) { const val KEY_LAST_SYNCED_USER_ID = "sync_last_user_id" const val KEY_SYNC_METADATA_VERSION = "sync_metadata_version" const val KEY_HAS_REQUESTED_NOTIFICATION_PERMISSION = "has_requested_notification_permission" + const val KEY_FEEDBACK_DRAFT_CATEGORY = "feedback_draft_category" + const val KEY_FEEDBACK_DRAFT_MESSAGE = "feedback_draft_message" + const val KEY_FEEDBACK_DRAFT_EMAIL = "feedback_draft_email" + const val KEY_FEEDBACK_DRAFT_STEPS = "feedback_draft_steps" + const val KEY_FEEDBACK_DRAFT_ATTACH_DIAGNOSTICS = "feedback_draft_attach_diagnostics" } // ── Auth / Magic Link ─────────────────────────────────────────────────── @@ -236,4 +241,53 @@ class BoxcastPrefs(context: Context) { fun setHasRequestedNotificationPermission(requested: Boolean = true) { prefs.edit().putBoolean(KEY_HAS_REQUESTED_NOTIFICATION_PERMISSION, requested).apply() } + + // ── Feedback Draft ──────────────────────────────────────────────────────── + + fun getFeedbackDraft(): FeedbackDraft? { + val message = prefs.getString(KEY_FEEDBACK_DRAFT_MESSAGE, null) ?: return null + if (message.isBlank()) return null + val category = prefs.getString(KEY_FEEDBACK_DRAFT_CATEGORY, "feature") ?: "feature" + val email = prefs.getString(KEY_FEEDBACK_DRAFT_EMAIL, "") ?: "" + val steps = prefs.getString(KEY_FEEDBACK_DRAFT_STEPS, "") ?: "" + val attachDiagnostics = prefs.getBoolean(KEY_FEEDBACK_DRAFT_ATTACH_DIAGNOSTICS, true) + return FeedbackDraft( + category = category, + message = message, + email = email, + stepsToReproduce = steps, + attachDiagnostics = attachDiagnostics, + ) + } + + fun saveFeedbackDraft(draft: FeedbackDraft) { + prefs.edit() + .putString(KEY_FEEDBACK_DRAFT_CATEGORY, draft.category) + .putString(KEY_FEEDBACK_DRAFT_MESSAGE, draft.message) + .putString(KEY_FEEDBACK_DRAFT_EMAIL, draft.email) + .putString(KEY_FEEDBACK_DRAFT_STEPS, draft.stepsToReproduce) + .putBoolean(KEY_FEEDBACK_DRAFT_ATTACH_DIAGNOSTICS, draft.attachDiagnostics) + .apply() + } + + fun clearFeedbackDraft() { + prefs.edit() + .remove(KEY_FEEDBACK_DRAFT_CATEGORY) + .remove(KEY_FEEDBACK_DRAFT_MESSAGE) + .remove(KEY_FEEDBACK_DRAFT_EMAIL) + .remove(KEY_FEEDBACK_DRAFT_STEPS) + .remove(KEY_FEEDBACK_DRAFT_ATTACH_DIAGNOSTICS) + .apply() + } } + +/** + * Persisted draft for the user feedback screen to prevent data loss on accidental dismissal. + */ +data class FeedbackDraft( + val category: String, + val message: String, + val email: String = "", + val stepsToReproduce: String = "", + val attachDiagnostics: Boolean = true, +) diff --git a/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/BoxcastPrefsTest.kt b/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/BoxcastPrefsTest.kt index 11f8ef1b8..d8d710a40 100644 --- a/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/BoxcastPrefsTest.kt +++ b/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/BoxcastPrefsTest.kt @@ -172,4 +172,24 @@ class BoxcastPrefsTest { prefs.setHasRequestedNotificationPermission(false) assertFalse(prefs.hasRequestedNotificationPermission()) } + + @Test + fun feedbackDraft_defaultsToNullAndRoundTripsAndClears() { + assertNull(prefs.getFeedbackDraft()) + + val draft = FeedbackDraft( + category = "bug", + message = "Playback stops after 5 minutes", + email = "listener@example.com", + stepsToReproduce = "1. Play episode\n2. Wait 5 min", + attachDiagnostics = true, + ) + prefs.saveFeedbackDraft(draft) + + val retrieved = prefs.getFeedbackDraft() + assertEquals(draft, retrieved) + + prefs.clearFeedbackDraft() + assertNull(prefs.getFeedbackDraft()) + } } diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeScreen.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeScreen.kt index 8b01f5f9d..1859a0d43 100644 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeScreen.kt +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeScreen.kt @@ -190,6 +190,7 @@ fun HomeRoute( onNavigateToSettings: (() -> Unit)? = null, onNavigateToPlayStoreReview: () -> Unit = {}, onSubmitFeedback: suspend (String, String, String, String) -> Boolean = { _, _, _, _ -> false }, + onFeedbackClick: (() -> Unit)? = null, onNavigateToDebug: () -> Unit = {}, onImportClick: () -> Unit = {}, onAiOnboardingClick: () -> Unit = {}, @@ -342,7 +343,7 @@ fun HomeRoute( onBriefingClick = onBriefingClick, onDismissBriefing = viewModel::dismissBriefingForToday, onDismissBriefingForever = viewModel::dismissBriefingForever, - onFeedbackClick = viewModel::triggerFeedback, + onFeedbackClick = onFeedbackClick ?: viewModel::triggerFeedback, onToggleHomePin = viewModel::toggleHomePin, onDismissFeaturedVideoShowcaseForever = viewModel::dismissFeaturedVideoShowcaseForever, ), diff --git a/feature/settings/README.md b/feature/settings/README.md index 369fa1aec..c9bd8b7a9 100644 --- a/feature/settings/README.md +++ b/feature/settings/README.md @@ -7,10 +7,11 @@ Owns the unified Settings hub, category pages (Account, Appearance, Playback, Pr ## Public API - `SettingsScreen`, `SettingsViewModel`, `SettingsViewModelAssembler`, and `ProfileSettingsDestination` for the settings hub and sub-pages. -- `SettingsScreenConfig` includes `isOnboarding: Boolean` (used by app navigation to keep onboarding mode active and bypass the hub on return) and `resolveSettingsBackAction` for deterministic back-handler actions. +- `SettingsScreenConfig` includes `isOnboarding: Boolean` (used by app navigation to keep onboarding mode active and bypass the hub on return), `onSendFeedback: (() -> Unit)?` to route to feedback, and `resolveSettingsBackAction` for deterministic back-handler actions. +- `FeedbackScreen`, `FeedbackViewModel`, `DiagnosticCollector`, and `LogcatCollector` under `feedback/` for sticky draft feedback submissions, in-process sanitized logcat extraction, device diagnostics, and GitHub issue reporting. - `AutoDownloadSettingsScreen` and `SmartDownloadsSettingsScreen` under `downloads/` for download management. - `AccountSettingsPage`, `AppearanceSettingsPage`, `PlaybackSettingsPage`, `PrivacySettingsPage`, `LibrarySettingsPage`, `DownloadsSettingsPage`, `AboutSettingsPage`. -- Dialogs: `AccentColorPickerDialog`, `AddRssFeedDialog`, `ResetAnalyticsDialog`. +- Dialogs: `AccentColorPickerDialog`, `AddRssFeedDialog`, `ResetAnalyticsDialog`, `LogsPreviewDialog`. ## Internal structure @@ -30,6 +31,12 @@ src/main/java/cx/aswin/boxlore/feature/settings/ downloads/ AutoDownloadSettingsScreen.kt SmartDownloadsSettingsScreen.kt + feedback/ + DiagnosticCollector.kt + FeedbackDialogs.kt + FeedbackScreen.kt + FeedbackViewModel.kt + LogcatCollector.kt pages/ AboutSettingsPage.kt AccountAuthHelpers.kt diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsScreen.kt index 5e2a232b2..29bc5b71b 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsScreen.kt @@ -98,6 +98,7 @@ data class SettingsScreenConfig( /** Optional deep-link page: "library", "appearance", etc. */ val initialPage: String? = null, val isOnboarding: Boolean = false, + val onSendFeedback: (() -> Unit)? = null, ) /** Appearance sub-page state paired with its actions, so [SettingsScreen] can pass both as one. */ @@ -411,6 +412,7 @@ private fun SettingsAnimatedPages( appInfo = uiData.appInfo, onVisitPodcastIndex = { visitPodcastIndexHomepage(contentBundle.context) }, onOpenChangelog = { openChangelog(contentBundle.context) }, + onSendFeedback = { config.onSendFeedback?.invoke() }, onBack = actions.onReturnToHub, ) } diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/DiagnosticCollector.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/DiagnosticCollector.kt new file mode 100644 index 000000000..d526676af --- /dev/null +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/DiagnosticCollector.kt @@ -0,0 +1,125 @@ +package cx.aswin.boxlore.feature.settings.feedback + +import android.content.Context +import android.media.AudioDeviceInfo +import android.media.AudioManager +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.Build +import androidx.core.content.pm.PackageInfoCompat +import java.util.Locale + +/** + * Diagnostic information about the device and runtime environment. + */ +data class DiagnosticInfo( + val appVersion: String, + val buildCode: Long, + val packageName: String, + val androidRelease: String, + val sdkInt: Int, + val manufacturer: String, + val model: String, + val networkType: String, + val audioRoute: String, + val locale: String, +) { + /** + * Compact summary designed to fit comfortably inside the in-app feedback body (~120 chars). + */ + fun toCondensedSummary(): String = "App: v$appVersion ($buildCode) | OS: Android $androidRelease (API $sdkInt) | Device: $manufacturer $model | Net: $networkType | Audio: $audioRoute" + + /** + * Full GitHub / Sharesheet Markdown report. + */ + fun toMarkdownReport(sanitizedLogcat: String? = null): String = buildString { + append("### System Diagnostics\n") + append("- **App**: boxlore `$appVersion` (build `$buildCode`)\n") + append("- **Package**: `$packageName`\n") + append("- **Android**: `$androidRelease` (API `$sdkInt`)\n") + append("- **Device**: `$manufacturer $model`\n") + append("- **Network**: `$networkType`\n") + append("- **Audio Route**: `$audioRoute`\n") + append("- **Locale**: `$locale`\n\n") + + if (!sanitizedLogcat.isNullOrBlank()) { + append("
\nSanitized App Logs\n\n") + append("```\n") + append(sanitizedLogcat.trim()) + append("\n```\n
\n") + } + } +} + +object DiagnosticCollector { + + /** + * Extracts non-sensitive diagnostic parameters from the Android environment. + */ + fun collect(context: Context): DiagnosticInfo { + val pm = context.packageManager + val pkgName = context.packageName + val packageInfo = try { + pm.getPackageInfo(pkgName, 0) + } catch (_: Exception) { + null + } + + val versionName = packageInfo?.versionName ?: "unknown" + val versionCode = packageInfo?.let { PackageInfoCompat.getLongVersionCode(it) } ?: 0L + + val networkType = determineNetworkType(context) + val audioRoute = determineAudioRoute(context) + val locale = Locale.getDefault().toLanguageTag() + + return DiagnosticInfo( + appVersion = versionName, + buildCode = versionCode, + packageName = pkgName, + androidRelease = Build.VERSION.RELEASE ?: "unknown", + sdkInt = Build.VERSION.SDK_INT, + manufacturer = Build.MANUFACTURER.replaceFirstChar { it.uppercase() }, + model = Build.MODEL, + networkType = networkType, + audioRoute = audioRoute, + locale = locale, + ) + } + + private fun determineNetworkType(context: Context): String { + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + ?: return "Unknown" + val network = cm.activeNetwork ?: return "Offline" + val caps = cm.getNetworkCapabilities(network) ?: return "Offline" + + return when { + caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "Wi-Fi" + caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "Cellular" + caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "Ethernet" + caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> "VPN" + else -> "Connected" + } + } + + private fun determineAudioRoute(context: Context): String { + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager + ?: return "Default" + + val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS) + val activeDeviceTypes = devices.map { it.type } + + return when { + activeDeviceTypes.contains(AudioDeviceInfo.TYPE_BLUETOOTH_A2DP) || + activeDeviceTypes.contains(AudioDeviceInfo.TYPE_BLUETOOTH_SCO) || + activeDeviceTypes.contains(AudioDeviceInfo.TYPE_BLE_HEADSET) || + activeDeviceTypes.contains(AudioDeviceInfo.TYPE_BLE_SPEAKER) -> "Bluetooth" + + activeDeviceTypes.contains(AudioDeviceInfo.TYPE_WIRED_HEADPHONES) || + activeDeviceTypes.contains(AudioDeviceInfo.TYPE_WIRED_HEADSET) || + activeDeviceTypes.contains(AudioDeviceInfo.TYPE_USB_HEADSET) -> "Headphones" + + activeDeviceTypes.contains(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER) -> "Speaker" + else -> "Default" + } + } +} diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackDialogs.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackDialogs.kt new file mode 100644 index 000000000..1ae668a58 --- /dev/null +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackDialogs.kt @@ -0,0 +1,176 @@ +package cx.aswin.boxlore.feature.settings.feedback + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.widget.Toast +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Notes +import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material.icons.rounded.Share +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight + +@Composable +fun LogsPreviewDialog( + logs: String?, + isLoading: Boolean, + diagnosticInfo: DiagnosticInfo?, + onShare: () -> Unit, + onDismissRequest: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismissRequest, + icon = { + Icon( + imageVector = Icons.AutoMirrored.Rounded.Notes, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + title = { + Text( + text = "Diagnostics & Logs Preview", + style = MaterialTheme.typography.titleLarge, + fontWeight = GoogleSansWeight.semiBold, + ) + }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "Tokens, keys, and private credentials are automatically scrubbed.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + LogsTextSurface(logs = logs, isLoading = isLoading) + } + }, + confirmButton = { + LogsActionButtons( + logs = logs, + diagnosticInfo = diagnosticInfo, + onShare = onShare, + ) + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text("Close") + } + }, + shape = MaterialTheme.shapes.extraLarge, + containerColor = MaterialTheme.colorScheme.surface, + ) +} + +@Composable +private fun LogsTextSurface( + logs: String?, + isLoading: Boolean, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 160.dp, max = 320.dp), + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + if (isLoading) { + Box( + modifier = Modifier.fillMaxWidth().height(160.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(modifier = Modifier.size(32.dp)) + } + } else { + val verticalScroll = rememberScrollState() + val horizontalScroll = rememberScrollState() + Box( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp) + .verticalScroll(verticalScroll) + .horizontalScroll(horizontalScroll), + ) { + Text( + text = logs ?: "No logs captured.", + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + lineHeight = 15.sp, + ), + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } +} + +@Composable +private fun LogsActionButtons( + logs: String?, + diagnosticInfo: DiagnosticInfo?, + onShare: () -> Unit, +) { + val context = LocalContext.current + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton( + onClick = { + val report = diagnosticInfo?.toMarkdownReport(logs) ?: (logs ?: "") + val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + cm.setPrimaryClip(ClipData.newPlainText("boxlore diagnostics", report)) + Toast.makeText(context, "Copied diagnostics & logs to clipboard", Toast.LENGTH_SHORT).show() + }, + shape = MaterialTheme.shapes.medium, + ) { + Icon(Icons.Rounded.ContentCopy, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(4.dp)) + Text("Copy") + } + + FilledTonalButton( + onClick = onShare, + shape = MaterialTheme.shapes.medium, + ) { + Icon(Icons.Rounded.Share, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(4.dp)) + Text("Share") + } + } +} 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 new file mode 100644 index 000000000..ab736c736 --- /dev/null +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackScreen.kt @@ -0,0 +1,672 @@ +package cx.aswin.boxlore.feature.settings.feedback + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.Launch +import androidx.compose.material.icons.automirrored.rounded.Message +import androidx.compose.material.icons.rounded.BugReport +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.DeleteOutline +import androidx.compose.material.icons.rounded.Headphones +import androidx.compose.material.icons.rounded.Lightbulb +import androidx.compose.material.icons.rounded.Restore +import androidx.compose.material.icons.rounded.Star +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import cx.aswin.boxlore.core.catalog.PodcastRepository +import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight +import cx.aswin.boxlore.core.prefs.BoxcastPrefs + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FeedbackScreen( + podcastRepository: PodcastRepository, + boxcastPrefs: BoxcastPrefs, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val feedbackViewModel: FeedbackViewModel = viewModel( + factory = FeedbackViewModel.Factory(podcastRepository, boxcastPrefs, context), + ) + val uiState by feedbackViewModel.uiState.collectAsStateWithLifecycle() + + BackHandler(onBack = onBack) + + Scaffold( + modifier = modifier.imePadding(), + topBar = { + TopAppBar( + title = { + Text( + text = "Share your thoughts", + style = MaterialTheme.typography.titleLarge, + fontWeight = GoogleSansWeight.semiBold, + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = "Navigate back", + ) + } + }, + actions = { + if (uiState.message.isNotBlank() || uiState.stepsToReproduce.isNotBlank()) { + IconButton(onClick = { feedbackViewModel.onDiscardDraft() }) { + Icon( + imageVector = Icons.Rounded.DeleteOutline, + contentDescription = "Discard draft", + ) + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + }, + containerColor = MaterialTheme.colorScheme.surface, + ) { paddingValues -> + if (uiState.isSuccess) { + FeedbackSuccessView( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(24.dp), + onDone = onBack, + ) + } else { + FeedbackFormView( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 12.dp), + uiState = uiState, + viewModel = feedbackViewModel, + context = context, + ) + } + + if (uiState.logsPreview != null || uiState.isLoadingLogs) { + LogsPreviewDialog( + logs = uiState.logsPreview, + isLoading = uiState.isLoadingLogs, + diagnosticInfo = uiState.diagnosticInfo, + onShare = { feedbackViewModel.shareDiagnosticsReport(context) }, + onDismissRequest = { feedbackViewModel.clearLogsPreview() }, + ) + } + } +} + +@Composable +private fun FeedbackSuccessView( + modifier: Modifier = Modifier, + onDone: () -> Unit, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.size(72.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Rounded.CheckCircle, + contentDescription = null, + modifier = Modifier.size(40.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + Text( + text = "Sent — thank you!", + style = MaterialTheme.typography.headlineSmall, + fontWeight = GoogleSansWeight.semiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Your feedback helps shape boxlore. We read every submission.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(32.dp)) + + Button( + onClick = onDone, + modifier = Modifier.fillMaxWidth(0.6f), + shape = MaterialTheme.shapes.large, + ) { + Text("Done") + } + } +} + +@Composable +private fun FeedbackFormView( + modifier: Modifier = Modifier, + uiState: FeedbackUiState, + viewModel: FeedbackViewModel, + context: Context, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + FeedbackDraftBanner( + visible = uiState.isDraftRestored, + onDiscard = { viewModel.onDiscardDraft() }, + onDismiss = { viewModel.onDismissDraftBanner() }, + ) + + FeedbackCategorySelector( + selectedCategory = uiState.category, + onSelectCategory = { viewModel.onCategorySelected(it) }, + ) + + FeedbackMessageInput( + category = uiState.category, + message = uiState.message, + onMessageChanged = { viewModel.onMessageChanged(it) }, + ) + + if (uiState.category == FeedbackCategory.BUG || uiState.category == FeedbackCategory.AUDIO) { + FeedbackStepsInput( + steps = uiState.stepsToReproduce, + onStepsChanged = { viewModel.onStepsChanged(it) }, + ) + } + + FeedbackEmailInput( + email = uiState.email, + onEmailChanged = { viewModel.onEmailChanged(it) }, + ) + + FeedbackDiagnosticsSection( + attachDiagnostics = uiState.attachDiagnostics, + diagnosticInfo = uiState.diagnosticInfo, + onToggleAttach = { viewModel.onAttachDiagnosticsChanged(it) }, + onPreviewLogs = { viewModel.loadLogsForPreview() }, + ) + + FeedbackCommunityRow( + gitHubUrl = viewModel.buildGitHubIssueUrl(), + context = context, + ) + + FeedbackSubmitButton( + isSubmitting = uiState.isSubmitting, + message = uiState.message, + errorMessage = uiState.errorMessage, + onSubmit = { viewModel.onSubmit() }, + ) + + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Composable +private fun FeedbackDraftBanner( + visible: Boolean, + onDiscard: () -> Unit, + onDismiss: () -> Unit, +) { + AnimatedVisibility( + visible = visible, + enter = fadeIn(), + exit = fadeOut(), + ) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + ), + shape = MaterialTheme.shapes.medium, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.Restore, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Restored draft from last session", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onDiscard) { + Text( + text = "Discard", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + IconButton(onClick = onDismiss, modifier = Modifier.size(24.dp)) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = "Dismiss banner", + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(16.dp), + ) + } + } + } + } +} + +@Composable +private fun FeedbackCategorySelector( + selectedCategory: FeedbackCategory, + onSelectCategory: (FeedbackCategory) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Category", + style = MaterialTheme.typography.labelLarge, + fontWeight = GoogleSansWeight.medium, + color = MaterialTheme.colorScheme.onSurface, + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FeedbackCategory.entries.forEach { cat -> + val selected = selectedCategory == cat + FilterChip( + selected = selected, + onClick = { onSelectCategory(cat) }, + leadingIcon = { + val icon = when (cat) { + FeedbackCategory.FEATURE -> Icons.Rounded.Lightbulb + FeedbackCategory.BUG -> Icons.Rounded.BugReport + FeedbackCategory.AUDIO -> Icons.Rounded.Headphones + FeedbackCategory.OTHER -> Icons.AutoMirrored.Rounded.Message + } + Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(16.dp)) + }, + label = { Text(cat.label) }, + shape = MaterialTheme.shapes.medium, + colors = FilterChipDefaults.filterChipColors( + containerColor = MaterialTheme.colorScheme.surface, + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) + } + } + } +} + +@Composable +private fun FeedbackMessageInput( + category: FeedbackCategory, + message: String, + onMessageChanged: (String) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + OutlinedTextField( + value = message, + onValueChange = onMessageChanged, + modifier = Modifier + .fillMaxWidth() + .height(150.dp), + placeholder = { + Text( + text = category.placeholder, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + }, + shape = MaterialTheme.shapes.large, + textStyle = MaterialTheme.typography.bodyMedium, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant, + ), + ) + + Text( + text = "${message.length}/2000", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.End, + ) + } +} + +@Composable +private fun FeedbackStepsInput( + steps: String, + onStepsChanged: (String) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = "Steps to Reproduce (Optional)", + style = MaterialTheme.typography.labelLarge, + fontWeight = GoogleSansWeight.medium, + color = MaterialTheme.colorScheme.onSurface, + ) + + OutlinedTextField( + value = steps, + onValueChange = onStepsChanged, + modifier = Modifier + .fillMaxWidth() + .height(100.dp), + placeholder = { + Text( + text = "1. Play episode X\n2. At 02:15 audio stops\n3. Happens on both Wi-Fi and Cellular", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + }, + shape = MaterialTheme.shapes.large, + textStyle = MaterialTheme.typography.bodyMedium, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant, + ), + ) + } +} + +@Composable +private fun FeedbackEmailInput( + email: String, + onEmailChanged: (String) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = "Contact Email (Optional)", + style = MaterialTheme.typography.labelLarge, + fontWeight = GoogleSansWeight.medium, + color = MaterialTheme.colorScheme.onSurface, + ) + + OutlinedTextField( + value = email, + onValueChange = onEmailChanged, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text( + text = "you@example.com", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + }, + supportingText = { + Text( + text = "Only used if we need more context or to let you know when it's resolved.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + }, + singleLine = true, + shape = MaterialTheme.shapes.large, + textStyle = MaterialTheme.typography.bodyMedium, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant, + ), + ) + } +} + +@Composable +private fun FeedbackDiagnosticsSection( + attachDiagnostics: Boolean, + diagnosticInfo: DiagnosticInfo?, + onToggleAttach: (Boolean) -> Unit, + onPreviewLogs: () -> Unit, +) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + shape = MaterialTheme.shapes.large, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Include device diagnostics", + style = MaterialTheme.typography.bodyLarge, + fontWeight = GoogleSansWeight.medium, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = "Helps troubleshoot hardware, OS, and network issues", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = attachDiagnostics, + onCheckedChange = onToggleAttach, + ) + } + + if (attachDiagnostics && diagnosticInfo != null) { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerHighest, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = "boxlore v${diagnosticInfo.appVersion} (${diagnosticInfo.buildCode}) • Android ${diagnosticInfo.androidRelease} (API ${diagnosticInfo.sdkInt})", + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "${diagnosticInfo.manufacturer} ${diagnosticInfo.model} • ${diagnosticInfo.networkType} • ${diagnosticInfo.audioRoute}", + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + OutlinedButton( + onClick = onPreviewLogs, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + ) { + Text("Preview & Export Logs") + } + } + } + } +} + +@Composable +private fun FeedbackCommunityRow( + gitHubUrl: String, + context: Context, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton( + onClick = { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(gitHubUrl)).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + }, + modifier = Modifier.weight(1f), + shape = MaterialTheme.shapes.large, + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.Launch, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text("GitHub Issue", maxLines = 1) + } + + OutlinedButton( + onClick = { + val pkgName = context.packageName + val uri = try { + Uri.parse("market://details?id=$pkgName") + } catch (_: Exception) { + Uri.parse("https://play.google.com/store/apps/details?id=$pkgName") + } + val intent = Intent(Intent.ACTION_VIEW, uri).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + }, + modifier = Modifier.weight(1f), + shape = MaterialTheme.shapes.large, + ) { + Icon( + imageVector = Icons.Rounded.Star, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text("Rate on Play", maxLines = 1) + } + } +} + +@Composable +private fun FeedbackSubmitButton( + isSubmitting: Boolean, + message: String, + errorMessage: String?, + onSubmit: () -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (errorMessage != null) { + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + ) + } + + Button( + onClick = onSubmit, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + enabled = !isSubmitting && message.isNotBlank(), + shape = MaterialTheme.shapes.large, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + ) { + if (isSubmitting) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp, + ) + } else { + Text( + text = "Send Feedback", + style = MaterialTheme.typography.labelLarge, + fontWeight = GoogleSansWeight.semiBold, + ) + } + } + } +} diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackViewModel.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackViewModel.kt new file mode 100644 index 000000000..c0b5328e1 --- /dev/null +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackViewModel.kt @@ -0,0 +1,281 @@ +package cx.aswin.boxlore.feature.settings.feedback + +import android.content.Context +import android.content.Intent +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import cx.aswin.boxlore.core.catalog.PodcastRepository +import cx.aswin.boxlore.core.prefs.BoxcastPrefs +import cx.aswin.boxlore.core.prefs.FeedbackDraft +import java.net.URLEncoder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +enum class FeedbackCategory( + val id: String, + val label: String, + val placeholder: String, +) { + FEATURE("feature", "Feature idea", "Describe the feature or improvement you'd like to see..."), + BUG("bug", "Bug report", "What happened and what did you expect to happen?"), + AUDIO("audio", "Audio / Stream", "Describe the playback issue (buffering, cut-off, audio distortion)..."), + OTHER("other", "General", "Tell us what's on your mind..."), +} + +data class FeedbackUiState( + val category: FeedbackCategory = FeedbackCategory.FEATURE, + val message: String = "", + val stepsToReproduce: String = "", + val email: String = "", + val attachDiagnostics: Boolean = true, + val isDraftRestored: Boolean = false, + val isSubmitting: Boolean = false, + val isSuccess: Boolean = false, + val errorMessage: String? = null, + val diagnosticInfo: DiagnosticInfo? = null, + val logsPreview: String? = null, + val isLoadingLogs: Boolean = false, +) + +class FeedbackViewModel( + private val podcastRepository: PodcastRepository, + private val boxcastPrefs: BoxcastPrefs, + private val context: Context, + private val submitFeedbackAction: (suspend (category: String, message: String, appVersion: String, email: String?) -> Boolean)? = null, +) : ViewModel() { + + private val _uiState = MutableStateFlow(FeedbackUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadInitialState() + } + + private fun loadInitialState() { + val diagnostics = DiagnosticCollector.collect(context) + val draft = boxcastPrefs.getFeedbackDraft() + + if (draft != null && draft.message.isNotBlank()) { + val matchingCategory = FeedbackCategory.entries.find { it.id == draft.category } + ?: FeedbackCategory.FEATURE + + _uiState.update { + it.copy( + category = matchingCategory, + message = draft.message, + stepsToReproduce = draft.stepsToReproduce, + email = draft.email, + attachDiagnostics = draft.attachDiagnostics, + isDraftRestored = true, + diagnosticInfo = diagnostics, + ) + } + } else { + _uiState.update { + it.copy(diagnosticInfo = diagnostics) + } + } + } + + fun onCategorySelected(category: FeedbackCategory) { + _uiState.update { it.copy(category = category, errorMessage = null) } + saveCurrentDraft() + } + + fun onMessageChanged(message: String) { + val trimmed = if (message.length > 2000) message.take(2000) else message + _uiState.update { it.copy(message = trimmed, errorMessage = null) } + saveCurrentDraft() + } + + fun onStepsChanged(steps: String) { + val trimmed = if (steps.length > 1000) steps.take(1000) else steps + _uiState.update { it.copy(stepsToReproduce = trimmed) } + saveCurrentDraft() + } + + fun onEmailChanged(email: String) { + _uiState.update { it.copy(email = email) } + saveCurrentDraft() + } + + fun onAttachDiagnosticsChanged(attach: Boolean) { + _uiState.update { it.copy(attachDiagnostics = attach) } + saveCurrentDraft() + } + + fun onDismissDraftBanner() { + _uiState.update { it.copy(isDraftRestored = false) } + } + + fun onDiscardDraft() { + boxcastPrefs.clearFeedbackDraft() + _uiState.update { + it.copy( + category = FeedbackCategory.FEATURE, + message = "", + stepsToReproduce = "", + isDraftRestored = false, + errorMessage = null, + ) + } + } + + private fun saveCurrentDraft() { + val current = _uiState.value + if (current.message.isBlank() && current.stepsToReproduce.isBlank()) { + boxcastPrefs.clearFeedbackDraft() + return + } + val draft = FeedbackDraft( + category = current.category.id, + message = current.message, + email = current.email, + stepsToReproduce = current.stepsToReproduce, + attachDiagnostics = current.attachDiagnostics, + ) + boxcastPrefs.saveFeedbackDraft(draft) + } + + fun loadLogsForPreview() { + _uiState.update { it.copy(isLoadingLogs = true) } + viewModelScope.launch { + val logs = withContext(Dispatchers.IO) { + LogcatCollector.collectSanitizedLogcat(maxLines = 150) + } + _uiState.update { + it.copy( + logsPreview = logs, + isLoadingLogs = false, + ) + } + } + } + + fun clearLogsPreview() { + _uiState.update { it.copy(logsPreview = null, isLoadingLogs = false) } + } + + fun onSubmit() { + val state = _uiState.value + val trimmedMessage = state.message.trim() + if (trimmedMessage.isBlank()) { + _uiState.update { it.copy(errorMessage = "Please enter your feedback before submitting.") } + return + } + + _uiState.update { it.copy(isSubmitting = true, errorMessage = null) } + + viewModelScope.launch { + // Map category: proxy endpoint accepts ["feature", "bug", "other"] + val wireCategory = when (state.category) { + FeedbackCategory.FEATURE -> "feature" + FeedbackCategory.BUG, FeedbackCategory.AUDIO -> "bug" + FeedbackCategory.OTHER -> "other" + } + + val appVersion = state.diagnosticInfo?.appVersion ?: "unknown" + + val fullMessage = buildString { + if (state.category == FeedbackCategory.AUDIO) { + append("[Category: Audio / Stream Issue]\n\n") + } + append(trimmedMessage) + if (state.stepsToReproduce.isNotBlank()) { + append("\n\nSteps to reproduce:\n") + append(state.stepsToReproduce.trim()) + } + if (state.attachDiagnostics && state.diagnosticInfo != null) { + append("\n\n---\nDiagnostics: ") + append(state.diagnosticInfo.toCondensedSummary()) + } + }.take(2000) + + val submit = submitFeedbackAction ?: { cat, msg, ver, mail -> + podcastRepository.submitFeedback(cat, msg, ver, mail) + } + + val success = submit( + wireCategory, + fullMessage, + appVersion, + state.email.trim().ifBlank { null }, + ) + + if (success) { + boxcastPrefs.clearFeedbackDraft() + _uiState.update { + it.copy( + isSubmitting = false, + isSuccess = true, + isDraftRestored = false, + ) + } + } else { + _uiState.update { + it.copy( + isSubmitting = false, + errorMessage = "Couldn't send feedback right now. Please check your connection and try again.", + ) + } + } + } + } + + fun buildGitHubIssueUrl(): String { + val state = _uiState.value + val title = "[${state.category.label}]: " + state.message.take(60).replace("\n", " ").trim() + val body = buildString { + append("### Description\n") + append(state.message.ifBlank { "Describe the issue or feature request here." }) + append("\n\n") + if (state.stepsToReproduce.isNotBlank()) { + append("### Steps to Reproduce\n") + append(state.stepsToReproduce) + append("\n\n") + } + if (state.diagnosticInfo != null) { + append(state.diagnosticInfo.toMarkdownReport(state.logsPreview)) + } + } + + return try { + "https://github.com/boxcreate/boxlore/issues/new?title=" + + URLEncoder.encode(title, "UTF-8") + + "&body=" + + URLEncoder.encode(body, "UTF-8") + } catch (_: Exception) { + "https://github.com/boxcreate/boxlore/issues/new" + } + } + + fun shareDiagnosticsReport(context: Context) { + val state = _uiState.value + val report = state.diagnosticInfo?.toMarkdownReport(state.logsPreview) + ?: "No diagnostics available." + + val sendIntent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_SUBJECT, "boxlore Diagnostics & Logs") + putExtra(Intent.EXTRA_TEXT, report) + } + val shareIntent = Intent.createChooser(sendIntent, "Share Diagnostics & Logs") + shareIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(shareIntent) + } + + class Factory( + private val podcastRepository: PodcastRepository, + private val boxcastPrefs: BoxcastPrefs, + private val context: Context, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = FeedbackViewModel(podcastRepository, boxcastPrefs, context) as T + } +} diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/LogcatCollector.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/LogcatCollector.kt new file mode 100644 index 000000000..59d30af5a --- /dev/null +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/LogcatCollector.kt @@ -0,0 +1,91 @@ +package cx.aswin.boxlore.feature.settings.feedback + +import android.os.Process +import androidx.annotation.WorkerThread +import java.io.BufferedReader +import java.io.InputStreamReader +import java.util.regex.Pattern + +/** + * Collects and sanitizes in-process logcat entries for diagnostic feedback. + * + * Scrubbing ensures that no bearer tokens, API keys, passwords, email addresses, + * or query parameters leak into feedback or diagnostic dumps. + */ +object LogcatCollector { + + private val BEARER_AUTH_PATTERN = Pattern.compile( + """(?i)(["']?authorization["']?\s*:\s*)?Bearer\s+([A-Za-z0-9\-_./+=]{6,})""", + ) + + private val FIREBASE_API_KEY_PATTERN = Pattern.compile( + """AIza[0-9A-Za-z\-_]{35}""", + ) + + private val URL_QUERY_PARAM_PATTERN = Pattern.compile( + """([?&](?:token|key|auth|api[-_]?key|secret)=)[^&\s]+""", + Pattern.CASE_INSENSITIVE, + ) + + private val SENSITIVE_KEY_VALUE_PATTERN = Pattern.compile( + """(?i)(["']?(?:authorization|token|secret|api[-_]?key|password|app[-_]?key|x[-_]app[-_]key)["']?\s*[:=]\s*["']?)(?!\s*(?:Bearer|\[REDACTED))([^"'\s,;&?]{6,})(["']?)""", + ) + + private val EMAIL_ADDRESS_PATTERN = Pattern.compile( + """\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b""", + ) + + /** + * Captures the most recent logcat entries for the current application process. + * Must be called on a background thread. + */ + @WorkerThread + fun collectSanitizedLogcat(maxLines: Int = 150): String = try { + val pid = Process.myPid() + val process = try { + ProcessBuilder("logcat", "--pid=$pid", "-d", "-v", "time", "-t", maxLines.toString()) + .redirectErrorStream(true) + .start() + } catch (_: Exception) { + // Fallback without --pid if platform logcat doesn't support the flag + ProcessBuilder("logcat", "-d", "-v", "time", "-t", maxLines.toString()) + .redirectErrorStream(true) + .start() + } + + val reader = BufferedReader(InputStreamReader(process.inputStream)) + val output = StringBuilder() + var line: String? + while (reader.readLine().also { line = it } != null) { + line?.let { + output.append(it).append('\n') + } + } + process.waitFor() + + val rawLog = output.toString() + if (rawLog.isBlank()) { + "[No logcat entries recorded for current session]" + } else { + sanitizeLogcatOutput(rawLog) + } + } catch (e: Exception) { + "[Logcat collection unavailable: ${e.message ?: e.javaClass.simpleName}]" + } + + /** + * Sanitizes sensitive information from raw logcat text. + */ + fun sanitizeLogcatOutput(raw: String): String { + if (raw.isBlank()) return raw + + var sanitized = raw + sanitized = URL_QUERY_PARAM_PATTERN.matcher(sanitized).replaceAll("$1[REDACTED]") + sanitized = BEARER_AUTH_PATTERN.matcher(sanitized).replaceAll("$1Bearer [REDACTED]") + sanitized = FIREBASE_API_KEY_PATTERN.matcher(sanitized).replaceAll("[REDACTED_API_KEY]") + sanitized = SENSITIVE_KEY_VALUE_PATTERN.matcher(sanitized).replaceAll("$1[REDACTED]$3") + sanitized = EMAIL_ADDRESS_PATTERN.matcher(sanitized).replaceAll("[REDACTED_EMAIL]") + + return sanitized + } +} diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AboutSettingsPage.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AboutSettingsPage.kt index 430a74747..da26ad05b 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AboutSettingsPage.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AboutSettingsPage.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.Launch +import androidx.compose.material.icons.rounded.Feedback import androidx.compose.material.icons.rounded.HistoryEdu import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -52,6 +53,7 @@ internal fun AboutSettingsPage( appInfo: AppInfo, onVisitPodcastIndex: () -> Unit, onOpenChangelog: () -> Unit, + onSendFeedback: () -> Unit = {}, onBack: () -> Unit, ) { SettingsScaffold( @@ -132,6 +134,12 @@ internal fun AboutSettingsPage( SpecRow(label = "Android", value = "${appInfo.androidRelease} (API ${appInfo.sdkInt})") } } + SettingsNavigationRow( + title = "Send feedback", + supportingText = "Ideas, bug reports, and logs", + icon = Icons.Rounded.Feedback, + onClick = onSendFeedback, + ) SettingsNavigationRow( title = "Changelog", supportingText = "What’s new on GitHub", diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/DiagnosticCollectorTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/DiagnosticCollectorTest.kt new file mode 100644 index 000000000..015139e90 --- /dev/null +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/DiagnosticCollectorTest.kt @@ -0,0 +1,58 @@ +package cx.aswin.boxlore.feature.settings.feedback + +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class DiagnosticCollectorTest { + + private val sampleInfo = DiagnosticInfo( + appVersion = "1.2.3", + buildCode = 42L, + packageName = "cx.aswin.boxlore", + androidRelease = "15", + sdkInt = 35, + manufacturer = "Google", + model = "Pixel 9", + networkType = "Wi-Fi", + audioRoute = "Bluetooth", + locale = "en-US", + ) + + @Test + fun `toCondensedSummary formats compact diagnostics correctly`() { + val summary = sampleInfo.toCondensedSummary() + + assertTrue(summary.contains("App: v1.2.3 (42)")) + assertTrue(summary.contains("Android 15 (API 35)")) + assertTrue(summary.contains("Google Pixel 9")) + assertTrue(summary.contains("Net: Wi-Fi")) + assertTrue(summary.contains("Audio: Bluetooth")) + assertTrue(summary.length < 150) + } + + @Test + fun `toMarkdownReport includes headers and fields`() { + val report = sampleInfo.toMarkdownReport(sanitizedLogcat = "Sample logcat content") + + assertTrue(report.contains("### System Diagnostics")) + assertTrue(report.contains("- **App**: boxlore `1.2.3` (build `42`)")) + assertTrue(report.contains("- **Package**: `cx.aswin.boxlore`")) + assertTrue(report.contains("- **Android**: `15` (API `35`)")) + assertTrue(report.contains("- **Device**: `Google Pixel 9`")) + assertTrue(report.contains("- **Network**: `Wi-Fi`")) + assertTrue(report.contains("- **Audio Route**: `Bluetooth`")) + assertTrue(report.contains("- **Locale**: `en-US`")) + assertTrue(report.contains("
")) + assertTrue(report.contains("Sanitized App Logs")) + assertTrue(report.contains("Sample logcat content")) + } + + @Test + fun `toMarkdownReport without logs omits details block`() { + val report = sampleInfo.toMarkdownReport(sanitizedLogcat = null) + + assertTrue(report.contains("### System Diagnostics")) + assertFalse(report.contains("
")) + } +} 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 new file mode 100644 index 000000000..fb993dba5 --- /dev/null +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackViewModelTest.kt @@ -0,0 +1,212 @@ +package cx.aswin.boxlore.feature.settings.feedback + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import cx.aswin.boxlore.core.catalog.PodcastRepository +import cx.aswin.boxlore.core.prefs.BoxcastPrefs +import cx.aswin.boxlore.core.prefs.FeedbackDraft +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class FeedbackViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + private lateinit var context: Context + private lateinit var prefs: BoxcastPrefs + private lateinit var dummyPodcastRepository: PodcastRepository + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + context = ApplicationProvider.getApplicationContext() + context + .getSharedPreferences(BoxcastPrefs.PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .clear() + .commit() + prefs = BoxcastPrefs(context) + dummyPodcastRepository = mock(PodcastRepository::class.java) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun initial_state_restores_draft_from_BoxcastPrefs_when_present() { + prefs.saveFeedbackDraft( + FeedbackDraft( + category = "bug", + message = "Player stops after 3 minutes", + email = "listener@example.com", + stepsToReproduce = "1. Play\n2. Wait", + attachDiagnostics = true, + ), + ) + + val vm = FeedbackViewModel(dummyPodcastRepository, prefs, context) + val state = vm.uiState.value + + assertEquals(FeedbackCategory.BUG, state.category) + assertEquals("Player stops after 3 minutes", state.message) + assertEquals("listener@example.com", state.email) + assertEquals("1. Play\n2. Wait", state.stepsToReproduce) + assertTrue(state.attachDiagnostics) + assertTrue(state.isDraftRestored) + } + + @Test + fun editing_fields_updates_state_and_persists_draft() { + val vm = FeedbackViewModel(dummyPodcastRepository, prefs, context) + + vm.onCategorySelected(FeedbackCategory.FEATURE) + vm.onMessageChanged("Add dark AMOLED theme") + vm.onEmailChanged("fan@example.com") + vm.onAttachDiagnosticsChanged(false) + + val state = vm.uiState.value + assertEquals(FeedbackCategory.FEATURE, state.category) + assertEquals("Add dark AMOLED theme", state.message) + assertEquals("fan@example.com", state.email) + assertFalse(state.attachDiagnostics) + + val savedDraft = prefs.getFeedbackDraft() + assertNotNull(savedDraft) + assertEquals("feature", savedDraft?.category) + assertEquals("Add dark AMOLED theme", savedDraft?.message) + assertEquals("fan@example.com", savedDraft?.email) + assertEquals(false, savedDraft?.attachDiagnostics) + } + + @Test + fun onDiscardDraft_resets_state_and_clears_draft_from_BoxcastPrefs() { + prefs.saveFeedbackDraft( + FeedbackDraft( + category = "other", + message = "Something random", + stepsToReproduce = "step 1", + ), + ) + + val vm = FeedbackViewModel(dummyPodcastRepository, prefs, context) + assertTrue(vm.uiState.value.isDraftRestored) + + vm.onDiscardDraft() + + val state = vm.uiState.value + assertEquals("", state.message) + assertEquals("", state.stepsToReproduce) + assertFalse(state.isDraftRestored) + assertNull(prefs.getFeedbackDraft()) + } + + @Test + fun onSubmit_with_blank_message_sets_error_and_does_not_call_repository() = runTest(testDispatcher) { + var called = false + val vm = FeedbackViewModel( + dummyPodcastRepository, + prefs, + context, + submitFeedbackAction = { _, _, _, _ -> + called = true + true + }, + ) + vm.onMessageChanged(" ") + + vm.onSubmit() + + val state = vm.uiState.value + assertNotNull(state.errorMessage) + assertFalse(state.isSubmitting) + assertFalse(called) + } + + @Test + fun onSubmit_success_clears_draft_and_marks_success() = runTest(testDispatcher) { + var capturedCategory: String? = null + var capturedEmail: String? = null + + val vm = FeedbackViewModel( + dummyPodcastRepository, + prefs, + context, + submitFeedbackAction = { cat, _, _, mail -> + capturedCategory = cat + capturedEmail = mail + true + }, + ) + vm.onCategorySelected(FeedbackCategory.FEATURE) + vm.onMessageChanged("Please support OPML tagging") + vm.onEmailChanged("dev@example.com") + + vm.onSubmit() + advanceUntilIdle() + + val state = vm.uiState.value + assertFalse(state.isSubmitting) + assertTrue(state.isSuccess) + assertEquals("feature", capturedCategory) + assertEquals("dev@example.com", capturedEmail) + assertNull(prefs.getFeedbackDraft()) + } + + @Test + fun onSubmit_with_audio_category_maps_to_bug_on_wire() = runTest(testDispatcher) { + var capturedCategory: String? = null + + val vm = FeedbackViewModel( + dummyPodcastRepository, + prefs, + context, + submitFeedbackAction = { cat, _, _, _ -> + capturedCategory = cat + true + }, + ) + vm.onCategorySelected(FeedbackCategory.AUDIO) + vm.onMessageChanged("Stream cuts off at end of track") + + vm.onSubmit() + advanceUntilIdle() + + val state = vm.uiState.value + assertTrue(state.isSuccess) + assertEquals("bug", capturedCategory) + } + + @Test + fun buildGitHubIssueUrl_creates_valid_URL_with_encoded_title() { + val vm = FeedbackViewModel(dummyPodcastRepository, prefs, context) + vm.onCategorySelected(FeedbackCategory.BUG) + vm.onMessageChanged("Crash when clicking download button") + vm.onStepsChanged("1. Tap download\n2. Observe crash") + + val url = vm.buildGitHubIssueUrl() + assertTrue(url.startsWith("https://github.com/boxcreate/boxlore/issues/new?title=")) + assertTrue(url.contains("Crash")) + assertTrue(url.contains("body=")) + } +} diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/LogcatCollectorTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/LogcatCollectorTest.kt new file mode 100644 index 000000000..7cc240990 --- /dev/null +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/LogcatCollectorTest.kt @@ -0,0 +1,72 @@ +package cx.aswin.boxlore.feature.settings.feedback + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class LogcatCollectorTest { + + @Test + fun `sanitizeLogcatOutput scrubs sensitive bearer tokens`() { + val raw = "12-10 10:20:30.123 1000 1000 D OkHttp: Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.xyz" + val sanitized = LogcatCollector.sanitizeLogcatOutput(raw) + + assertFalse(sanitized.contains("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.xyz")) + assertTrue(sanitized.contains("Bearer [REDACTED]")) + } + + @Test + fun `sanitizeLogcatOutput scrubs Firebase API keys`() { + val fakeKey = "AIza" + "SyD1234567890abcdefghijklmnopqrstuv" + val raw = "Loaded Firebase options with key $fakeKey" + val sanitized = LogcatCollector.sanitizeLogcatOutput(raw) + + assertFalse(sanitized.contains(fakeKey)) + assertTrue(sanitized.contains("[REDACTED_API_KEY]")) + } + + @Test + fun `sanitizeLogcatOutput scrubs key-value credentials`() { + val fakeApiKey = "mock" + "_api_key_12345678" + val fakeToken = "mock" + "_token_12345678" + val raw = """{"api_key": "$fakeApiKey", "token": "$fakeToken"}""" + val sanitized = LogcatCollector.sanitizeLogcatOutput(raw) + + assertFalse(sanitized.contains(fakeApiKey)) + assertFalse(sanitized.contains(fakeToken)) + assertTrue(sanitized.contains("[REDACTED]")) + } + + @Test + fun `sanitizeLogcatOutput scrubs sensitive query parameters`() { + val raw = "GET https://api.boxlore.cx/query?token=xyz123456&category=news HTTP/1.1" + val sanitized = LogcatCollector.sanitizeLogcatOutput(raw) + + assertFalse(sanitized.contains("token=xyz123456")) + assertTrue(sanitized.contains("token=[REDACTED]")) + assertTrue(sanitized.contains("category=news")) + } + + @Test + fun `sanitizeLogcatOutput scrubs email addresses`() { + val raw = "User signed in as user.test@example.com from device" + val sanitized = LogcatCollector.sanitizeLogcatOutput(raw) + + assertFalse(sanitized.contains("user.test@example.com")) + assertTrue(sanitized.contains("[REDACTED_EMAIL]")) + } + + @Test + fun `sanitizeLogcatOutput preserves non-sensitive content`() { + val raw = "12-10 10:20:30.123 1000 1000 I PlaybackService: State changed to STATE_PLAYING position=12400" + val sanitized = LogcatCollector.sanitizeLogcatOutput(raw) + + assertEquals(raw, sanitized) + } + + @Test + fun `sanitizeLogcatOutput handles empty text safely`() { + assertEquals("", LogcatCollector.sanitizeLogcatOutput("")) + } +} From 4064578f452ad293a55af2c14910ef9f9a844541 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Sat, 26 Sep 2026 16:12:55 +0530 Subject: [PATCH 02/21] fix(settings): enhance bottom clearance, scrub API URLs, and replace x-app-key with public key --- .../settings/feedback/FeedbackDialogs.kt | 17 ++- .../settings/feedback/FeedbackScreen.kt | 44 +++++- .../settings/feedback/FeedbackViewModel.kt | 127 ++++++++++++------ .../settings/feedback/LogcatCollector.kt | 106 +++++++++++---- .../feedback/FeedbackViewModelTest.kt | 30 ++++- .../settings/feedback/LogcatCollectorTest.kt | 24 ++++ 6 files changed, 267 insertions(+), 81 deletions(-) diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackDialogs.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackDialogs.kt index 1ae668a58..500f01550 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackDialogs.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackDialogs.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.Notes import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material.icons.rounded.Email import androidx.compose.material.icons.rounded.Share import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator @@ -46,6 +47,7 @@ fun LogsPreviewDialog( isLoading: Boolean, diagnosticInfo: DiagnosticInfo?, onShare: () -> Unit, + onEmail: () -> Unit, onDismissRequest: () -> Unit, ) { AlertDialog( @@ -70,7 +72,7 @@ fun LogsPreviewDialog( verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( - text = "Tokens, keys, and private credentials are automatically scrubbed.", + text = "Tokens and sensitive keys are automatically scrubbed.\nTip: Reproduce the issue first so relevant error logs appear.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -82,6 +84,7 @@ fun LogsPreviewDialog( logs = logs, diagnosticInfo = diagnosticInfo, onShare = onShare, + onEmail = onEmail, ) }, dismissButton = { @@ -143,11 +146,12 @@ private fun LogsActionButtons( logs: String?, diagnosticInfo: DiagnosticInfo?, onShare: () -> Unit, + onEmail: () -> Unit, ) { val context = LocalContext.current Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically, ) { OutlinedButton( @@ -164,6 +168,15 @@ private fun LogsActionButtons( Text("Copy") } + OutlinedButton( + onClick = onEmail, + shape = MaterialTheme.shapes.medium, + ) { + Icon(Icons.Rounded.Email, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(4.dp)) + Text("Email") + } + FilledTonalButton( onClick = onShare, shape = MaterialTheme.shapes.medium, 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 ab736c736..882236334 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 @@ -69,6 +69,8 @@ import cx.aswin.boxlore.core.catalog.PodcastRepository import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight import cx.aswin.boxlore.core.prefs.BoxcastPrefs +private val FEEDBACK_CONTENT_BOTTOM_PADDING = 240.dp + @OptIn(ExperimentalMaterial3Api::class) @Composable fun FeedbackScreen( @@ -135,7 +137,12 @@ fun FeedbackScreen( .fillMaxSize() .padding(paddingValues) .verticalScroll(rememberScrollState()) - .padding(horizontal = 20.dp, vertical = 12.dp), + .padding( + start = 20.dp, + top = 12.dp, + end = 20.dp, + bottom = FEEDBACK_CONTENT_BOTTOM_PADDING, + ), uiState = uiState, viewModel = feedbackViewModel, context = context, @@ -147,7 +154,8 @@ fun FeedbackScreen( logs = uiState.logsPreview, isLoading = uiState.isLoadingLogs, diagnosticInfo = uiState.diagnosticInfo, - onShare = { feedbackViewModel.shareDiagnosticsReport(context) }, + onShare = { shareFeedbackDiagnostics(context, uiState) }, + onEmail = { sendFeedbackEmail(context, uiState) }, onDismissRequest = { feedbackViewModel.clearLogsPreview() }, ) } @@ -237,7 +245,7 @@ private fun FeedbackFormView( onMessageChanged = { viewModel.onMessageChanged(it) }, ) - if (uiState.category == FeedbackCategory.BUG || uiState.category == FeedbackCategory.AUDIO) { + if (uiState.category.isBugReport) { FeedbackStepsInput( steps = uiState.stepsToReproduce, onStepsChanged = { viewModel.onStepsChanged(it) }, @@ -257,7 +265,7 @@ private fun FeedbackFormView( ) FeedbackCommunityRow( - gitHubUrl = viewModel.buildGitHubIssueUrl(), + gitHubUrl = buildFeedbackGitHubIssueUrl(uiState), context = context, ) @@ -268,7 +276,7 @@ private fun FeedbackFormView( onSubmit = { viewModel.onSubmit() }, ) - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(24.dp)) } } @@ -417,7 +425,7 @@ private fun FeedbackStepsInput( steps: String, onStepsChanged: (String) -> Unit, ) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Text( text = "Steps to Reproduce (Optional)", style = MaterialTheme.typography.labelLarge, @@ -425,6 +433,30 @@ private fun FeedbackStepsInput( color = MaterialTheme.colorScheme.onSurface, ) + Surface( + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Rounded.Lightbulb, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp), + ) + Text( + text = "Tip: Reproduce the issue right before sending so recent logs capture the error.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + OutlinedTextField( value = steps, onValueChange = onStepsChanged, diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackViewModel.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackViewModel.kt index c0b5328e1..7d88fd3bf 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackViewModel.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackViewModel.kt @@ -2,6 +2,7 @@ package cx.aswin.boxlore.feature.settings.feedback import android.content.Context import android.content.Intent +import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope @@ -28,12 +29,15 @@ enum class FeedbackCategory( OTHER("other", "General", "Tell us what's on your mind..."), } +val FeedbackCategory.isBugReport: Boolean + get() = this == FeedbackCategory.BUG || this == FeedbackCategory.AUDIO + data class FeedbackUiState( val category: FeedbackCategory = FeedbackCategory.FEATURE, val message: String = "", val stepsToReproduce: String = "", val email: String = "", - val attachDiagnostics: Boolean = true, + val attachDiagnostics: Boolean = false, val isDraftRestored: Boolean = false, val isSubmitting: Boolean = false, val isSuccess: Boolean = false, @@ -84,7 +88,13 @@ class FeedbackViewModel( } fun onCategorySelected(category: FeedbackCategory) { - _uiState.update { it.copy(category = category, errorMessage = null) } + _uiState.update { + it.copy( + category = category, + attachDiagnostics = category.isBugReport, + errorMessage = null, + ) + } saveCurrentDraft() } @@ -121,6 +131,7 @@ class FeedbackViewModel( category = FeedbackCategory.FEATURE, message = "", stepsToReproduce = "", + attachDiagnostics = false, isDraftRestored = false, errorMessage = null, ) @@ -147,7 +158,7 @@ class FeedbackViewModel( _uiState.update { it.copy(isLoadingLogs = true) } viewModelScope.launch { val logs = withContext(Dispatchers.IO) { - LogcatCollector.collectSanitizedLogcat(maxLines = 150) + LogcatCollector.collectSanitizedLogcat(context = context, maxLines = 150) } _uiState.update { it.copy( @@ -228,54 +239,84 @@ class FeedbackViewModel( } } - fun buildGitHubIssueUrl(): String { - val state = _uiState.value - val title = "[${state.category.label}]: " + state.message.take(60).replace("\n", " ").trim() - val body = buildString { - append("### Description\n") - append(state.message.ifBlank { "Describe the issue or feature request here." }) + class Factory( + private val podcastRepository: PodcastRepository, + private val boxcastPrefs: BoxcastPrefs, + private val context: Context, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = FeedbackViewModel(podcastRepository, boxcastPrefs, context) as T + } +} + +fun buildFeedbackGitHubIssueUrl(state: FeedbackUiState): String { + val title = "[${state.category.label}]: " + state.message.take(60).replace("\n", " ").trim() + val body = buildString { + append("### Description\n") + append(state.message.ifBlank { "Describe the issue or feature request here." }) + append("\n\n") + if (state.stepsToReproduce.isNotBlank()) { + append("### Steps to Reproduce\n") + append(state.stepsToReproduce) append("\n\n") - if (state.stepsToReproduce.isNotBlank()) { - append("### Steps to Reproduce\n") - append(state.stepsToReproduce) - append("\n\n") - } - if (state.diagnosticInfo != null) { - append(state.diagnosticInfo.toMarkdownReport(state.logsPreview)) - } } - - return try { - "https://github.com/boxcreate/boxlore/issues/new?title=" + - URLEncoder.encode(title, "UTF-8") + - "&body=" + - URLEncoder.encode(body, "UTF-8") - } catch (_: Exception) { - "https://github.com/boxcreate/boxlore/issues/new" + if (state.diagnosticInfo != null) { + append(state.diagnosticInfo.toMarkdownReport(state.logsPreview)) } } - fun shareDiagnosticsReport(context: Context) { - val state = _uiState.value - val report = state.diagnosticInfo?.toMarkdownReport(state.logsPreview) - ?: "No diagnostics available." + return try { + "https://github.com/boxcreate/boxlore/issues/new?title=" + + URLEncoder.encode(title, "UTF-8") + + "&body=" + + URLEncoder.encode(body, "UTF-8") + } catch (_: Exception) { + "https://github.com/boxcreate/boxlore/issues/new" + } +} + +fun shareFeedbackDiagnostics(context: Context, state: FeedbackUiState) { + val report = state.diagnosticInfo?.toMarkdownReport(state.logsPreview) + ?: "No diagnostics available." + + val sendIntent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_SUBJECT, "boxlore Diagnostics & Logs") + putExtra(Intent.EXTRA_TEXT, report) + } + val shareIntent = Intent.createChooser(sendIntent, "Share Diagnostics & Logs") + shareIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(shareIntent) +} - val sendIntent = Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(Intent.EXTRA_SUBJECT, "boxlore Diagnostics & Logs") - putExtra(Intent.EXTRA_TEXT, report) +fun sendFeedbackEmail(context: Context, state: FeedbackUiState) { + val report = buildString { + if (state.message.isNotBlank()) { + append("Message:\n") + append(state.message.trim()) + append("\n\n") + } + if (state.stepsToReproduce.isNotBlank()) { + append("Steps to reproduce:\n") + append(state.stepsToReproduce.trim()) + append("\n\n") } - val shareIntent = Intent.createChooser(sendIntent, "Share Diagnostics & Logs") - shareIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - context.startActivity(shareIntent) + append(state.diagnosticInfo?.toMarkdownReport(state.logsPreview) ?: "No diagnostics available.") } - class Factory( - private val podcastRepository: PodcastRepository, - private val boxcastPrefs: BoxcastPrefs, - private val context: Context, - ) : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = FeedbackViewModel(podcastRepository, boxcastPrefs, context) as T + val emailIntent = Intent(Intent.ACTION_SENDTO).apply { + data = Uri.parse("mailto:feedback@aswin.cx") + putExtra( + Intent.EXTRA_SUBJECT, + "[boxlore] ${state.category.label} & Diagnostics (v${state.diagnosticInfo?.appVersion ?: ""})", + ) + putExtra(Intent.EXTRA_TEXT, report) + } + try { + val chooser = Intent.createChooser(emailIntent, "Send feedback email") + chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(chooser) + } catch (_: Exception) { + shareFeedbackDiagnostics(context, state) } } diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/LogcatCollector.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/LogcatCollector.kt index 59d30af5a..9c717816d 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/LogcatCollector.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/LogcatCollector.kt @@ -1,5 +1,6 @@ package cx.aswin.boxlore.feature.settings.feedback +import android.content.Context import android.os.Process import androidx.annotation.WorkerThread import java.io.BufferedReader @@ -10,10 +11,14 @@ import java.util.regex.Pattern * Collects and sanitizes in-process logcat entries for diagnostic feedback. * * Scrubbing ensures that no bearer tokens, API keys, passwords, email addresses, - * or query parameters leak into feedback or diagnostic dumps. + * private API URLs, or internal header names leak into feedback or diagnostic dumps. */ object LogcatCollector { + private const val SCRUBBED_API_URL = "https://api.boxlore.app" + private const val SCRUBBED_API_HOST = "api.boxlore.app" + private const val SCRUBBED_APP_KEY_LABEL = "public key" + private val BEARER_AUTH_PATTERN = Pattern.compile( """(?i)(["']?authorization["']?\s*:\s*)?Bearer\s+([A-Za-z0-9\-_./+=]{6,})""", ) @@ -27,64 +32,107 @@ object LogcatCollector { Pattern.CASE_INSENSITIVE, ) + private val X_APP_KEY_VALUE_PATTERN = Pattern.compile( + """(?i)["']?(?:x[-_ ]app[-_ ]key|boxlore[-_]public[-_]key|boxcast[-_]public[-_]key)["']?\s*[:=]\s*(?:\[REDACTED\]|["']?[^"'\s,;&?]+["']?)""", + ) + + private val X_APP_KEY_NAME_PATTERN = Pattern.compile( + """(?i)\b(?:x[-_]app[-_]key|boxlore[-_]public[-_]key|boxcast[-_]public[-_]key|x\s+app\s+key)\b""", + ) + private val SENSITIVE_KEY_VALUE_PATTERN = Pattern.compile( - """(?i)(["']?(?:authorization|token|secret|api[-_]?key|password|app[-_]?key|x[-_]app[-_]key)["']?\s*[:=]\s*["']?)(?!\s*(?:Bearer|\[REDACTED))([^"'\s,;&?]{6,})(["']?)""", + """(?i)(["']?(?:authorization|token|secret|api[-_]?key|password|app[-_]?key)["']?\s*[:=]\s*["']?)(?!\s*(?:Bearer|\[REDACTED))([^"'\s,;&?]{6,})(["']?)""", ) private val EMAIL_ADDRESS_PATTERN = Pattern.compile( """\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b""", ) + private val API_URL_PATTERN = Pattern.compile( + """(?i)https?://[a-zA-Z0-9.-]*aswin\.cx(:[0-9]+)?(/[^\s"']*)?""", + ) + + private val ASWIN_HOST_PATTERN = Pattern.compile( + """(?i)\b[a-zA-Z0-9.-]*aswin\.cx(:[0-9]+)?\b""", + ) + + private val API_CONFIG_URL_PATTERN = Pattern.compile( + """(?i)(["']?(?:api[-_]?base[-_]?url|api[-_]?url|base[-_]?url)["']?\s*[:=]\s*["']?)https?://[^"'\s]+(["']?)""", + ) + /** * Captures the most recent logcat entries for the current application process. * Must be called on a background thread. */ @WorkerThread - fun collectSanitizedLogcat(maxLines: Int = 150): String = try { - val pid = Process.myPid() - val process = try { - ProcessBuilder("logcat", "--pid=$pid", "-d", "-v", "time", "-t", maxLines.toString()) - .redirectErrorStream(true) - .start() - } catch (_: Exception) { - // Fallback without --pid if platform logcat doesn't support the flag - ProcessBuilder("logcat", "-d", "-v", "time", "-t", maxLines.toString()) - .redirectErrorStream(true) - .start() - } + fun collectSanitizedLogcat(context: Context? = null, maxLines: Int = 150): String = try { + val pid = Process.myPid() + val process = try { + ProcessBuilder("logcat", "--pid=$pid", "-d", "-v", "time", "-t", maxLines.toString()) + .redirectErrorStream(true) + .start() + } catch (_: Exception) { + // Fallback without --pid if platform logcat doesn't support the flag + ProcessBuilder("logcat", "-d", "-v", "time", "-t", maxLines.toString()) + .redirectErrorStream(true) + .start() + } - val reader = BufferedReader(InputStreamReader(process.inputStream)) - val output = StringBuilder() - var line: String? - while (reader.readLine().also { line = it } != null) { - line?.let { - output.append(it).append('\n') - } + val reader = BufferedReader(InputStreamReader(process.inputStream)) + val output = StringBuilder() + var line: String? + while (reader.readLine().also { line = it } != null) { + line?.let { + output.append(it).append('\n') } - process.waitFor() + } + process.waitFor() - val rawLog = output.toString() - if (rawLog.isBlank()) { - "[No logcat entries recorded for current session]" + val rawLog = output.toString() + if (rawLog.isBlank()) { + "[No logcat entries recorded for current session]" + } else { + val (apiBaseUrl, publicKey) = if (context != null) { + val prefs = context.getSharedPreferences("boxlore_api_config", Context.MODE_PRIVATE) + prefs.getString("base_url", null) to prefs.getString("public_key", null) } else { - sanitizeLogcatOutput(rawLog) + null to null } - } catch (e: Exception) { - "[Logcat collection unavailable: ${e.message ?: e.javaClass.simpleName}]" + sanitizeLogcatOutput(rawLog, apiBaseUrl, publicKey) } + } catch (e: Exception) { + "[Logcat collection unavailable: ${e.message ?: e.javaClass.simpleName}]" + } /** * Sanitizes sensitive information from raw logcat text. */ - fun sanitizeLogcatOutput(raw: String): String { + fun sanitizeLogcatOutput( + raw: String, + knownApiBaseUrl: String? = null, + knownPublicKey: String? = null, + ): String { if (raw.isBlank()) return raw var sanitized = raw + + if (!knownPublicKey.isNullOrBlank() && knownPublicKey.length >= 4) { + sanitized = sanitized.replace(knownPublicKey, "[REDACTED]") + } + if (!knownApiBaseUrl.isNullOrBlank() && knownApiBaseUrl.startsWith("http")) { + sanitized = sanitized.replace(knownApiBaseUrl, SCRUBBED_API_URL) + } + sanitized = URL_QUERY_PARAM_PATTERN.matcher(sanitized).replaceAll("$1[REDACTED]") sanitized = BEARER_AUTH_PATTERN.matcher(sanitized).replaceAll("$1Bearer [REDACTED]") sanitized = FIREBASE_API_KEY_PATTERN.matcher(sanitized).replaceAll("[REDACTED_API_KEY]") + sanitized = X_APP_KEY_VALUE_PATTERN.matcher(sanitized).replaceAll("$SCRUBBED_APP_KEY_LABEL: [REDACTED]") + sanitized = X_APP_KEY_NAME_PATTERN.matcher(sanitized).replaceAll(SCRUBBED_APP_KEY_LABEL) sanitized = SENSITIVE_KEY_VALUE_PATTERN.matcher(sanitized).replaceAll("$1[REDACTED]$3") sanitized = EMAIL_ADDRESS_PATTERN.matcher(sanitized).replaceAll("[REDACTED_EMAIL]") + sanitized = API_CONFIG_URL_PATTERN.matcher(sanitized).replaceAll("$1$SCRUBBED_API_URL$2") + sanitized = API_URL_PATTERN.matcher(sanitized).replaceAll("$SCRUBBED_API_URL$2") + sanitized = ASWIN_HOST_PATTERN.matcher(sanitized).replaceAll(SCRUBBED_API_HOST) return sanitized } 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 fb993dba5..f5bdcfb1c 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 @@ -204,9 +204,37 @@ class FeedbackViewModelTest { vm.onMessageChanged("Crash when clicking download button") vm.onStepsChanged("1. Tap download\n2. Observe crash") - val url = vm.buildGitHubIssueUrl() + val url = buildFeedbackGitHubIssueUrl(vm.uiState.value) assertTrue(url.startsWith("https://github.com/boxcreate/boxlore/issues/new?title=")) assertTrue(url.contains("Crash")) assertTrue(url.contains("body=")) } + + @Test + fun category_selection_toggles_attachDiagnostics_default_for_bug_reports() { + val vm = FeedbackViewModel(dummyPodcastRepository, prefs, context) + // Default category is FEATURE -> attachDiagnostics should be false + assertEquals(FeedbackCategory.FEATURE, vm.uiState.value.category) + assertFalse(vm.uiState.value.attachDiagnostics) + + // Switching to BUG should default attachDiagnostics to true + vm.onCategorySelected(FeedbackCategory.BUG) + assertEquals(FeedbackCategory.BUG, vm.uiState.value.category) + assertTrue(vm.uiState.value.attachDiagnostics) + + // Switching to AUDIO should keep attachDiagnostics true + vm.onCategorySelected(FeedbackCategory.AUDIO) + assertEquals(FeedbackCategory.AUDIO, vm.uiState.value.category) + assertTrue(vm.uiState.value.attachDiagnostics) + + // Switching back to FEATURE should set attachDiagnostics to false + vm.onCategorySelected(FeedbackCategory.FEATURE) + assertEquals(FeedbackCategory.FEATURE, vm.uiState.value.category) + assertFalse(vm.uiState.value.attachDiagnostics) + + // Switching to OTHER should set attachDiagnostics to false + vm.onCategorySelected(FeedbackCategory.OTHER) + assertEquals(FeedbackCategory.OTHER, vm.uiState.value.category) + assertFalse(vm.uiState.value.attachDiagnostics) + } } diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/LogcatCollectorTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/LogcatCollectorTest.kt index 7cc240990..aaa2fe79f 100644 --- a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/LogcatCollectorTest.kt +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/LogcatCollectorTest.kt @@ -65,6 +65,30 @@ class LogcatCollectorTest { assertEquals(raw, sanitized) } + @Test + fun `sanitizeLogcatOutput scrubs API URLs and replaces them with simple text`() { + val domain = "aswin" + ".cx" + val raw = "--> GET https://api.$domain/podcasts/search?q=news HTTP/1.1\nHost: api.$domain" + val sanitized = LogcatCollector.sanitizeLogcatOutput(raw) + + assertFalse(sanitized.contains(domain)) + assertTrue(sanitized.contains("https://api.boxlore.app/podcasts/search?q=news")) + assertTrue(sanitized.contains("Host: api.boxlore.app")) + } + + @Test + fun `sanitizeLogcatOutput scrubs X-App-Key header and replaces param name with public key`() { + val fakeKey = "mock" + "_key_value_89012" + val raw = "--> X-App-Key: $fakeKey\nAttaching x-app-key to outgoing request" + val sanitized = LogcatCollector.sanitizeLogcatOutput(raw) + + assertFalse(sanitized.contains("X-App-Key")) + assertFalse(sanitized.contains("x-app-key")) + assertFalse(sanitized.contains(fakeKey)) + assertTrue(sanitized.contains("public key: [REDACTED]")) + assertTrue(sanitized.contains("Attaching public key to outgoing request")) + } + @Test fun `sanitizeLogcatOutput handles empty text safely`() { assertEquals("", LogcatCollector.sanitizeLogcatOutput("")) From f5323aab6a6a4993e20e902e7962e8488c4be762 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Sat, 26 Sep 2026 16:21:38 +0530 Subject: [PATCH 03/21] feat(settings): add category-specific supporting flavors for email input --- .../feature/settings/feedback/FeedbackScreen.kt | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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 882236334..3116e5067 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 @@ -253,6 +253,7 @@ private fun FeedbackFormView( } FeedbackEmailInput( + category = uiState.category, email = uiState.email, onEmailChanged = { viewModel.onEmailChanged(it) }, ) @@ -480,8 +481,18 @@ private fun FeedbackStepsInput( } } +private fun getEmailSupportingText(category: FeedbackCategory): String = when (category) { + FeedbackCategory.BUG, FeedbackCategory.AUDIO -> + "We obsess over getting boxlore right. Rather than applying a quick band-aid, we would like to chat more about the issue to ensure that it is truly solved. So please, if possible, share your email — we promise to respect your inbox with no marketing, only direct help on this issue." + FeedbackCategory.FEATURE -> + "We love shaping boxlore alongside our listeners. If your idea sparks something great, we’d love to reach out to brainstorm, discuss designs, or have you test it early — strictly product chats, zero marketing." + FeedbackCategory.OTHER -> + "Every note helps shape where boxlore goes next. Leave your email if you'd like us to write back — we read every single message and promise to respect your inbox with zero marketing." +} + @Composable private fun FeedbackEmailInput( + category: FeedbackCategory, email: String, onEmailChanged: (String) -> Unit, ) { @@ -506,7 +517,7 @@ private fun FeedbackEmailInput( }, supportingText = { Text( - text = "Only used if we need more context or to let you know when it's resolved.", + text = getEmailSupportingText(category), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), ) From 167511888dc65b2dbc7dcd8fe5e5f0a062bef02e Mon Sep 17 00:00:00 2001 From: Aswin C Date: Sat, 26 Sep 2026 16:32:07 +0530 Subject: [PATCH 04/21] fix(settings): expand log preview dialog and layout action buttons horizontally --- .../settings/feedback/FeedbackDialogs.kt | 64 ++++++++++++++----- 1 file changed, 49 insertions(+), 15 deletions(-) diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackDialogs.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackDialogs.kt index 500f01550..0af233457 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackDialogs.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackDialogs.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -16,6 +17,7 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -37,8 +39,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.DialogProperties import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight @Composable @@ -49,9 +53,15 @@ fun LogsPreviewDialog( onShare: () -> Unit, onEmail: () -> Unit, onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, ) { AlertDialog( onDismissRequest = onDismissRequest, + modifier = modifier + .padding(horizontal = 16.dp) + .widthIn(max = 520.dp) + .fillMaxWidth(), + properties = DialogProperties(usePlatformDefaultWidth = false), icon = { Icon( imageVector = Icons.AutoMirrored.Rounded.Notes, @@ -69,7 +79,7 @@ fun LogsPreviewDialog( text = { Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), ) { Text( text = "Tokens and sensitive keys are automatically scrubbed.\nTip: Reproduce the issue first so relevant error logs appear.", @@ -77,17 +87,16 @@ fun LogsPreviewDialog( color = MaterialTheme.colorScheme.onSurfaceVariant, ) LogsTextSurface(logs = logs, isLoading = isLoading) + LogsActionButtons( + logs = logs, + diagnosticInfo = diagnosticInfo, + isLoading = isLoading, + onShare = onShare, + onEmail = onEmail, + ) } }, confirmButton = { - LogsActionButtons( - logs = logs, - diagnosticInfo = diagnosticInfo, - onShare = onShare, - onEmail = onEmail, - ) - }, - dismissButton = { TextButton(onClick = onDismissRequest) { Text("Close") } @@ -106,13 +115,13 @@ private fun LogsTextSurface( Surface( modifier = modifier .fillMaxWidth() - .heightIn(min = 160.dp, max = 320.dp), + .heightIn(min = 140.dp, max = 280.dp), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.surfaceContainerHigh, ) { if (isLoading) { Box( - modifier = Modifier.fillMaxWidth().height(160.dp), + modifier = Modifier.fillMaxWidth().height(140.dp), contentAlignment = Alignment.Center, ) { CircularProgressIndicator(modifier = Modifier.size(32.dp)) @@ -145,13 +154,17 @@ private fun LogsTextSurface( private fun LogsActionButtons( logs: String?, diagnosticInfo: DiagnosticInfo?, + isLoading: Boolean, onShare: () -> Unit, onEmail: () -> Unit, + modifier: Modifier = Modifier, ) { val context = LocalContext.current + val isActionEnabled = !isLoading Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { OutlinedButton( @@ -161,29 +174,50 @@ private fun LogsActionButtons( cm.setPrimaryClip(ClipData.newPlainText("boxlore diagnostics", report)) Toast.makeText(context, "Copied diagnostics & logs to clipboard", Toast.LENGTH_SHORT).show() }, + modifier = Modifier.weight(1f), + enabled = isActionEnabled, + contentPadding = PaddingValues(horizontal = 6.dp, vertical = 8.dp), shape = MaterialTheme.shapes.medium, ) { Icon(Icons.Rounded.ContentCopy, contentDescription = null, modifier = Modifier.size(16.dp)) Spacer(Modifier.width(4.dp)) - Text("Copy") + Text( + text = "Copy", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } OutlinedButton( onClick = onEmail, + modifier = Modifier.weight(1f), + enabled = isActionEnabled, + contentPadding = PaddingValues(horizontal = 6.dp, vertical = 8.dp), shape = MaterialTheme.shapes.medium, ) { Icon(Icons.Rounded.Email, contentDescription = null, modifier = Modifier.size(16.dp)) Spacer(Modifier.width(4.dp)) - Text("Email") + Text( + text = "Email", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } FilledTonalButton( onClick = onShare, + modifier = Modifier.weight(1f), + enabled = isActionEnabled, + contentPadding = PaddingValues(horizontal = 6.dp, vertical = 8.dp), shape = MaterialTheme.shapes.medium, ) { Icon(Icons.Rounded.Share, contentDescription = null, modifier = Modifier.size(16.dp)) Spacer(Modifier.width(4.dp)) - Text("Share") + Text( + text = "Share", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } } } From d8acf902cb71ae9025c37aaaa8a0e4fdea394978 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Sat, 26 Sep 2026 16:42:09 +0530 Subject: [PATCH 05/21] feat(settings): refine feedback success screen with category flavors and 24h TAT --- .../settings/feedback/FeedbackScreen.kt | 62 +-- .../settings/feedback/FeedbackSuccessView.kt | 372 ++++++++++++++++++ .../feedback/FeedbackSuccessContentTest.kt | 99 +++++ 3 files changed, 474 insertions(+), 59 deletions(-) create mode 100644 feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSuccessView.kt create mode 100644 feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSuccessContentTest.kt 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 3116e5067..3994ce8e3 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 @@ -9,7 +9,6 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -27,7 +26,6 @@ import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.Launch import androidx.compose.material.icons.automirrored.rounded.Message import androidx.compose.material.icons.rounded.BugReport -import androidx.compose.material.icons.rounded.CheckCircle import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.DeleteOutline import androidx.compose.material.icons.rounded.Headphones @@ -125,11 +123,12 @@ fun FeedbackScreen( ) { paddingValues -> if (uiState.isSuccess) { FeedbackSuccessView( + uiState = uiState, + onDone = onBack, modifier = Modifier .fillMaxSize() .padding(paddingValues) - .padding(24.dp), - onDone = onBack, + .padding(horizontal = 24.dp, vertical = 16.dp), ) } else { FeedbackFormView( @@ -162,61 +161,6 @@ fun FeedbackScreen( } } -@Composable -private fun FeedbackSuccessView( - modifier: Modifier = Modifier, - onDone: () -> Unit, -) { - Column( - modifier = modifier, - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Surface( - shape = MaterialTheme.shapes.extraLarge, - color = MaterialTheme.colorScheme.primaryContainer, - modifier = Modifier.size(72.dp), - ) { - Box(contentAlignment = Alignment.Center) { - Icon( - imageVector = Icons.Rounded.CheckCircle, - contentDescription = null, - modifier = Modifier.size(40.dp), - tint = MaterialTheme.colorScheme.onPrimaryContainer, - ) - } - } - - Spacer(modifier = Modifier.height(20.dp)) - - Text( - text = "Sent — thank you!", - style = MaterialTheme.typography.headlineSmall, - fontWeight = GoogleSansWeight.semiBold, - color = MaterialTheme.colorScheme.onSurface, - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = "Your feedback helps shape boxlore. We read every submission.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) - - Spacer(modifier = Modifier.height(32.dp)) - - Button( - onClick = onDone, - modifier = Modifier.fillMaxWidth(0.6f), - shape = MaterialTheme.shapes.large, - ) { - Text("Done") - } - } -} - @Composable private fun FeedbackFormView( modifier: Modifier = Modifier, diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSuccessView.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSuccessView.kt new file mode 100644 index 000000000..862fce941 --- /dev/null +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSuccessView.kt @@ -0,0 +1,372 @@ +package cx.aswin.boxlore.feature.settings.feedback + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.DoneAll +import androidx.compose.material.icons.rounded.Email +import androidx.compose.material.icons.rounded.Schedule +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight + +internal data class FeedbackSuccessContent( + val title: String, + val subtitle: String, + val statusTitle: String, + val statusDescription: String, + val isEmailAttached: Boolean, +) + +internal fun getFeedbackSuccessContent( + category: FeedbackCategory, + email: String, +): FeedbackSuccessContent { + val trimmedEmail = email.trim() + val hasEmail = trimmedEmail.isNotBlank() + + return when (category) { + FeedbackCategory.BUG -> FeedbackSuccessContent( + title = "Bug report submitted", + subtitle = "Thank you for helping us squash this bug. We obsess over getting boxlore right and fixing issues at the source.", + statusTitle = if (hasEmail) "Reach out within 24 hours" else "Submitted anonymously", + statusDescription = if (hasEmail) { + "Rather than applying a quick band-aid, we would like to chat more about the issue to ensure it is truly solved. We will review your diagnostics and reach out to $trimmedEmail within 24 hours." + } else { + "Your report has been logged in our debugging queue. We will investigate the root cause for upcoming boxlore updates." + }, + isEmailAttached = hasEmail, + ) + FeedbackCategory.AUDIO -> FeedbackSuccessContent( + title = "Audio report submitted", + subtitle = "Playback reliability is core to boxlore. Thank you for reporting this stream behavior so we can investigate and resolve it.", + statusTitle = if (hasEmail) "Reach out within 24 hours" else "Submitted anonymously", + statusDescription = if (hasEmail) { + "We obsess over getting boxlore right. We'll analyze your playback diagnostics and reach out to $trimmedEmail within 24 hours to help troubleshoot." + } else { + "Your audio playback report and stream diagnostics have been queued for investigation in our next release." + }, + isEmailAttached = hasEmail, + ) + FeedbackCategory.FEATURE -> FeedbackSuccessContent( + title = "Feature idea submitted", + subtitle = "We love shaping boxlore alongside our listeners. Your suggestion helps steer what we build next.", + statusTitle = if (hasEmail) "Reach out within 24 hours" else "Submitted anonymously", + statusDescription = if (hasEmail) { + "We discuss community ideas regularly. If we explore this feature further or need your perspective, we'll reach out to $trimmedEmail within 24 hours." + } else { + "Your idea has been added to our product design roadmap for upcoming boxlore planning." + }, + isEmailAttached = hasEmail, + ) + FeedbackCategory.OTHER -> FeedbackSuccessContent( + title = "Feedback received", + subtitle = "Thank you for taking the time to write to us. Every note and perspective helps make boxlore better.", + statusTitle = if (hasEmail) "Reach out within 24 hours" else "Submitted anonymously", + statusDescription = if (hasEmail) { + "We personally review every note. If your message requires a follow-up or reply, we'll reach out to $trimmedEmail within 24 hours." + } else { + "Your feedback has been safely shared with the boxlore team. Thank you for listening!" + }, + isEmailAttached = hasEmail, + ) + } +} + +@Composable +fun FeedbackSuccessView( + uiState: FeedbackUiState, + onDone: () -> Unit, + modifier: Modifier = Modifier, +) { + val content = remember(uiState.category, uiState.email) { + getFeedbackSuccessContent(uiState.category, uiState.email) + } + + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(16.dp)) + FeedbackSuccessHero(title = content.title, subtitle = content.subtitle) + Spacer(modifier = Modifier.height(28.dp)) + FeedbackStatusCard(content = content) + Spacer(modifier = Modifier.height(16.dp)) + FeedbackSummaryCard(uiState = uiState, isEmailAttached = content.isEmailAttached) + Spacer(modifier = Modifier.height(36.dp)) + Button( + onClick = onDone, + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + shape = MaterialTheme.shapes.large, + ) { + Text("Done") + } + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Composable +private fun FeedbackSuccessHero( + title: String, + subtitle: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.size(80.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Rounded.CheckCircle, + contentDescription = null, + modifier = Modifier.size(44.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + fontWeight = GoogleSansWeight.semiBold, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 8.dp), + ) + } +} + +@Composable +private fun FeedbackStatusCard( + content: FeedbackSuccessContent, + modifier: Modifier = Modifier, +) { + Surface( + shape = MaterialTheme.shapes.large, + color = if (content.isEmailAttached) { + MaterialTheme.colorScheme.surfaceContainerHigh + } else { + MaterialTheme.colorScheme.surfaceContainer + }, + modifier = modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.Top, + ) { + Surface( + shape = CircleShape, + color = if (content.isEmailAttached) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.secondaryContainer + }, + modifier = Modifier.size(40.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = if (content.isEmailAttached) { + Icons.Rounded.Schedule + } else { + Icons.Rounded.DoneAll + }, + contentDescription = null, + tint = if (content.isEmailAttached) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSecondaryContainer + }, + modifier = Modifier.size(22.dp), + ) + } + } + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = content.statusTitle, + style = MaterialTheme.typography.titleSmall, + fontWeight = GoogleSansWeight.semiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = content.statusDescription, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = 18.sp, + ) + } + } + } +} + +@Composable +private fun FeedbackSummaryCard( + uiState: FeedbackUiState, + isEmailAttached: Boolean, + modifier: Modifier = Modifier, +) { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + SummaryCategoryRow(categoryLabel = uiState.category.label) + + if (isEmailAttached) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), + ) + SummaryEmailRow(email = uiState.email.trim()) + } + + if (uiState.attachDiagnostics) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), + ) + SummaryDiagnosticsRow() + } + } + } +} + +@Composable +private fun SummaryCategoryRow(categoryLabel: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Category", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Surface( + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.secondaryContainer, + ) { + Text( + text = categoryLabel, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + ) + } + } +} + +@Composable +private fun SummaryEmailRow(email: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Contact email", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.Email, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = email, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = GoogleSansWeight.medium, + ) + } + } +} + +@Composable +private fun SummaryDiagnosticsRow() { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Diagnostics & logs", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.CheckCircle, + contentDescription = null, + modifier = Modifier.size(15.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = "Included", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = GoogleSansWeight.medium, + ) + } + } +} diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSuccessContentTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSuccessContentTest.kt new file mode 100644 index 000000000..36f697083 --- /dev/null +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSuccessContentTest.kt @@ -0,0 +1,99 @@ +package cx.aswin.boxlore.feature.settings.feedback + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class FeedbackSuccessContentTest { + + @Test + fun getFeedbackSuccessContent_bugWithEmail_includes24HrTatAndEmail() { + val content = getFeedbackSuccessContent(FeedbackCategory.BUG, "listener@example.com") + + assertEquals("Bug report submitted", content.title) + assertTrue(content.subtitle.contains("boxlore")) + assertTrue(content.isEmailAttached) + assertEquals("Reach out within 24 hours", content.statusTitle) + assertTrue(content.statusDescription.contains("24 hours")) + assertTrue(content.statusDescription.contains("listener@example.com")) + assertTrue(content.statusDescription.contains("truly solved")) + } + + @Test + fun getFeedbackSuccessContent_bugWithoutEmail_showsAnonymousLogging() { + val content = getFeedbackSuccessContent(FeedbackCategory.BUG, " ") + + assertEquals("Bug report submitted", content.title) + assertFalse(content.isEmailAttached) + assertEquals("Submitted anonymously", content.statusTitle) + assertTrue(content.statusDescription.contains("debugging queue")) + } + + @Test + fun getFeedbackSuccessContent_audioWithEmail_includesAudioSpecificCopyAndTat() { + val content = getFeedbackSuccessContent(FeedbackCategory.AUDIO, "audiofan@example.com") + + assertEquals("Audio report submitted", content.title) + assertTrue(content.subtitle.contains("Playback reliability")) + assertTrue(content.isEmailAttached) + assertEquals("Reach out within 24 hours", content.statusTitle) + assertTrue(content.statusDescription.contains("24 hours")) + assertTrue(content.statusDescription.contains("audiofan@example.com")) + assertTrue(content.statusDescription.contains("playback diagnostics")) + } + + @Test + fun getFeedbackSuccessContent_audioWithoutEmail_showsAnonymousQueue() { + val content = getFeedbackSuccessContent(FeedbackCategory.AUDIO, "") + + assertEquals("Audio report submitted", content.title) + assertFalse(content.isEmailAttached) + assertEquals("Submitted anonymously", content.statusTitle) + assertTrue(content.statusDescription.contains("audio playback report")) + } + + @Test + fun getFeedbackSuccessContent_featureWithEmail_includesFeatureCopyAndTat() { + val content = getFeedbackSuccessContent(FeedbackCategory.FEATURE, "builder@example.com") + + assertEquals("Feature idea submitted", content.title) + assertTrue(content.subtitle.contains("boxlore")) + assertTrue(content.isEmailAttached) + assertEquals("Reach out within 24 hours", content.statusTitle) + assertTrue(content.statusDescription.contains("24 hours")) + assertTrue(content.statusDescription.contains("builder@example.com")) + } + + @Test + fun getFeedbackSuccessContent_featureWithoutEmail_showsAnonymousRoadmap() { + val content = getFeedbackSuccessContent(FeedbackCategory.FEATURE, "") + + assertEquals("Feature idea submitted", content.title) + assertFalse(content.isEmailAttached) + assertEquals("Submitted anonymously", content.statusTitle) + assertTrue(content.statusDescription.contains("product design roadmap")) + } + + @Test + fun getFeedbackSuccessContent_otherWithEmail_includesGeneralCopyAndTat() { + val content = getFeedbackSuccessContent(FeedbackCategory.OTHER, "friend@example.com") + + assertEquals("Feedback received", content.title) + assertTrue(content.subtitle.contains("boxlore")) + assertTrue(content.isEmailAttached) + assertEquals("Reach out within 24 hours", content.statusTitle) + assertTrue(content.statusDescription.contains("24 hours")) + assertTrue(content.statusDescription.contains("friend@example.com")) + } + + @Test + fun getFeedbackSuccessContent_otherWithoutEmail_showsAnonymousDelivery() { + val content = getFeedbackSuccessContent(FeedbackCategory.OTHER, "") + + assertEquals("Feedback received", content.title) + assertFalse(content.isEmailAttached) + assertEquals("Submitted anonymously", content.statusTitle) + assertTrue(content.statusDescription.contains("safely shared")) + } +} From 2d1a5721a2b493f006add73591b0449134387376 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Sat, 26 Sep 2026 16:52:46 +0530 Subject: [PATCH 06/21] feat(feedback): send full diagnostics and sanitized logs to feedback endpoint --- .../boxlore/core/catalog/PodcastRepository.kt | 19 +++++++-- .../boxlore/core/network/model/SyncModels.kt | 9 +++- .../settings/feedback/FeedbackViewModel.kt | 41 ++++++++++++++++--- .../feedback/FeedbackViewModelTest.kt | 33 +++++++++++++-- 4 files changed, 89 insertions(+), 13 deletions(-) diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepository.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepository.kt index 5d57f594a..7c831a69e 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepository.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/PodcastRepository.kt @@ -679,11 +679,24 @@ class PodcastRepository( } } - suspend fun submitFeedback(category: String, message: String, appVersion: String, email: String? = null,): Boolean = withContext(ioDispatcher) { + suspend fun submitFeedback( + category: String, + message: String, + appVersion: String, + email: String? = null, + diagnostics: String? = null, + logs: String? = null, + ): Boolean = withContext(ioDispatcher) { try { val request = - cx.aswin.boxlore.core.network.model - .FeedbackRequest(category, message, appVersion, email) + cx.aswin.boxlore.core.network.model.FeedbackRequest( + category = category, + message = message, + appVersion = appVersion, + email = email, + diagnostics = diagnostics, + logs = logs, + ) val response = api.submitFeedback(publicKey, request).execute() response.isSuccessful && response.body()?.success == true } catch (e: Exception) { diff --git a/core/network/src/main/java/cx/aswin/boxlore/core/network/model/SyncModels.kt b/core/network/src/main/java/cx/aswin/boxlore/core/network/model/SyncModels.kt index 3361133c5..927978c52 100644 --- a/core/network/src/main/java/cx/aswin/boxlore/core/network/model/SyncModels.kt +++ b/core/network/src/main/java/cx/aswin/boxlore/core/network/model/SyncModels.kt @@ -14,7 +14,14 @@ data class SyncResponse(val items: List = emptyList(), val error: Stri data class SyncItem(val id: String, val latestEpisode: EpisodeItem? = null) @Serializable -data class FeedbackRequest(val category: String, val message: String, val appVersion: String, val email: String? = null) +data class FeedbackRequest( + val category: String, + val message: String, + val appVersion: String, + val email: String? = null, + val diagnostics: String? = null, + val logs: String? = null, +) @Serializable data class FeedbackResponse(val success: Boolean) diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackViewModel.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackViewModel.kt index 7d88fd3bf..4f65204e3 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackViewModel.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackViewModel.kt @@ -10,6 +10,7 @@ import cx.aswin.boxlore.core.catalog.PodcastRepository import cx.aswin.boxlore.core.prefs.BoxcastPrefs import cx.aswin.boxlore.core.prefs.FeedbackDraft import java.net.URLEncoder +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -51,7 +52,17 @@ class FeedbackViewModel( private val podcastRepository: PodcastRepository, private val boxcastPrefs: BoxcastPrefs, private val context: Context, - private val submitFeedbackAction: (suspend (category: String, message: String, appVersion: String, email: String?) -> Boolean)? = null, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val submitFeedbackAction: ( + suspend ( + category: String, + message: String, + appVersion: String, + email: String?, + diagnostics: String?, + logs: String?, + ) -> Boolean + )? = null, ) : ViewModel() { private val _uiState = MutableStateFlow(FeedbackUiState()) @@ -157,7 +168,7 @@ class FeedbackViewModel( fun loadLogsForPreview() { _uiState.update { it.copy(isLoadingLogs = true) } viewModelScope.launch { - val logs = withContext(Dispatchers.IO) { + val logs = withContext(ioDispatcher) { LogcatCollector.collectSanitizedLogcat(context = context, maxLines = 150) } _uiState.update { @@ -193,6 +204,8 @@ class FeedbackViewModel( val appVersion = state.diagnosticInfo?.appVersion ?: "unknown" + val diagnosticsInfo = state.diagnosticInfo ?: DiagnosticCollector.collect(context) + val fullMessage = buildString { if (state.category == FeedbackCategory.AUDIO) { append("[Category: Audio / Stream Issue]\n\n") @@ -202,14 +215,28 @@ class FeedbackViewModel( append("\n\nSteps to reproduce:\n") append(state.stepsToReproduce.trim()) } - if (state.attachDiagnostics && state.diagnosticInfo != null) { + if (state.attachDiagnostics) { append("\n\n---\nDiagnostics: ") - append(state.diagnosticInfo.toCondensedSummary()) + append(diagnosticsInfo.toCondensedSummary()) } }.take(2000) - val submit = submitFeedbackAction ?: { cat, msg, ver, mail -> - podcastRepository.submitFeedback(cat, msg, ver, mail) + val diagnosticsReport = if (state.attachDiagnostics) { + diagnosticsInfo.toMarkdownReport() + } else { + null + } + + val sanitizedLogs = if (state.attachDiagnostics) { + withContext(ioDispatcher) { + LogcatCollector.collectSanitizedLogcat(context = context, maxLines = 100) + } + } else { + null + } + + val submit = submitFeedbackAction ?: { cat, msg, ver, mail, diag, logs -> + podcastRepository.submitFeedback(cat, msg, ver, mail, diag, logs) } val success = submit( @@ -217,6 +244,8 @@ class FeedbackViewModel( fullMessage, appVersion, state.email.trim().ifBlank { null }, + diagnosticsReport, + sanitizedLogs, ) if (success) { 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 f5bdcfb1c..4e6e93af2 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 @@ -128,7 +128,7 @@ class FeedbackViewModelTest { dummyPodcastRepository, prefs, context, - submitFeedbackAction = { _, _, _, _ -> + submitFeedbackAction = { _, _, _, _, _, _ -> called = true true }, @@ -152,7 +152,7 @@ class FeedbackViewModelTest { dummyPodcastRepository, prefs, context, - submitFeedbackAction = { cat, _, _, mail -> + submitFeedbackAction = { cat, _, _, mail, _, _ -> capturedCategory = cat capturedEmail = mail true @@ -181,7 +181,8 @@ class FeedbackViewModelTest { dummyPodcastRepository, prefs, context, - submitFeedbackAction = { cat, _, _, _ -> + ioDispatcher = testDispatcher, + submitFeedbackAction = { cat, _, _, _, _, _ -> capturedCategory = cat true }, @@ -197,6 +198,32 @@ class FeedbackViewModelTest { assertEquals("bug", capturedCategory) } + @Test + fun onSubmit_with_diagnostics_attached_passes_diagnostics_and_logs() = runTest(testDispatcher) { + var capturedDiag: String? = null + + val vm = FeedbackViewModel( + dummyPodcastRepository, + prefs, + context, + ioDispatcher = testDispatcher, + submitFeedbackAction = { _, _, _, _, diag, _ -> + capturedDiag = diag + true + }, + ) + vm.onCategorySelected(FeedbackCategory.BUG) + vm.onMessageChanged("Crash when clicking download button") + + vm.onSubmit() + advanceUntilIdle() + + val state = vm.uiState.value + assertTrue(state.isSuccess) + assertNotNull(capturedDiag) + assertTrue(capturedDiag!!.contains("System Diagnostics")) + } + @Test fun buildGitHubIssueUrl_creates_valid_URL_with_encoded_title() { val vm = FeedbackViewModel(dummyPodcastRepository, prefs, context) From 2911165898f9c73049bcdb00ec23cfc604ce9c07 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Sat, 26 Sep 2026 16:58:17 +0530 Subject: [PATCH 07/21] style(feedback): improve card and row icon alignment in success view --- .../settings/feedback/FeedbackSuccessView.kt | 123 +++++++++++++----- 1 file changed, 87 insertions(+), 36 deletions(-) diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSuccessView.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSuccessView.kt index 862fce941..5a865be6f 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSuccessView.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSuccessView.kt @@ -10,13 +10,20 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Message +import androidx.compose.material.icons.rounded.Analytics +import androidx.compose.material.icons.rounded.BugReport +import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.CheckCircle import androidx.compose.material.icons.rounded.DoneAll import androidx.compose.material.icons.rounded.Email +import androidx.compose.material.icons.rounded.Headphones +import androidx.compose.material.icons.rounded.Lightbulb import androidx.compose.material.icons.rounded.Schedule import androidx.compose.material3.Button import androidx.compose.material3.HorizontalDivider @@ -28,7 +35,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight @@ -180,6 +189,14 @@ private fun FeedbackSuccessHero( } } +private val FeedbackCategory.icon: ImageVector + get() = when (this) { + FeedbackCategory.BUG -> Icons.Rounded.BugReport + FeedbackCategory.AUDIO -> Icons.Rounded.Headphones + FeedbackCategory.FEATURE -> Icons.Rounded.Lightbulb + FeedbackCategory.OTHER -> Icons.AutoMirrored.Rounded.Message + } + @Composable private fun FeedbackStatusCard( content: FeedbackSuccessContent, @@ -197,7 +214,7 @@ private fun FeedbackStatusCard( Row( modifier = Modifier.padding(16.dp), horizontalArrangement = Arrangement.spacedBy(14.dp), - verticalAlignment = Alignment.Top, + verticalAlignment = Alignment.CenterVertically, ) { Surface( shape = CircleShape, @@ -254,15 +271,15 @@ private fun FeedbackSummaryCard( modifier: Modifier = Modifier, ) { Surface( - shape = MaterialTheme.shapes.medium, + shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surfaceContainerLow, modifier = modifier.fillMaxWidth(), ) { Column( - modifier = Modifier.padding(14.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { - SummaryCategoryRow(categoryLabel = uiState.category.label) + SummaryCategoryRow(category = uiState.category) if (isEmailAttached) { HorizontalDivider( @@ -282,23 +299,34 @@ private fun FeedbackSummaryCard( } @Composable -private fun SummaryCategoryRow(categoryLabel: String) { +private fun SummaryCategoryRow(category: FeedbackCategory) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = "Category", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + imageVector = category.icon, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "Category", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } Surface( shape = MaterialTheme.shapes.small, color = MaterialTheme.colorScheme.secondaryContainer, ) { Text( - text = categoryLabel, + text = category.label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSecondaryContainer, modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), @@ -314,28 +342,34 @@ private fun SummaryEmailRow(email: String) { horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = "Contact email", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.weight(1f, fill = false), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), ) { Icon( imageVector = Icons.Rounded.Email, contentDescription = null, - modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( - text = email, + text = "Contact email", style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = GoogleSansWeight.medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = email, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = GoogleSansWeight.medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + textAlign = TextAlign.End, + ) } } @@ -346,27 +380,44 @@ private fun SummaryDiagnosticsRow() { horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = "Diagnostics & logs", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), ) { Icon( - imageVector = Icons.Rounded.CheckCircle, + imageVector = Icons.Rounded.Analytics, contentDescription = null, - modifier = Modifier.size(15.dp), - tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( - text = "Included", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = GoogleSansWeight.medium, + text = "Diagnostics & logs", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + Surface( + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.primaryContainer, + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + Text( + text = "Included", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + fontWeight = GoogleSansWeight.medium, + ) + } + } } } From 80881304899904bfbc8c7c7506d0e8fec4dad15e Mon Sep 17 00:00:00 2001 From: Aswin C Date: Sat, 26 Sep 2026 17:07:02 +0530 Subject: [PATCH 08/21] fix(feedback): add bottom padding to success screen to clear player and nav --- .../boxlore/feature/settings/feedback/FeedbackScreen.kt | 3 ++- .../feature/settings/feedback/FeedbackSuccessView.kt | 8 +++++--- 2 files changed, 7 insertions(+), 4 deletions(-) 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 3994ce8e3..222bd87ab 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 @@ -125,10 +125,11 @@ fun FeedbackScreen( FeedbackSuccessView( uiState = uiState, onDone = onBack, + bottomPadding = FEEDBACK_CONTENT_BOTTOM_PADDING, modifier = Modifier .fillMaxSize() .padding(paddingValues) - .padding(horizontal = 24.dp, vertical = 16.dp), + .padding(horizontal = 24.dp), ) } else { FeedbackFormView( diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSuccessView.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSuccessView.kt index 5a865be6f..cc194a34b 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSuccessView.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSuccessView.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight @@ -110,6 +111,7 @@ fun FeedbackSuccessView( uiState: FeedbackUiState, onDone: () -> Unit, modifier: Modifier = Modifier, + bottomPadding: Dp = 240.dp, ) { val content = remember(uiState.category, uiState.email) { getFeedbackSuccessContent(uiState.category, uiState.email) @@ -119,7 +121,7 @@ fun FeedbackSuccessView( modifier = modifier .fillMaxSize() .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.Center, + verticalArrangement = Arrangement.Top, horizontalAlignment = Alignment.CenterHorizontally, ) { Spacer(modifier = Modifier.height(16.dp)) @@ -128,7 +130,7 @@ fun FeedbackSuccessView( FeedbackStatusCard(content = content) Spacer(modifier = Modifier.height(16.dp)) FeedbackSummaryCard(uiState = uiState, isEmailAttached = content.isEmailAttached) - Spacer(modifier = Modifier.height(36.dp)) + Spacer(modifier = Modifier.height(32.dp)) Button( onClick = onDone, modifier = Modifier @@ -138,7 +140,7 @@ fun FeedbackSuccessView( ) { Text("Done") } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(bottomPadding)) } } From cb5b3b060a075f077d44abbd11cd745279cb3b3b Mon Sep 17 00:00:00 2001 From: Aswin C Date: Sat, 26 Sep 2026 17:19:10 +0530 Subject: [PATCH 09/21] fix(playback): only emit android auto analytics for auto and automotive controllers --- core/analytics/README.md | 1 + .../boxlore/core/analytics/AnalyticsHelper.kt | 4 + core/playback/README.md | 1 + .../auto/AutoBrowseControllerMatcher.kt | 18 +++ .../service/auto/AutoBrowseLibraryCallback.kt | 32 ++++-- .../auto/AutoBrowseLibraryCallbackTest.kt | 107 ++++++++++++++++++ 6 files changed, 152 insertions(+), 11 deletions(-) create mode 100644 core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseControllerMatcher.kt diff --git a/core/analytics/README.md b/core/analytics/README.md index 32a1166eb..01563508d 100644 --- a/core/analytics/README.md +++ b/core/analytics/README.md @@ -61,6 +61,7 @@ src/main/java/cx/aswin/boxlore/core/analytics/ - Unit tests live under `src/test/java/cx/aswin/boxlore/core/analytics`. - Use `RecordingAnalytics` when testing classes that accept `Analytics`. +- Use `AnalyticsHelper.installRecordingSink(recorder)` when testing classes or callbacks that interact directly with the singleton `AnalyticsHelper`. - `GlossaryAllEventsEmissionTest` covers every `emission:` inventory row; `LifecycleSdkMappingTest` / `InstallChannelAttributionTest` assert no dual open/install emits. - Architecture guards in `:core:testing` enforce CSV ↔ allowlist ↔ inventory parity. diff --git a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/AnalyticsHelper.kt b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/AnalyticsHelper.kt index 52ae3c9f8..60d58b127 100644 --- a/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/AnalyticsHelper.kt +++ b/core/analytics/src/main/java/cx/aswin/boxlore/core/analytics/AnalyticsHelper.kt @@ -13,6 +13,10 @@ object AnalyticsHelper : Analytics { @Volatile private var activePauseReason: String = "user_voluntary" + @androidx.annotation.VisibleForTesting + fun installRecordingSink(recorder: MutableList>>): () -> Unit = + AnalyticsEmit.installRecordingSink(recorder) + fun setSeekSource(source: String) { activeSeekSource = source } diff --git a/core/playback/README.md b/core/playback/README.md index 994dedec9..68bf8278b 100644 --- a/core/playback/README.md +++ b/core/playback/README.md @@ -23,6 +23,7 @@ Owns playback session control, queue orchestration, smart queue logic, Media3 pl - `HistoryRecommendationLogic`, `AutoVoiceSearchLogic`, `SmartQueueRefillPolicy`, `MixtapeResumePolicy`, `PlaybackEntryPointResolve`, `NightWindowLogic`, and `ListeningHistoryUpsertLogic` are JVM-testable playback helpers. `PlaybackControllerStatePolicy` keeps an empty/unprepared MediaController from erasing Room-restored progress while preserving policy-selected zero-position restarts, `PlaybackProgressPersistencePolicy` rejects out-of-order asynchronous snapshots while preserving intentional restarts/backward seeks and determining when `lastPlayedAt` must update on short plays or intentional replays, `PlaybackHistorySeedPolicy` resolves ordered non-blank service metadata into missing history rows with fallback enrichment from podcast and catalog repositories, `PlaybackSessionRestoreHelper` coordinates cold-start and controller session restoration without reverting to unrelated sessions while healing blank podcast metadata into Room, and `PlaybackTaskRemovalPolicy` requires a final save before stopping an inactive service. `PlaybackEntryPointResolve` maps fine-grained source-context `entry_point` strings (`home_mixtape`, `learn`/`learn_history`, `briefing`) to coarse `PlaybackEntryPoint` for queue/mixtape policy while the raw string still attributes `playback_*`. - `AutoMediaResolver` and `AutoMediaResolutionPolicy` resolve playable items and metadata for Android Auto, enriching missing fields independently while preserving local non-blank metadata and falling back to catalog sources. - `AutoPlaybackResumptionPolicy` and `AutoPlaybackResumptionHandler` coordinate Android Auto and media button reconnection playback resumption (`onPlaybackResumption`), managing live player continuation, active miniplayer restoration, dismissed/inactive incomplete episode resumption, and playlist queue alignment. +- `AutoBrowseControllerMatcher` filters MediaSession controllers using Media3 companion/automotive inspection APIs and known automotive package identifiers (`com.google.android.projection.gearhead`, `com.google.android.apps.automotive.media`, `com.android.car.media`) so Android Auto connection and browsing analytics are strictly isolated from standard Bluetooth AVRCP (`com.android.bluetooth`) and system notifications. - `AutoArtworkFetchLogic`, `AutoCollageFreshnessLogic`, and `AutoArtworkPipeLogic` encode Android Auto artwork fetch, cache policy, and safe pipe streaming for hermetic tests. - `AutoCollagePrewarmPolicy` and `AutoCollageFolderLogic` encode prewarm throttle and aligned image/key folder inputs for hermetic tests. - `PlaybackIntroOutroController` manages intro-skip and outro-trim playback lifecycle. Natural completion remains event-driven; ending-trim polling runs only during active playback with a real trim, clears completed monitor jobs, and uses a 500 ms cadence. diff --git a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseControllerMatcher.kt b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseControllerMatcher.kt new file mode 100644 index 000000000..f922425e7 --- /dev/null +++ b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseControllerMatcher.kt @@ -0,0 +1,18 @@ +package cx.aswin.boxlore.core.playback.service.auto + +import androidx.media3.session.MediaSession + +@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) +internal object AutoBrowseControllerMatcher { + fun isAndroidAuto(session: MediaSession, controller: MediaSession.ControllerInfo): Boolean { + val isMedia3Auto = + runCatching { + session.isAutoCompanionController(controller) || session.isAutomotiveController(controller) + }.getOrDefault(false) + + return isMedia3Auto || + controller.packageName == "com.google.android.projection.gearhead" || + controller.packageName == "com.google.android.apps.automotive.media" || + controller.packageName == "com.android.car.media" + } +} diff --git a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseLibraryCallback.kt b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseLibraryCallback.kt index 2b2f0c4b9..0b9640350 100644 --- a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseLibraryCallback.kt +++ b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseLibraryCallback.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.guava.future import kotlinx.coroutines.launch @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) +@Suppress("LargeClass") internal class AutoBrowseLibraryCallback( private val host: AutoBrowseLibraryHost, mediaResolver: AutoMediaResolver? = null, @@ -71,9 +72,14 @@ internal class AutoBrowseLibraryCallback( Bundle.EMPTY, ) - override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo,): MediaSession.ConnectionResult { - connectedAtMs[controller] = System.currentTimeMillis() - AnalyticsHelper.trackAndroidAutoConnected(sessionId = controller.packageName) + internal fun isAndroidAuto(session: MediaSession, controller: MediaSession.ControllerInfo): Boolean = + AutoBrowseControllerMatcher.isAndroidAuto(session, controller) + + override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): MediaSession.ConnectionResult { + if (isAndroidAuto(session, controller)) { + connectedAtMs[controller] = System.currentTimeMillis() + AnalyticsHelper.trackAndroidAutoConnected(sessionId = controller.packageName) + } val defaultResult = super.onConnect(session, controller) val sessionCommands = defaultResult.availableSessionCommands @@ -93,14 +99,16 @@ internal class AutoBrowseLibraryCallback( .build() } - override fun onDisconnected(session: MediaSession, controller: MediaSession.ControllerInfo,) { + override fun onDisconnected(session: MediaSession, controller: MediaSession.ControllerInfo) { val started = connectedAtMs.remove(controller) - val durationSeconds = - started?.let { ((System.currentTimeMillis() - it) / 1000L).toInt().coerceAtLeast(0) } - AnalyticsHelper.trackAndroidAutoDisconnected( - sessionId = controller.packageName, - durationSeconds = durationSeconds, - ) + if (started != null) { + val durationSeconds = + ((System.currentTimeMillis() - started) / 1000L).toInt().coerceAtLeast(0) + AnalyticsHelper.trackAndroidAutoDisconnected( + sessionId = controller.packageName, + durationSeconds = durationSeconds, + ) + } rootChildLimits.remove(controller) super.onDisconnected(session, controller) } @@ -338,7 +346,9 @@ internal class AutoBrowseLibraryCallback( params: LibraryParams?, ): ListenableFuture>> { android.util.Log.d("AutoBrowse", "onGetChildren: parentId=$parentId, page=$page") - AnalyticsHelper.trackAndroidAutoBrowse(node = parentId, action = "get_children") + if (isAndroidAuto(session, browser)) { + AnalyticsHelper.trackAndroidAutoBrowse(node = parentId, action = "get_children") + } return host.serviceScope.future { try { diff --git a/core/playback/src/test/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseLibraryCallbackTest.kt b/core/playback/src/test/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseLibraryCallbackTest.kt index 2e5593dc7..bf651c702 100644 --- a/core/playback/src/test/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseLibraryCallbackTest.kt +++ b/core/playback/src/test/java/cx/aswin/boxlore/core/playback/service/auto/AutoBrowseLibraryCallbackTest.kt @@ -4,15 +4,18 @@ import android.content.Context import android.net.Uri import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata +import androidx.media3.common.Player import androidx.media3.session.MediaLibraryService.MediaLibrarySession import androidx.media3.session.MediaSession import androidx.test.core.app.ApplicationProvider +import cx.aswin.boxlore.core.analytics.AnalyticsHelper import cx.aswin.boxlore.core.database.BoxLoreDatabase import cx.aswin.boxlore.core.database.PodcastDao import cx.aswin.boxlore.core.database.PodcastEntity import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking +import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue @@ -45,9 +48,13 @@ class AutoBrowseLibraryCallbackTest { private lateinit var treeBuilder: AutoBrowseTreeBuilder private lateinit var resumptionHandler: AutoPlaybackResumptionHandler private lateinit var callback: AutoBrowseLibraryCallback + private val recordedEvents = mutableListOf>>() + private lateinit var restoreAnalytics: () -> Unit @Before fun setUp() = runBlocking { + recordedEvents.clear() + restoreAnalytics = AnalyticsHelper.installRecordingSink(recordedEvents) context = ApplicationProvider.getApplicationContext() host = mock(AutoBrowseLibraryHost::class.java) database = mock(BoxLoreDatabase::class.java) @@ -62,6 +69,10 @@ class AutoBrowseLibraryCallbackTest { `when`(database.podcastDao()).thenReturn(podcastDao) `when`(host.getString(cx.aswin.boxlore.core.catalog.R.string.auto_app_name)).thenReturn("boxlore") `when`(host.getString(cx.aswin.boxlore.core.catalog.R.string.auto_group_subscriptions)).thenReturn("Subscriptions") + val commandButton = mock(androidx.media3.session.CommandButton::class.java) + `when`(host.seekBackAction).thenReturn(commandButton) + `when`(host.seekForwardAction).thenReturn(commandButton) + `when`(host.markCompleteAction).thenReturn(commandButton) `when`(treeBuilder.getRootChildren()).thenReturn(emptyList()) `when`(treeBuilder.getHomeChildren()).thenReturn(emptyList()) @@ -76,6 +87,11 @@ class AutoBrowseLibraryCallbackTest { ) } + @After + fun tearDown() { + restoreAnalytics() + } + @Test fun `resolveGetItem resolves known subscription to browsable podcast folder`() = runBlocking { val podcast = PodcastEntity( @@ -217,4 +233,95 @@ class AutoBrowseLibraryCallbackTest { assertEquals("subscription:pod-contract", result.value?.mediaId) assertEquals("Contract Show", result.value?.mediaMetadata?.title?.toString()) } + + @Test + fun `isAndroidAuto identifies Auto and Automotive controllers correctly`() { + val session = mock(MediaSession::class.java) + + fun createController(packageName: String): MediaSession.ControllerInfo { + val controller = mock(MediaSession.ControllerInfo::class.java) + `when`(controller.packageName).thenReturn(packageName) + return controller + } + + val bluetooth = createController("com.android.bluetooth") + val systemUi = createController("com.android.systemui") + val appSelf = createController("cx.aswin.boxlore") + val autoCompanion = createController("com.google.android.projection.gearhead") + val automotiveGoogle = createController("com.google.android.apps.automotive.media") + val automotiveAosp = createController("com.android.car.media") + + assertEquals(false, callback.isAndroidAuto(session, bluetooth)) + assertEquals(false, callback.isAndroidAuto(session, systemUi)) + assertEquals(false, callback.isAndroidAuto(session, appSelf)) + assertEquals(true, callback.isAndroidAuto(session, autoCompanion)) + assertEquals(true, callback.isAndroidAuto(session, automotiveGoogle)) + assertEquals(true, callback.isAndroidAuto(session, automotiveAosp)) + } + + @Test + fun `isAndroidAuto returns true when MediaSession indicates companion or automotive`() { + val session = mock(MediaSession::class.java) + val controller = mock(MediaSession.ControllerInfo::class.java) + `when`(controller.packageName).thenReturn("com.custom.auto.oem") + + `when`(session.isAutoCompanionController(controller)).thenReturn(true) + assertEquals(true, callback.isAndroidAuto(session, controller)) + + `when`(session.isAutoCompanionController(controller)).thenReturn(false) + `when`(session.isAutomotiveController(controller)).thenReturn(true) + assertEquals(true, callback.isAndroidAuto(session, controller)) + } + + @Test + fun `onConnect and onDisconnected do not emit analytics for Bluetooth controller`() { + val session = mock(MediaSession::class.java) + val player = mock(Player::class.java) + `when`(player.availableCommands).thenReturn(Player.Commands.EMPTY) + `when`(session.player).thenReturn(player) + val controller = mock(MediaSession.ControllerInfo::class.java) + `when`(controller.packageName).thenReturn("com.android.bluetooth") + + callback.onConnect(session, controller) + assertTrue(recordedEvents.none { it.first == "android_auto_connected" }) + + callback.onDisconnected(session, controller) + assertTrue(recordedEvents.none { it.first == "android_auto_disconnected" }) + } + + @Test + fun `onConnect and onDisconnected emit analytics for Android Auto controller`() { + val session = mock(MediaSession::class.java) + val player = mock(Player::class.java) + `when`(player.availableCommands).thenReturn(Player.Commands.EMPTY) + `when`(session.player).thenReturn(player) + val controller = mock(MediaSession.ControllerInfo::class.java) + `when`(controller.packageName).thenReturn("com.google.android.projection.gearhead") + + callback.onConnect(session, controller) + assertTrue(recordedEvents.any { it.first == "android_auto_connected" }) + + callback.onDisconnected(session, controller) + assertTrue(recordedEvents.any { it.first == "android_auto_disconnected" }) + } + + @Test + fun `onGetChildren does not emit android_auto_browse for non-Auto controller`() { + val session = mock(MediaLibrarySession::class.java) + val controller = mock(MediaSession.ControllerInfo::class.java) + `when`(controller.packageName).thenReturn("com.android.bluetooth") + + callback.onGetChildren(session, controller, AutoBrowseContract.ROOT_ID, 0, 10, null) + assertTrue(recordedEvents.none { it.first == "android_auto_browse" }) + } + + @Test + fun `onGetChildren emits android_auto_browse for Android Auto controller`() { + val session = mock(MediaLibrarySession::class.java) + val controller = mock(MediaSession.ControllerInfo::class.java) + `when`(controller.packageName).thenReturn("com.google.android.projection.gearhead") + + callback.onGetChildren(session, controller, AutoBrowseContract.ROOT_ID, 0, 10, null) + assertTrue(recordedEvents.any { it.first == "android_auto_browse" }) + } } From 27c1a63086edbb8ba1d8cfe9d6a68a63a1cdba84 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Sat, 26 Sep 2026 17:26:11 +0530 Subject: [PATCH 10/21] fix(feedback): clarify automatic log inclusion and replace export button with preview action --- .../settings/feedback/FeedbackDialogs.kt | 34 ++---- .../settings/feedback/FeedbackScreen.kt | 108 +++++++++++++----- 2 files changed, 86 insertions(+), 56 deletions(-) diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackDialogs.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackDialogs.kt index 0af233457..1e52c184b 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackDialogs.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackDialogs.kt @@ -23,7 +23,6 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.Notes import androidx.compose.material.icons.rounded.ContentCopy -import androidx.compose.material.icons.rounded.Email import androidx.compose.material.icons.rounded.Share import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator @@ -51,7 +50,6 @@ fun LogsPreviewDialog( isLoading: Boolean, diagnosticInfo: DiagnosticInfo?, onShare: () -> Unit, - onEmail: () -> Unit, onDismissRequest: () -> Unit, modifier: Modifier = Modifier, ) { @@ -82,7 +80,7 @@ fun LogsPreviewDialog( verticalArrangement = Arrangement.spacedBy(10.dp), ) { Text( - text = "Tokens and sensitive keys are automatically scrubbed.\nTip: Reproduce the issue first so relevant error logs appear.", + text = "These diagnostics and logs are already included with your submission. Tokens and sensitive keys are scrubbed.\nTip: Reproduce the issue first so relevant error logs appear.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -92,7 +90,6 @@ fun LogsPreviewDialog( diagnosticInfo = diagnosticInfo, isLoading = isLoading, onShare = onShare, - onEmail = onEmail, ) } }, @@ -156,7 +153,6 @@ private fun LogsActionButtons( diagnosticInfo: DiagnosticInfo?, isLoading: Boolean, onShare: () -> Unit, - onEmail: () -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -176,29 +172,13 @@ private fun LogsActionButtons( }, modifier = Modifier.weight(1f), enabled = isActionEnabled, - contentPadding = PaddingValues(horizontal = 6.dp, vertical = 8.dp), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), shape = MaterialTheme.shapes.medium, ) { Icon(Icons.Rounded.ContentCopy, contentDescription = null, modifier = Modifier.size(16.dp)) - Spacer(Modifier.width(4.dp)) + Spacer(Modifier.width(6.dp)) Text( - text = "Copy", - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - - OutlinedButton( - onClick = onEmail, - modifier = Modifier.weight(1f), - enabled = isActionEnabled, - contentPadding = PaddingValues(horizontal = 6.dp, vertical = 8.dp), - shape = MaterialTheme.shapes.medium, - ) { - Icon(Icons.Rounded.Email, contentDescription = null, modifier = Modifier.size(16.dp)) - Spacer(Modifier.width(4.dp)) - Text( - text = "Email", + text = "Copy text", maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -208,13 +188,13 @@ private fun LogsActionButtons( onClick = onShare, modifier = Modifier.weight(1f), enabled = isActionEnabled, - contentPadding = PaddingValues(horizontal = 6.dp, vertical = 8.dp), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), shape = MaterialTheme.shapes.medium, ) { Icon(Icons.Rounded.Share, contentDescription = null, modifier = Modifier.size(16.dp)) - Spacer(Modifier.width(4.dp)) + Spacer(Modifier.width(6.dp)) Text( - text = "Share", + text = "Share text", maxLines = 1, overflow = TextOverflow.Ellipsis, ) 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 222bd87ab..a49408826 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 @@ -10,6 +10,7 @@ import androidx.compose.animation.fadeOut import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -26,12 +27,14 @@ import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.Launch import androidx.compose.material.icons.automirrored.rounded.Message import androidx.compose.material.icons.rounded.BugReport +import androidx.compose.material.icons.rounded.CheckCircle import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.DeleteOutline import androidx.compose.material.icons.rounded.Headphones import androidx.compose.material.icons.rounded.Lightbulb import androidx.compose.material.icons.rounded.Restore import androidx.compose.material.icons.rounded.Star +import androidx.compose.material.icons.rounded.Visibility import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card @@ -155,7 +158,6 @@ fun FeedbackScreen( isLoading = uiState.isLoadingLogs, diagnosticInfo = uiState.diagnosticInfo, onShare = { shareFeedbackDiagnostics(context, uiState) }, - onEmail = { sendFeedbackEmail(context, uiState) }, onDismissRequest = { feedbackViewModel.clearLogsPreview() }, ) } @@ -521,39 +523,87 @@ private fun FeedbackDiagnosticsSection( } if (attachDiagnostics && diagnosticInfo != null) { - Surface( - shape = MaterialTheme.shapes.medium, - color = MaterialTheme.colorScheme.surfaceContainerHighest, - modifier = Modifier.fillMaxWidth(), - ) { - Column(modifier = Modifier.padding(12.dp)) { - Text( - text = "boxlore v${diagnosticInfo.appVersion} (${diagnosticInfo.buildCode}) • Android ${diagnosticInfo.androidRelease} (API ${diagnosticInfo.sdkInt})", - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = "${diagnosticInfo.manufacturer} ${diagnosticInfo.model} • ${diagnosticInfo.networkType} • ${diagnosticInfo.audioRoute}", - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - OutlinedButton( - onClick = onPreviewLogs, - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.medium, - ) { - Text("Preview & Export Logs") - } + DiagnosticsDetailsContent( + diagnosticInfo = diagnosticInfo, + onPreviewLogs = onPreviewLogs, + ) } } } } +@Composable +private fun DiagnosticsDetailsContent( + diagnosticInfo: DiagnosticInfo, + onPreviewLogs: () -> Unit, +) { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerHighest, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = "boxlore v${diagnosticInfo.appVersion} (${diagnosticInfo.buildCode}) • Android ${diagnosticInfo.androidRelease} (API ${diagnosticInfo.sdkInt})", + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "${diagnosticInfo.manufacturer} ${diagnosticInfo.model} • ${diagnosticInfo.networkType} • ${diagnosticInfo.audioRoute}", + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 2.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + imageVector = Icons.Rounded.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(15.dp), + ) + Text( + text = "Included automatically with report", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + TextButton( + onClick = onPreviewLogs, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), + ) { + Icon( + imageVector = Icons.Rounded.Visibility, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "Preview logs", + style = MaterialTheme.typography.labelMedium, + ) + } + } +} + @Composable private fun FeedbackCommunityRow( gitHubUrl: String, From ac34cb0f273c31254bcda9afebb4d99be1b21886 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Sat, 26 Sep 2026 19:43:03 +0530 Subject: [PATCH 11/21] docs(settings): document FeedbackSuccessView in README --- feature/settings/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/feature/settings/README.md b/feature/settings/README.md index c9bd8b7a9..c4cc4a5e5 100644 --- a/feature/settings/README.md +++ b/feature/settings/README.md @@ -35,6 +35,7 @@ src/main/java/cx/aswin/boxlore/feature/settings/ DiagnosticCollector.kt FeedbackDialogs.kt FeedbackScreen.kt + FeedbackSuccessView.kt FeedbackViewModel.kt LogcatCollector.kt pages/ From 37589444cb4661c3a3bba54f4658cb5531541999 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Sat, 26 Sep 2026 20:15:15 +0530 Subject: [PATCH 12/21] feat(auth): cascade delete cloud sync data on account deletion --- .../java/cx/aswin/boxlore/AppContainer.kt | 3 + .../core/auth/FirebaseAuthRepository.kt | 3 + core/catalog/README.md | 2 +- .../core/catalog/sync/UserSyncCoordinator.kt | 23 +++++++ .../catalog/sync/UserSyncCoordinatorTest.kt | 69 +++++++++++++++++++ 5 files changed, 99 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/cx/aswin/boxlore/AppContainer.kt b/app/src/main/java/cx/aswin/boxlore/AppContainer.kt index 91b2fbbea..06b1bdce4 100644 --- a/app/src/main/java/cx/aswin/boxlore/AppContainer.kt +++ b/app/src/main/java/cx/aswin/boxlore/AppContainer.kt @@ -340,6 +340,9 @@ class AppContainer( override fun getPendingEmail(): String? = prefs.getPendingAuthEmail() override fun setPendingEmail(email: String?) = prefs.setPendingAuthEmail(email) }, + onPreDeleteAccount = { + userSyncCoordinator.deleteCloudSyncData().getOrThrow() + }, ) }.getOrElse { object : cx.aswin.boxlore.core.auth.AuthRepository { diff --git a/core/auth/src/main/java/cx/aswin/boxlore/core/auth/FirebaseAuthRepository.kt b/core/auth/src/main/java/cx/aswin/boxlore/core/auth/FirebaseAuthRepository.kt index c3e8f2b15..3ae2c3079 100644 --- a/core/auth/src/main/java/cx/aswin/boxlore/core/auth/FirebaseAuthRepository.kt +++ b/core/auth/src/main/java/cx/aswin/boxlore/core/auth/FirebaseAuthRepository.kt @@ -19,6 +19,7 @@ class FirebaseAuthRepository( private val pendingEmailStore: PendingEmailStore? = null, private val magicLinkUrl: String = MAGIC_LINK_DEFAULT_URL, private val packageName: String = PACKAGE_NAME_DEFAULT, + private val onPreDeleteAccount: (suspend () -> Unit)? = null, ) : AuthRepository { private val _currentUser = MutableStateFlow(auth.currentUser.toBoxLoreUser()) @@ -98,6 +99,7 @@ class FirebaseAuthRepository( override suspend fun deleteAccount(): Result = runCatching { val user = auth.currentUser ?: error("No authenticated user to delete") try { + onPreDeleteAccount?.invoke() user.delete().awaitTask() pendingEmailStore?.setPendingEmail(null) } catch (e: Exception) { @@ -126,6 +128,7 @@ class FirebaseAuthRepository( "recent-login", "requires-recent-login", "credential_too_old", + "401", ) } } diff --git a/core/catalog/README.md b/core/catalog/README.md index 1d79d6b6e..be39a8ad5 100644 --- a/core/catalog/README.md +++ b/core/catalog/README.md @@ -22,7 +22,7 @@ Owns catalog orchestration: Podcast Index access through `PodcastRepository`, su - `InstallReferrerManager` parses Play Install Referrer deep links and exposes optional `onInstallReferrerResolved` (channel + raw referrer). `:app` wires that callback into analytics; catalog must not depend on `:core:analytics`. - `RoomLocalCatalog` implements `LocalCatalogPort`; its metadata enrichment transaction updates only an existing subscribed row, so a late Podcast Info response cannot create or restore a subscription after unsubscribe. `RoomEpisodeOfflineLookup` implements `EpisodeOfflineLookupPort`. - `ports.ListeningHistoryBackupPort`, `ports.SmartDownloadSyncPort`, `ports.QueueSyncPort`, `ports.ActivePlaybackSyncPort`, and `ports.PodcastNotificationSyncPort` keep backup, download, notification topic bindings, and playback seams cleanly decoupled without introducing forbidden `:core:catalog` to `:core:playback` dependency cycles. -- `sync.UserSyncCoordinator` orchestrates Boxlore Realtime Cloud Sync: batch-bounded dirty push with multi-column optimistic concurrency flag clearing, delta pull, account switch local data purge and active playback session teardown via `activePlaybackSyncPort.stopAndClearActiveSession()`, and coordination across `SubscriptionSyncResolver` (LWW subscription and tombstone resolution, RSS ingestion), `HistorySyncResolver` (active playing episode shield, progress LWW, independent like status LWW), and `QueueSyncResolver` (monotonic versioning, 3-way tombstone merge, head-position retention of active episode, and 5-tier metadata hydration). Results are communicated via `sync.SyncResult`, with reactive UI state modelled via `sync.CloudSyncUiStatus`. +- `sync.UserSyncCoordinator` orchestrates Boxlore Realtime Cloud Sync: batch-bounded dirty push with multi-column optimistic concurrency flag clearing, delta pull, cloud account deletion (`deleteCloudSyncData`), account switch local data purge and active playback session teardown via `activePlaybackSyncPort.stopAndClearActiveSession()`, and coordination across `SubscriptionSyncResolver` (LWW subscription and tombstone resolution, RSS ingestion), `HistorySyncResolver` (active playing episode shield, progress LWW, independent like status LWW), and `QueueSyncResolver` (monotonic versioning, 3-way tombstone merge, head-position retention of active episode, and 5-tier metadata hydration). Results are communicated via `sync.SyncResult`, with reactive UI state modelled via `sync.CloudSyncUiStatus`. - `FolderRepository` and `RoomFolderRepository` manage library subscription folders: creating iconless or icon-assigned folders with optional `showPodcastGrid` 1×1 cover style, updating display sizes, restoring folders from JSON backups (`restoreFolder`), auto-organizing subscribed shows into genre folders (with selectable display sizes and cover styles, while preserving custom and existing folders), auto-syncing subscribed shows matching `linkedGenre` (with canonical genre token matching and Tech/Technology synonym normalization), moving shows between folders on genre customization, adding/removing podcasts from folders, and cascading folder deletions without unsubscribing shows. - `:core:rss`, `:core:domain`, `:core:database`, and `:core:prefs` are re-exported where existing public signatures require those types. diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/sync/UserSyncCoordinator.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/sync/UserSyncCoordinator.kt index ceb433479..a72c6874b 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/sync/UserSyncCoordinator.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/sync/UserSyncCoordinator.kt @@ -451,6 +451,29 @@ open class UserSyncCoordinator( requireBoxcastPrefs.setLastSyncTimestamp(syncedAt) } + open suspend fun deleteCloudSyncData(): Result = withContext(ioDispatcher) { + syncMutex.withLock { + runCatching { + val userId = authUserIdProvider() + if (userId.isNullOrBlank()) { + return@runCatching + } + val token = tokenProvider() ?: return@runCatching + val call = requireBoxLoreApi.deleteSyncAccount( + publicKey = publicKey, + authorization = "Bearer $token", + ) + val response = call.execute() + val code = response.code() + if (!response.isSuccessful && code != 404) { + error("Delete cloud sync data failed with HTTP $code: ${response.errorBody()?.string()}") + } + requireBoxcastPrefs.setLastSyncTimestamp(0L) + requireBoxcastPrefs.setLastSyncedUserId(null) + } + } + } + private suspend fun handoffIdleMiniplayerIfNewer(history: List) { val newestRemoteHistory = history.maxByOrNull { it.lastPlayedAt } if (newestRemoteHistory != null && newestRemoteHistory.lastPlayedAt > 0L) { diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/sync/UserSyncCoordinatorTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/sync/UserSyncCoordinatorTest.kt index df4415ed6..bbc787684 100644 --- a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/sync/UserSyncCoordinatorTest.kt +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/sync/UserSyncCoordinatorTest.kt @@ -16,6 +16,7 @@ import cx.aswin.boxlore.core.network.BoxLoreApi import cx.aswin.boxlore.core.network.model.ListeningHistorySyncDto import cx.aswin.boxlore.core.network.model.QueueItemSyncDto import cx.aswin.boxlore.core.network.model.QueueSyncDto +import cx.aswin.boxlore.core.network.model.SyncDeleteAccountResponse import cx.aswin.boxlore.core.network.model.SyncPullRequest import cx.aswin.boxlore.core.network.model.SyncPullResponse import cx.aswin.boxlore.core.network.model.SyncPushRequest @@ -27,7 +28,9 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest +import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.Request +import okhttp3.ResponseBody.Companion.toResponseBody import okio.Timeout import org.junit.After import org.junit.Assert.assertEquals @@ -58,6 +61,7 @@ class UserSyncCoordinatorTest { private var syncPushHandler: ((publicKey: String, auth: String?, request: SyncPushRequest) -> Call)? = null private var syncPullHandler: ((publicKey: String, auth: String?, request: SyncPullRequest) -> Call)? = null + private var deleteSyncAccountHandler: ((publicKey: String, auth: String?) -> Call)? = null private lateinit var subscriptionSyncResolver: SubscriptionSyncResolver private lateinit var historySyncResolver: HistorySyncResolver @@ -83,6 +87,7 @@ class UserSyncCoordinatorTest { fakeQueueSyncPort = FakeQueueSyncPort() syncPushHandler = null syncPullHandler = null + deleteSyncAccountHandler = null fakeBoxLoreApi = Proxy.newProxyInstance( BoxLoreApi::class.java.classLoader, @@ -91,6 +96,7 @@ class UserSyncCoordinatorTest { when (method.name) { "syncPush" -> syncPushHandler?.invoke(args[0] as String, args[1] as? String, args[2] as SyncPushRequest) "syncPull" -> syncPullHandler?.invoke(args[0] as String, args[1] as? String, args[2] as SyncPullRequest) + "deleteSyncAccount" -> deleteSyncAccountHandler?.invoke(args[0] as String, args[1] as? String) "hashCode" -> 42 "equals" -> false "toString" -> "FakeBoxLoreApi" @@ -901,4 +907,67 @@ class UserSyncCoordinatorTest { assertEquals("account-b", prefs.getLastSyncedUserId()) assertEquals(2000L, prefs.getLastSyncTimestamp()) } + + @Test + fun deleteCloudSyncData_callsDeleteSyncAccountAndClearsPrefs() = runTest(testDispatcher) { + prefs.setLastSyncedUserId("test-user-123") + prefs.setLastSyncTimestamp(12345L) + + var capturedPublicKey: String? = null + var capturedAuth: String? = null + + deleteSyncAccountHandler = { pubKey, auth -> + capturedPublicKey = pubKey + capturedAuth = auth + FakeCall(Response.success(SyncDeleteAccountResponse(status = "ok"))) + } + + val result = coordinator.deleteCloudSyncData() + assertTrue(result.isSuccess) + assertEquals("test-public-key", capturedPublicKey) + assertEquals("Bearer mock-jwt-token", capturedAuth) + assertEquals(null, prefs.getLastSyncedUserId()) + assertEquals(0L, prefs.getLastSyncTimestamp()) + } + + @Test + fun deleteCloudSyncData_treats404AsSuccessAndClearsPrefs() = runTest(testDispatcher) { + prefs.setLastSyncedUserId("test-user-123") + prefs.setLastSyncTimestamp(12345L) + + deleteSyncAccountHandler = { _, _ -> + FakeCall( + Response.error( + 404, + "{\"error\":\"Not Found\"}".toResponseBody("application/json".toMediaTypeOrNull()), + ), + ) + } + + val result = coordinator.deleteCloudSyncData() + assertTrue(result.isSuccess) + assertEquals(null, prefs.getLastSyncedUserId()) + assertEquals(0L, prefs.getLastSyncTimestamp()) + } + + @Test + fun deleteCloudSyncData_serverErrorReturnsFailure() = runTest(testDispatcher) { + prefs.setLastSyncedUserId("test-user-123") + prefs.setLastSyncTimestamp(12345L) + + deleteSyncAccountHandler = { _, _ -> + FakeCall( + Response.error( + 500, + "{\"error\":\"Database connection failed\"}".toResponseBody("application/json".toMediaTypeOrNull()), + ), + ) + } + + val result = coordinator.deleteCloudSyncData() + assertTrue(result.isFailure) + // Ensure prefs are NOT wiped if the server deletion failed + assertEquals("test-user-123", prefs.getLastSyncedUserId()) + assertEquals(12345L, prefs.getLastSyncTimestamp()) + } } From 562faf95f4cc1e800be06b48797f3c8a7d5f0cd0 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Sat, 26 Sep 2026 20:29:17 +0530 Subject: [PATCH 13/21] feat(settings): link account deletion and data retention info in signed-in state --- .../settings/pages/AccountSettingsDialogs.kt | 29 +++++-- .../settings/pages/AccountSettingsPage.kt | 87 +++++++++++++++---- 2 files changed, 93 insertions(+), 23 deletions(-) diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AccountSettingsDialogs.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AccountSettingsDialogs.kt index de8dccb23..146cb3cc1 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AccountSettingsDialogs.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AccountSettingsDialogs.kt @@ -2,6 +2,10 @@ package cx.aswin.boxlore.feature.settings.pages import android.content.Context import android.widget.Toast +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -9,6 +13,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import cx.aswin.boxlore.core.auth.AuthRepository import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight import kotlinx.coroutines.CoroutineScope @@ -61,11 +67,13 @@ internal fun AccountSettingsDialogs( onConfirmDelete: () -> Unit, onDismissReauthRequired: () -> Unit, onSignOutToReauth: () -> Unit, + onOpenAccountDeletionInfo: () -> Unit = {}, ) { if (showDeleteConfirmation) { DeleteAccountConfirmationDialog( onDismiss = onDismissDeleteConfirmation, onConfirmDelete = onConfirmDelete, + onOpenAccountDeletionInfo = onOpenAccountDeletionInfo, ) } @@ -81,16 +89,27 @@ internal fun AccountSettingsDialogs( private fun DeleteAccountConfirmationDialog( onDismiss: () -> Unit, onConfirmDelete: () -> Unit, + onOpenAccountDeletionInfo: () -> Unit = {}, ) { AlertDialog( onDismissRequest = onDismiss, title = { Text("Delete Account", fontWeight = GoogleSansWeight.bold) }, text = { - Text( - "Are you sure you want to delete your boxlore account? " + - "This permanently removes your cloud profile and cross-device sync data. " + - "Your local downloads and podcast catalog on this device will not be erased.", - ) + Column { + Text( + "Are you sure you want to delete your boxlore account? " + + "This permanently removes your cloud profile and cross-device sync data. " + + "Your local downloads and podcast catalog on this device will not be erased.", + ) + Spacer(Modifier.height(12.dp)) + Text( + text = "Learn how your data is handled after deletion", + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.bodyMedium, + fontWeight = GoogleSansWeight.medium, + modifier = Modifier.clickable(onClick = onOpenAccountDeletionInfo), + ) + } }, confirmButton = { Button( diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AccountSettingsPage.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AccountSettingsPage.kt index 62c7255b1..4ce5e08fa 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AccountSettingsPage.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AccountSettingsPage.kt @@ -1,8 +1,11 @@ package cx.aswin.boxlore.feature.settings.pages import android.content.Context +import android.content.Intent +import android.net.Uri import android.widget.Toast import androidx.compose.foundation.Image +import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -24,6 +27,7 @@ import androidx.compose.material.icons.automirrored.rounded.Logout import androidx.compose.material.icons.rounded.CheckCircle import androidx.compose.material.icons.rounded.DeleteForever import androidx.compose.material.icons.rounded.Email +import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.Key import androidx.compose.material.icons.rounded.Lock import androidx.compose.material3.Button @@ -46,6 +50,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -62,6 +68,49 @@ import kotlinx.coroutines.launch private const val PROVIDER_GOOGLE = "google.com" private const val PROVIDER_EMAIL_LINK = "emailLink" +private const val ACCOUNT_DELETION_URL = "https://aswin.cx/boxlore/account-deletion/" + +@Composable +private fun AccountStateEffects( + currentUser: BoxLoreUser?, + authState: AccountAuthState, + context: Context, + scrollState: ScrollState, +) { + var previousUser by remember { mutableStateOf(null) } + LaunchedEffect(currentUser) { + val user = currentUser + val prev = previousUser + previousUser = user + if (prev != null && user == null) { + authState.resetToNewEmail() + } else if (user?.isEmailVerified == true) { + authState.isAwaitingVerification = false + AccountVerificationStorage.setPref(context, false) + } + } + + LaunchedEffect(authState.isAwaitingVerification, authState.magicLinkSent) { + if (authState.isAwaitingVerification || authState.magicLinkSent) { + scrollState.animateScrollTo(0) + } + } +} + +private fun openAccountDeletionInfo(context: Context, uriHandler: UriHandler) { + val opened = runCatching { + uriHandler.openUri(ACCOUNT_DELETION_URL) + true + }.getOrDefault(false) + if (!opened) { + runCatching { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(ACCOUNT_DELETION_URL)).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } + } +} @Composable internal fun AccountSettingsPage( @@ -72,8 +121,10 @@ internal fun AccountSettingsPage( isOnboarding: Boolean = false, ) { val context = LocalContext.current + val uriHandler = LocalUriHandler.current val scope = rememberCoroutineScope() val scrollState = rememberScrollState() + val onOpenAccountDeletionInfo = { openAccountDeletionInfo(context, uriHandler) } val currentUser by ( authRepository?.currentUser?.collectAsState() ?: remember { mutableStateOf(null) } @@ -88,24 +139,12 @@ internal fun AccountSettingsPage( scope = scope, ) - var previousUser by remember { mutableStateOf(null) } - LaunchedEffect(currentUser) { - val user = currentUser - val prev = previousUser - previousUser = user - if (prev != null && user == null) { - authState.resetToNewEmail() - } else if (user?.isEmailVerified == true) { - authState.isAwaitingVerification = false - AccountVerificationStorage.setPref(context, false) - } - } - - LaunchedEffect(authState.isAwaitingVerification, authState.magicLinkSent) { - if (authState.isAwaitingVerification || authState.magicLinkSent) { - scrollState.animateScrollTo(0) - } - } + AccountStateEffects( + currentUser = currentUser, + authState = authState, + context = context, + scrollState = scrollState, + ) SettingsScaffold( title = "Account", @@ -127,6 +166,7 @@ internal fun AccountSettingsPage( handleSignOut(authRepository, authState, context) }, onDeleteAccountClick = { showDeleteConfirmation = true }, + onOpenAccountDeletionInfo = onOpenAccountDeletionInfo, onSyncNow = onSyncNow, onContinueToHome = onBack, ), @@ -158,6 +198,7 @@ internal fun AccountSettingsPage( showReauthRequiredDialog = false handleSignOut(authRepository, authState, context) }, + onOpenAccountDeletionInfo = onOpenAccountDeletionInfo, ) } @@ -201,6 +242,7 @@ private data class SignedInActions( val onResetPassword: () -> Unit, val onSignOut: () -> Unit, val onDeleteAccountClick: () -> Unit, + val onOpenAccountDeletionInfo: () -> Unit, val onSyncNow: () -> Unit, val onContinueToHome: () -> Unit = {}, ) @@ -225,6 +267,7 @@ private fun ColumnScope.SignedInContent( onResetPassword = actions.onResetPassword, onSignOut = actions.onSignOut, onDeleteAccountClick = actions.onDeleteAccountClick, + onOpenAccountDeletionInfo = actions.onOpenAccountDeletionInfo, ) } @@ -466,6 +509,7 @@ private fun AccountManagementGroup( onResetPassword: () -> Unit, onSignOut: () -> Unit, onDeleteAccountClick: () -> Unit, + onOpenAccountDeletionInfo: () -> Unit, ) { val isPasswordAccount = user.providerId != PROVIDER_GOOGLE && !user.email.isNullOrBlank() SettingsGroup(title = "Account Management") { @@ -492,5 +536,12 @@ private fun AccountManagementGroup( destructive = true, onClick = onDeleteAccountClick, ) + SettingsDivider() + SettingsActionRow( + title = "Account & Data Deletion Info", + supportingText = "Learn how your data is handled after deletion or request partial data removal", + icon = Icons.Rounded.Info, + onClick = onOpenAccountDeletionInfo, + ) } } From 0d3722057e2c030939ae6f79c86e90b29b546992 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Sat, 26 Sep 2026 20:41:12 +0530 Subject: [PATCH 14/21] feat(settings): extract cloud sync and backups into dedicated settings destination --- feature/settings/README.md | 5 +- .../settings/ProfileSettingsDestination.kt | 1 + .../feature/settings/SettingsScreen.kt | 21 +- .../settings/pages/LibrarySettingsPage.kt | 259 ---------------- .../feature/settings/pages/SettingsHub.kt | 15 +- .../settings/pages/SyncAndBackupsPage.kt | 278 ++++++++++++++++++ .../settings/SettingsBackNavigationTest.kt | 24 ++ 7 files changed, 332 insertions(+), 271 deletions(-) create mode 100644 feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/SyncAndBackupsPage.kt diff --git a/feature/settings/README.md b/feature/settings/README.md index c4cc4a5e5..63663fbf9 100644 --- a/feature/settings/README.md +++ b/feature/settings/README.md @@ -2,7 +2,7 @@ ## Purpose -Owns the unified Settings hub, category pages (Account, Appearance, Playback, Privacy, Library, Downloads, About), dialogs (Accent color, Add RSS, Reset analytics), authentication credential management (Google One Tap, Magic Link, Password), and download preference screens (`AutoDownloadSettingsScreen`, `SmartDownloadsSettingsScreen`). It presents data from injected core dependencies and does not own catalog engines, ranking persistence, playback services, download workers, or Room schemas. +Owns the unified Settings hub, category pages (Account, Sync & Backups, Appearance, Playback, Privacy, Library, Downloads, About), dialogs (Accent color, Add RSS, Reset analytics), authentication credential management (Google One Tap, Magic Link, Password), and download preference screens (`AutoDownloadSettingsScreen`, `SmartDownloadsSettingsScreen`). It presents data from injected core dependencies and does not own catalog engines, ranking persistence, playback services, download workers, or Room schemas. ## Public API @@ -10,7 +10,7 @@ Owns the unified Settings hub, category pages (Account, Appearance, Playback, Pr - `SettingsScreenConfig` includes `isOnboarding: Boolean` (used by app navigation to keep onboarding mode active and bypass the hub on return), `onSendFeedback: (() -> Unit)?` to route to feedback, and `resolveSettingsBackAction` for deterministic back-handler actions. - `FeedbackScreen`, `FeedbackViewModel`, `DiagnosticCollector`, and `LogcatCollector` under `feedback/` for sticky draft feedback submissions, in-process sanitized logcat extraction, device diagnostics, and GitHub issue reporting. - `AutoDownloadSettingsScreen` and `SmartDownloadsSettingsScreen` under `downloads/` for download management. -- `AccountSettingsPage`, `AppearanceSettingsPage`, `PlaybackSettingsPage`, `PrivacySettingsPage`, `LibrarySettingsPage`, `DownloadsSettingsPage`, `AboutSettingsPage`. +- `AccountSettingsPage`, `SyncAndBackupsPage`, `AppearanceSettingsPage`, `PlaybackSettingsPage`, `PrivacySettingsPage`, `LibrarySettingsPage`, `DownloadsSettingsPage`, `AboutSettingsPage`. - Dialogs: `AccentColorPickerDialog`, `AddRssFeedDialog`, `ResetAnalyticsDialog`, `LogsPreviewDialog`. ## Internal structure @@ -53,6 +53,7 @@ src/main/java/cx/aswin/boxlore/feature/settings/ PlaybackSettingsPage.kt PrivacySettingsPage.kt SettingsHub.kt + SyncAndBackupsPage.kt ``` ## Dependencies diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/ProfileSettingsDestination.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/ProfileSettingsDestination.kt index 395f7a806..fde67aa54 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/ProfileSettingsDestination.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/ProfileSettingsDestination.kt @@ -5,6 +5,7 @@ internal enum class ProfileSettingsDestination( ) { Hub("Settings"), Account("Account"), + SyncAndBackups("Cloud Sync & Backups"), Library("Library"), Appearance("Appearance"), Playback("Playback"), diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsScreen.kt index 29bc5b71b..2bfeb33f1 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/SettingsScreen.kt @@ -53,6 +53,7 @@ import cx.aswin.boxlore.feature.settings.pages.PlaybackUiState import cx.aswin.boxlore.feature.settings.pages.PrivacySettingsActions import cx.aswin.boxlore.feature.settings.pages.PrivacySettingsPage import cx.aswin.boxlore.feature.settings.pages.SettingsHub +import cx.aswin.boxlore.feature.settings.pages.SyncAndBackupsPage import kotlinx.coroutines.flow.StateFlow /** Where to send the user for the two Downloads settings sub-screens. */ @@ -243,8 +244,8 @@ fun SettingsScreen( val actions = SettingsPagesActions( onNavigate = { destination = it }, onReturnToHub = returnToHub, - onNavigateToAccountFromLibrary = { - previousDestination = ProfileSettingsDestination.Library + onNavigateToAccountFromSync = { + previousDestination = ProfileSettingsDestination.SyncAndBackups destination = ProfileSettingsDestination.Account }, onDeletionExpandedChange = { isDeletionExpanded = it }, @@ -302,7 +303,7 @@ internal data class SettingsPagesUiData( internal data class SettingsPagesActions( val onNavigate: (ProfileSettingsDestination) -> Unit, val onReturnToHub: () -> Unit, - val onNavigateToAccountFromLibrary: () -> Unit, + val onNavigateToAccountFromSync: () -> Unit, val onDeletionExpandedChange: (Boolean) -> Unit, val onShowResetDialog: () -> Unit, val backupActions: LibraryBackupActions, @@ -344,6 +345,15 @@ private fun SettingsAnimatedPages( ) } + ProfileSettingsDestination.SyncAndBackups -> + SyncAndBackupsPage( + accountStatus = uiData.accountStatus, + syncStatus = syncStatus, + backupActions = actions.backupActions, + onBack = actions.onReturnToHub, + onAccountClick = actions.onNavigateToAccountFromSync, + ) + ProfileSettingsDestination.Library -> LibrarySettingsPage( discoveryPreferences = LibraryDiscoveryPreferences( @@ -362,11 +372,7 @@ private fun SettingsAnimatedPages( }, ), onAddRssClick = { contentBundle.settingsViewModel.openAddRssDialog() }, - backupActions = actions.backupActions, onBack = actions.onReturnToHub, - onAccountClick = actions.onNavigateToAccountFromLibrary, - accountStatus = uiData.accountStatus, - syncStatus = syncStatus, ) ProfileSettingsDestination.Appearance -> @@ -640,6 +646,7 @@ private fun openChangelog(context: Context) { internal fun String?.toSettingsDestination(): ProfileSettingsDestination = when (this?.trim()?.lowercase()) { "account" -> ProfileSettingsDestination.Account + "sync", "sync_and_backups", "sync-and-backups", "backups" -> ProfileSettingsDestination.SyncAndBackups "library" -> ProfileSettingsDestination.Library "appearance" -> ProfileSettingsDestination.Appearance "playback" -> ProfileSettingsDestination.Playback diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/LibrarySettingsPage.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/LibrarySettingsPage.kt index 8254a893e..d3e69c4ab 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/LibrarySettingsPage.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/LibrarySettingsPage.kt @@ -5,30 +5,18 @@ import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.ArrowForward -import androidx.compose.material.icons.rounded.CloudDone -import androidx.compose.material.icons.rounded.CloudOff -import androidx.compose.material.icons.rounded.CloudSync -import androidx.compose.material.icons.rounded.FileDownload -import androidx.compose.material.icons.rounded.FileUpload import androidx.compose.material.icons.rounded.KeyboardArrowDown import androidx.compose.material.icons.rounded.KeyboardArrowUp import androidx.compose.material.icons.rounded.RssFeed import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -37,10 +25,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp -import cx.aswin.boxlore.core.catalog.sync.CloudSyncUiStatus import cx.aswin.boxlore.core.designsystem.components.ContentRegionLanguagePicker import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight import cx.aswin.boxlore.core.designsystem.theme.expressiveClickable @@ -60,26 +45,11 @@ data class LibraryDiscoveryPreferences( val onSetContentLanguages: (List) -> Unit, ) -/** - * Export/import callbacks for [LibrarySettingsPage], grouped to keep its parameter count small. - * Also used by [cx.aswin.boxlore.feature.settings.SettingsScreen]. - */ -data class LibraryBackupActions( - val onExportJson: () -> Unit, - val onExportOpml: () -> Unit, - val onImportJson: () -> Unit, - val onImportOpml: () -> Unit, -) - @Composable internal fun LibrarySettingsPage( discoveryPreferences: LibraryDiscoveryPreferences, onAddRssClick: () -> Unit, - backupActions: LibraryBackupActions, onBack: () -> Unit, - onAccountClick: () -> Unit, - accountStatus: String? = null, - syncStatus: CloudSyncUiStatus = CloudSyncUiStatus.Idle, ) { var isCountryFaqExpanded by rememberSaveable { mutableStateOf(false) } val collapseCountryFaq = { isCountryFaqExpanded = false } @@ -89,14 +59,6 @@ internal fun LibrarySettingsPage( onBack = onBack, onUnconsumedTap = if (isCountryFaqExpanded) collapseCountryFaq else null, ) { - LibraryAccountSyncGroup( - accountStatus = accountStatus, - syncStatus = syncStatus, - onAccountClick = { - collapseCountryFaq() - onAccountClick() - }, - ) SettingsGroup(title = "Discovery") { SettingsContent { ContentRegionLanguagePicker( @@ -131,227 +93,6 @@ internal fun LibrarySettingsPage( }, ) } - - LibraryBackupGroups( - backupActions = backupActions, - onAction = collapseCountryFaq, - ) - } -} - -@Composable -private fun LibraryAccountSyncGroup( - accountStatus: String?, - syncStatus: CloudSyncUiStatus, - onAccountClick: () -> Unit, -) { - SettingsGroup(title = "Account & Cloud Sync") { - LibraryAccountSyncButton( - accountStatus = accountStatus, - syncStatus = syncStatus, - onClick = onAccountClick, - ) - } -} - -private data class LibrarySyncVisualState( - val icon: ImageVector, - val iconTint: Color, - val iconBackground: Color, -) - -@Composable -private fun resolveLibrarySyncVisual( - isSignedIn: Boolean, - syncStatus: CloudSyncUiStatus, -): LibrarySyncVisualState { - val isSyncError = syncStatus is CloudSyncUiStatus.Error - val isSyncing = syncStatus is CloudSyncUiStatus.Syncing - - return when { - !isSignedIn || isSyncing -> LibrarySyncVisualState( - icon = Icons.Rounded.CloudSync, - iconTint = MaterialTheme.colorScheme.primary, - iconBackground = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), - ) - isSyncError -> LibrarySyncVisualState( - icon = Icons.Rounded.CloudOff, - iconTint = MaterialTheme.colorScheme.error, - iconBackground = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.6f), - ) - else -> LibrarySyncVisualState( - icon = Icons.Rounded.CloudDone, - iconTint = MaterialTheme.colorScheme.primary, - iconBackground = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), - ) - } -} - -@Composable -private fun LibrarySyncStatusBadge( - syncStatus: CloudSyncUiStatus, - modifier: Modifier = Modifier, -) { - val isSyncError = syncStatus is CloudSyncUiStatus.Error - val isSyncing = syncStatus is CloudSyncUiStatus.Syncing - - val statusDotColor = if (isSyncError) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.primary - } - val statusText = when { - isSyncError -> "Sync issue" - isSyncing -> "Syncing..." - else -> "Cloud sync active" - } - - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(5.dp), - ) { - Box( - modifier = Modifier - .size(6.dp) - .background(statusDotColor, CircleShape), - ) - Text( - text = statusText, - style = MaterialTheme.typography.labelSmall, - color = statusDotColor, - fontWeight = GoogleSansWeight.medium, - ) - } -} - -@Composable -private fun LibraryAccountSyncButton( - accountStatus: String?, - syncStatus: CloudSyncUiStatus, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val isSignedIn = accountStatus != null - val visual = resolveLibrarySyncVisual(isSignedIn, syncStatus) - - Row( - modifier = modifier - .fillMaxWidth() - .expressiveClickable( - shape = MaterialTheme.shapes.large, - onClick = onClick, - ) - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(14.dp), - ) { - Surface( - modifier = Modifier.size(44.dp), - shape = MaterialTheme.shapes.medium, - color = visual.iconBackground, - ) { - Box(contentAlignment = Alignment.Center) { - Icon( - imageVector = visual.icon, - contentDescription = null, - tint = visual.iconTint, - modifier = Modifier.size(24.dp), - ) - } - } - - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(3.dp), - ) { - Text( - text = "Cloud sync with account", - style = MaterialTheme.typography.titleMedium, - fontWeight = GoogleSansWeight.bold, - color = MaterialTheme.colorScheme.onSurface, - ) - - Text( - text = if (isSignedIn) { - "Signed in as $accountStatus" - } else { - "Sign in to backup and sync across devices" - }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - if (isSignedIn) { - Spacer(Modifier.height(1.dp)) - LibrarySyncStatusBadge(syncStatus = syncStatus) - } - } - - Surface( - shape = CircleShape, - color = MaterialTheme.colorScheme.surfaceContainerHighest, - modifier = Modifier.size(32.dp), - ) { - Box(contentAlignment = Alignment.Center) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.ArrowForward, - contentDescription = "Open account settings", - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } -} - -@Composable -private fun LibraryBackupGroups( - backupActions: LibraryBackupActions, - onAction: () -> Unit, -) { - SettingsGroup(title = "Export") { - SettingsNavigationRow( - title = "Full library backup", - supportingText = "Subscriptions, history, likes, settings, and catalog shows with Missing episodes? (JSON)", - icon = Icons.Rounded.FileUpload, - onClick = { - onAction() - backupActions.onExportJson() - }, - ) - SettingsDivider() - SettingsNavigationRow( - title = "Subscriptions only", - supportingText = "OPML file", - icon = Icons.Rounded.FileUpload, - onClick = { - onAction() - backupActions.onExportOpml() - }, - ) - } - - SettingsGroup(title = "Import") { - SettingsNavigationRow( - title = "Full library backup", - supportingText = "Restores Missing episodes? opt-ins and refreshes those shows", - icon = Icons.Rounded.FileDownload, - onClick = { - onAction() - backupActions.onImportJson() - }, - ) - SettingsDivider() - SettingsNavigationRow( - title = "Subscriptions only", - supportingText = "Import from an OPML file", - icon = Icons.Rounded.FileDownload, - onClick = { - onAction() - backupActions.onImportOpml() - }, - ) } } diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/SettingsHub.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/SettingsHub.kt index d27ee1de6..76a97ac1b 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/SettingsHub.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/SettingsHub.kt @@ -2,6 +2,7 @@ package cx.aswin.boxlore.feature.settings.pages import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.LibraryBooks +import androidx.compose.material.icons.rounded.CloudSync import androidx.compose.material.icons.rounded.DownloadForOffline import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.Palette @@ -23,11 +24,19 @@ internal fun SettingsHub( onBack = onBack, ) { SettingsCategoryCard( - title = "Library & Account", - description = "Cloud sync, region, RSS feeds, import and export", - icon = Icons.AutoMirrored.Rounded.LibraryBooks, + title = "Cloud Sync & Backups", + description = "Account sync, OPML feeds, and library backups", + icon = Icons.Rounded.CloudSync, containerColor = MaterialTheme.colorScheme.primaryContainer, contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + onClick = { onNavigate(ProfileSettingsDestination.SyncAndBackups) }, + ) + SettingsCategoryCard( + title = "Library", + description = "Discovery region, content languages, and RSS feeds", + icon = Icons.AutoMirrored.Rounded.LibraryBooks, + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, onClick = { onNavigate(ProfileSettingsDestination.Library) }, ) SettingsCategoryCard( diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/SyncAndBackupsPage.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/SyncAndBackupsPage.kt new file mode 100644 index 000000000..7bdd9259b --- /dev/null +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/SyncAndBackupsPage.kt @@ -0,0 +1,278 @@ +package cx.aswin.boxlore.feature.settings.pages + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowForward +import androidx.compose.material.icons.rounded.CloudDone +import androidx.compose.material.icons.rounded.CloudOff +import androidx.compose.material.icons.rounded.CloudSync +import androidx.compose.material.icons.rounded.FileDownload +import androidx.compose.material.icons.rounded.FileUpload +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import cx.aswin.boxlore.core.catalog.sync.CloudSyncUiStatus +import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight +import cx.aswin.boxlore.core.designsystem.theme.expressiveClickable +import cx.aswin.boxlore.feature.settings.components.SettingsDivider +import cx.aswin.boxlore.feature.settings.components.SettingsGroup +import cx.aswin.boxlore.feature.settings.components.SettingsNavigationRow +import cx.aswin.boxlore.feature.settings.components.SettingsScaffold + +/** + * Export/import callbacks for [SyncAndBackupsPage], grouped to keep its parameter count small. + * Also used by [cx.aswin.boxlore.feature.settings.SettingsScreen]. + */ +data class LibraryBackupActions( + val onExportJson: () -> Unit, + val onExportOpml: () -> Unit, + val onImportJson: () -> Unit, + val onImportOpml: () -> Unit, +) + +@Composable +internal fun SyncAndBackupsPage( + accountStatus: String?, + syncStatus: CloudSyncUiStatus, + backupActions: LibraryBackupActions, + onBack: () -> Unit, + onAccountClick: () -> Unit, +) { + SettingsScaffold( + title = "Cloud Sync & Backups", + onBack = onBack, + ) { + SyncAccountSection( + accountStatus = accountStatus, + syncStatus = syncStatus, + onAccountClick = onAccountClick, + ) + SyncBackupExportGroup(backupActions = backupActions) + SyncBackupImportGroup(backupActions = backupActions) + } +} + +@Composable +private fun SyncAccountSection( + accountStatus: String?, + syncStatus: CloudSyncUiStatus, + onAccountClick: () -> Unit, +) { + SettingsGroup(title = "Cloud Sync") { + CloudSyncCard( + accountStatus = accountStatus, + syncStatus = syncStatus, + onClick = onAccountClick, + ) + } +} + +private data class SyncVisualState( + val icon: ImageVector, + val iconTint: Color, + val iconBackground: Color, +) + +@Composable +private fun resolveSyncVisual( + isSignedIn: Boolean, + syncStatus: CloudSyncUiStatus, +): SyncVisualState { + val isSyncError = syncStatus is CloudSyncUiStatus.Error + val isSyncing = syncStatus is CloudSyncUiStatus.Syncing + + return when { + !isSignedIn || isSyncing -> SyncVisualState( + icon = Icons.Rounded.CloudSync, + iconTint = MaterialTheme.colorScheme.primary, + iconBackground = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), + ) + isSyncError -> SyncVisualState( + icon = Icons.Rounded.CloudOff, + iconTint = MaterialTheme.colorScheme.error, + iconBackground = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.6f), + ) + else -> SyncVisualState( + icon = Icons.Rounded.CloudDone, + iconTint = MaterialTheme.colorScheme.primary, + iconBackground = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), + ) + } +} + +@Composable +private fun SyncStatusBadge( + syncStatus: CloudSyncUiStatus, + modifier: Modifier = Modifier, +) { + val isSyncError = syncStatus is CloudSyncUiStatus.Error + val isSyncing = syncStatus is CloudSyncUiStatus.Syncing + + val statusDotColor = if (isSyncError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + } + val statusText = when { + isSyncError -> "Sync issue" + isSyncing -> "Syncing..." + else -> "Cloud sync active" + } + + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(5.dp), + ) { + Box( + modifier = Modifier + .size(6.dp) + .background(statusDotColor, CircleShape), + ) + Text( + text = statusText, + style = MaterialTheme.typography.labelSmall, + color = statusDotColor, + fontWeight = GoogleSansWeight.medium, + ) + } +} + +@Composable +private fun CloudSyncCard( + accountStatus: String?, + syncStatus: CloudSyncUiStatus, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val isSignedIn = accountStatus != null + val visual = resolveSyncVisual(isSignedIn, syncStatus) + + Row( + modifier = modifier + .fillMaxWidth() + .expressiveClickable( + shape = MaterialTheme.shapes.large, + onClick = onClick, + ) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + Surface( + modifier = Modifier.size(44.dp), + shape = MaterialTheme.shapes.medium, + color = visual.iconBackground, + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = visual.icon, + contentDescription = null, + tint = visual.iconTint, + modifier = Modifier.size(24.dp), + ) + } + } + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Text( + text = "Cloud sync", + style = MaterialTheme.typography.titleMedium, + fontWeight = GoogleSansWeight.bold, + color = MaterialTheme.colorScheme.onSurface, + ) + + Text( + text = if (isSignedIn) { + "Signed in as $accountStatus" + } else { + "Sign in to backup and sync across devices" + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + if (isSignedIn) { + Spacer(Modifier.height(1.dp)) + SyncStatusBadge(syncStatus = syncStatus) + } + } + + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceContainerHighest, + modifier = Modifier.size(32.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowForward, + contentDescription = "Open account settings", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun SyncBackupExportGroup( + backupActions: LibraryBackupActions, +) { + SettingsGroup(title = "Export") { + SettingsNavigationRow( + title = "Full library backup", + supportingText = "Subscriptions, history, likes, settings, and catalog shows with Missing episodes? (JSON)", + icon = Icons.Rounded.FileUpload, + onClick = backupActions.onExportJson, + ) + SettingsDivider() + SettingsNavigationRow( + title = "Subscriptions only", + supportingText = "OPML file", + icon = Icons.Rounded.FileUpload, + onClick = backupActions.onExportOpml, + ) + } +} + +@Composable +private fun SyncBackupImportGroup( + backupActions: LibraryBackupActions, +) { + SettingsGroup(title = "Import") { + SettingsNavigationRow( + title = "Full library backup", + supportingText = "Restores Missing episodes? opt-ins and refreshes those shows", + icon = Icons.Rounded.FileDownload, + onClick = backupActions.onImportJson, + ) + SettingsDivider() + SettingsNavigationRow( + title = "Subscriptions only", + supportingText = "Import from an OPML file", + icon = Icons.Rounded.FileDownload, + onClick = backupActions.onImportOpml, + ) + } +} diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/SettingsBackNavigationTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/SettingsBackNavigationTest.kt index 18eb96732..4c8f28874 100644 --- a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/SettingsBackNavigationTest.kt +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/SettingsBackNavigationTest.kt @@ -66,4 +66,28 @@ class SettingsBackNavigationTest { actionNull, ) } + + @Test + fun whenNotOnboarding_withSyncAndBackupsPreviousDestination_navigatesToSyncAndBackups() { + val action = resolveSettingsBackAction( + isOnboarding = false, + previousDestination = ProfileSettingsDestination.SyncAndBackups, + initialPage = null, + ) + assertEquals( + SettingsBackAction.NavigateTo(ProfileSettingsDestination.SyncAndBackups), + action, + ) + } + + @Test + fun toSettingsDestination_resolvesSyncAndBackupsVariants() { + assertEquals(ProfileSettingsDestination.SyncAndBackups, "sync".toSettingsDestination()) + assertEquals(ProfileSettingsDestination.SyncAndBackups, "sync_and_backups".toSettingsDestination()) + assertEquals(ProfileSettingsDestination.SyncAndBackups, "sync-and-backups".toSettingsDestination()) + assertEquals(ProfileSettingsDestination.SyncAndBackups, "backups".toSettingsDestination()) + assertEquals(ProfileSettingsDestination.Library, "library".toSettingsDestination()) + assertEquals(ProfileSettingsDestination.Account, "account".toSettingsDestination()) + assertEquals(ProfileSettingsDestination.Hub, "unknown".toSettingsDestination()) + } } From add27a8a99b3b2a58219a4e0dca604962208cd92 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Sat, 26 Sep 2026 20:55:21 +0530 Subject: [PATCH 15/21] feat(settings): overhaul avatar with dual spotlights, genre moods, and spring physics --- feature/settings/README.md | 2 + .../settings/pages/AccountSettingsPage.kt | 2 +- .../settings/pages/AnimatedBlobAvatar.kt | 537 ++++++++++++------ .../settings/pages/BlobAvatarGenreMood.kt | 90 +++ .../settings/pages/BlobAvatarGeometry.kt | 34 +- .../settings/pages/AnimatedBlobAvatarTest.kt | 55 +- 6 files changed, 520 insertions(+), 200 deletions(-) create mode 100644 feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarGenreMood.kt diff --git a/feature/settings/README.md b/feature/settings/README.md index 63663fbf9..4b7fc4555 100644 --- a/feature/settings/README.md +++ b/feature/settings/README.md @@ -46,6 +46,8 @@ src/main/java/cx/aswin/boxlore/feature/settings/ AccountSettingsPage.kt AccountSignedOutContent.kt AnimatedBlobAvatar.kt + BlobAvatarGeometry.kt + BlobAvatarGenreMood.kt AppearanceSettingsPage.kt DownloadsSettingsPage.kt EmailVerificationPendingSection.kt diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AccountSettingsPage.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AccountSettingsPage.kt index 4ce5e08fa..04a44d3d2 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AccountSettingsPage.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AccountSettingsPage.kt @@ -469,7 +469,7 @@ private fun UserProfileCard( horizontalAlignment = Alignment.CenterHorizontally, ) { AnimatedBlobAvatar( - size = 76.dp, + size = 92.dp, ) Spacer(Modifier.height(14.dp)) diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatar.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatar.kt index 3b07c46a3..5e62b67c6 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatar.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatar.kt @@ -1,19 +1,29 @@ package cx.aswin.boxlore.feature.settings.pages +import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.keyframes import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.CornerRadius @@ -28,9 +38,16 @@ import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.graphics.drawscope.scale import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import kotlin.math.sin +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch internal data class BlobAvatarAnimationState( val floatProgress: State, @@ -40,13 +57,15 @@ internal data class BlobAvatarAnimationState( val headBobAngle: State, val pupilLookRatioX: State, val pupilLookRatioY: State, + val soundwaveProgress: State, + val particleProgress: State, + val bassPulseScale: State, ) @Composable internal fun rememberBlobAvatarAnimationState(): BlobAvatarAnimationState { val infiniteTransition = rememberInfiniteTransition(label = "BlobAvatarTransition") - // Gentle, calming float val floatProgress = infiniteTransition.animateFloat( initialValue = -1f, targetValue = 1f, @@ -57,7 +76,6 @@ internal fun rememberBlobAvatarAnimationState(): BlobAvatarAnimationState { label = "BlobFloatProgress", ) - // Soft squishy breathing val breatheScaleX = infiniteTransition.animateFloat( initialValue = 0.985f, targetValue = 1.015f, @@ -77,7 +95,6 @@ internal fun rememberBlobAvatarAnimationState(): BlobAvatarAnimationState { label = "BlobBreatheY", ) - // Sweet subtle head groove to the music val headBobAngle = infiniteTransition.animateFloat( initialValue = -2.5f, targetValue = 2.5f, @@ -88,7 +105,6 @@ internal fun rememberBlobAvatarAnimationState(): BlobAvatarAnimationState { label = "BlobHeadBob", ) - // Natural, cute blink with double-blink val eyeOpenScale = infiniteTransition.animateFloat( initialValue = 1f, targetValue = 1f, @@ -108,6 +124,36 @@ internal fun rememberBlobAvatarAnimationState(): BlobAvatarAnimationState { label = "BlobEyeOpen", ) + val soundwaveProgress = infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1800, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "BlobSoundwave", + ) + + val particleProgress = infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 3200, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "BlobParticles", + ) + + val bassPulseScale = infiniteTransition.animateFloat( + initialValue = 1.0f, + targetValue = 1.05f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 700, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "BlobBassPulse", + ) + val (pupilLookRatioX, pupilLookRatioY) = rememberPupilLookAnimations(infiniteTransition) return BlobAvatarAnimationState( @@ -118,6 +164,9 @@ internal fun rememberBlobAvatarAnimationState(): BlobAvatarAnimationState { headBobAngle = headBobAngle, pupilLookRatioX = pupilLookRatioX, pupilLookRatioY = pupilLookRatioY, + soundwaveProgress = soundwaveProgress, + particleProgress = particleProgress, + bassPulseScale = bassPulseScale, ) } @@ -166,52 +215,117 @@ private fun rememberPupilLookAnimations( return Pair(pupilLookRatioX, pupilLookRatioY) } +internal data class BlobAvatarPalette( + val bodyBase: Color, + val bodyShadow: Color, + val bodyBounce: Color, + val facialColor: Color, + val blushColor: Color, + val headbandColor: Color, + val cushionColor: Color, + val headphoneCup: Color, + val headphoneAccent: Color, + val shadowAlpha: Float, +) + +@Composable +internal fun resolveAvatarPalette( + genreMood: BlobAvatarGenreMood, + isDark: Boolean, +): BlobAvatarPalette { + val primaryColor = MaterialTheme.colorScheme.primary + + val bodyBase = if (isDark) { + Color(0xFFF1F5F9) + } else { + Color(0xFFFFFDF8) + } + + val bodyShadow = if (isDark) { + Color(0xFFCBD5E1) + } else { + Color(0xFFE2E8F0) + } + + val bodyBounce = if (isDark) { + genreMood.rimLightColor.copy(alpha = 0.38f) + } else { + primaryColor.copy(alpha = 0.20f) + } + + val facialColor = Color(0xFF0F172A) + val blushColor = Color(0xFFFF5277).copy(alpha = if (isDark) 0.50f else 0.40f) + + val headbandColor = if (isDark) Color(0xFF1E293B) else Color(0xFF334155) + val cushionColor = if (isDark) Color(0xFF0F172A) else Color(0xFF1E293B) + val headphoneCup = genreMood.keyLightColor + val headphoneAccent = genreMood.rimLightColor + val shadowAlpha = if (isDark) 0.22f else 0.18f + + return BlobAvatarPalette( + bodyBase = bodyBase, + bodyShadow = bodyShadow, + bodyBounce = bodyBounce, + facialColor = facialColor, + blushColor = blushColor, + headbandColor = headbandColor, + cushionColor = cushionColor, + headphoneCup = headphoneCup, + headphoneAccent = headphoneAccent, + shadowAlpha = shadowAlpha, + ) +} + /** - * An ultra-cute, 3D clay-shaded mochi blob wearing cozy studio headphones. - * Features warm rosy cheeks, glossy kawaii button eyes with bright catchlights, - * a sweet delicate smile, and a gentle music groove. + * An ultra-expressive, tactile 3D mochi mascot avatar with dual studio spotlights, + * concentric headphone soundwaves, spring-physics squish on tap, and podcast genre moods. */ @Composable internal fun AnimatedBlobAvatar( modifier: Modifier = Modifier, - size: Dp = 82.dp, + size: Dp = 88.dp, + initialMood: BlobAvatarGenreMood? = null, ) { val animState = rememberBlobAvatarAnimationState() - val isDark = MaterialTheme.colorScheme.surface.luminance() < 0.5f - val bodyBaseColor = if (isDark) { - MaterialTheme.colorScheme.primary.copy(alpha = 0.95f) - } else { - MaterialTheme.colorScheme.primaryContainer - } - val bodyShadowColor = if (isDark) { - MaterialTheme.colorScheme.primary.copy(alpha = 0.70f) - } else { - MaterialTheme.colorScheme.primary.copy(alpha = 0.25f) + var activeMood by remember { + mutableStateOf(initialMood ?: BlobAvatarGenreMood.random()) } - val bodyBounceColor = if (isDark) { - Color.White.copy(alpha = 0.22f) - } else { - MaterialTheme.colorScheme.primary.copy(alpha = 0.25f) - } - - val facialColor = Color(0xFF1E222A) - val blushColor = if (isDark) { - Color(0xFFFF7597).copy(alpha = 0.45f) - } else { - Color(0xFFFF7B95).copy(alpha = 0.38f) + val palette = resolveAvatarPalette(activeMood, isDark) + val haptic = LocalHapticFeedback.current + val coroutineScope = rememberCoroutineScope() + + val squishScaleY = remember { Animatable(1f) } + var isWinking by remember { mutableStateOf(false) } + + val onTapAvatar = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + activeMood = BlobAvatarGenreMood.next(activeMood) + isWinking = true + coroutineScope.launch { + squishScaleY.snapTo(0.80f) + squishScaleY.animateTo( + targetValue = 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow, + ), + ) + } + coroutineScope.launch { + delay(650) + isWinking = false + } } - val headbandColor = if (isDark) Color(0xFF282C34) else Color(0xFF3C4048) - val cushionColor = if (isDark) Color(0xFF1C1F26) else Color(0xFF2E323A) - val headphoneCupColor = MaterialTheme.colorScheme.tertiary - val headphoneAccentColor = if (isDark) Color.White.copy(alpha = 0.90f) else MaterialTheme.colorScheme.primary - val shadowBaseAlpha = if (isDark) 0.10f else 0.18f - Box( - modifier = modifier.size(size), + modifier = modifier + .size(size) + .pointerInput(Unit) { + detectTapGestures(onTap = { onTapAvatar() }) + }, contentAlignment = Alignment.Center, ) { Canvas(modifier = Modifier.fillMaxSize()) { @@ -221,81 +335,98 @@ internal fun AnimatedBlobAvatar( val floatOffsetPx = BlobAvatarGeometry.computeFloatOffset(h, animState.floatProgress.value) val center = Offset(w / 2f, (h / 2f) + floatOffsetPx) - // 1. Soft 3D Ground Drop Shadow + drawDualSpotlights(mood = activeMood, w = w, h = h, isDark = isDark) + drawGroundShadow( center = center, w = w, h = h, floatProgress = animState.floatProgress.value, - baseAlpha = shadowBaseAlpha, + baseAlpha = palette.shadowAlpha, ) - // 2. Groovy Head-Bobbing Container - rotate(degrees = animState.headBobAngle.value, pivot = center) { - val bodyWidth = BlobAvatarGeometry.computeBodyWidth(w, animState.breatheScaleX.value) - val bodyHeight = BlobAvatarGeometry.computeBodyHeight(h, animState.breatheScaleY.value) - - // 2A. Headband Arch Over the Head - drawHeadband( - center = center, - bodyWidth = bodyWidth, - bodyHeight = bodyHeight, - bandColor = headbandColor, - w = w, - ) - - // 2B. 3D Volumetric Clay Mochi Body - drawClayMochiBody( - center = center, - bodyWidth = bodyWidth, - bodyHeight = bodyHeight, - baseColor = bodyBaseColor, - shadowColor = bodyShadowColor, - bounceColor = bodyBounceColor, - ) - - // 2C. Soft Rosy Blush Cheeks - drawRosyBlush( - center = center, - bodyWidth = bodyWidth, - bodyHeight = bodyHeight, - blushColor = blushColor, - w = w, - ) - - // 2D. Kawaii Glossy Button Eyes (No creepy sclera!) - drawKawaiiEyes( - center = center, - bodyWidth = bodyWidth, - bodyHeight = bodyHeight, - animState = animState, - eyeColor = facialColor, - w = w, - ) + drawSoundwaveRipples( + center = center, + w = w, + h = h, + animProgress = animState.soundwaveProgress.value, + waveColor = activeMood.rimLightColor, + ) - // 2E. Sweet Delicate Smile - drawSweetSmile( - center = center, - bodyHeight = bodyHeight, - smileColor = facialColor, - w = w, - ) + drawGenreParticles( + center = center, + w = w, + mood = activeMood, + progress = animState.particleProgress.value, + ) - // 2F. Cozy Padded Over-Ear Headphones - drawCozyHeadphones( - center = center, - bodyWidth = bodyWidth, - bodyHeight = bodyHeight, - cupColor = headphoneCupColor, - cushionColor = cushionColor, - accentColor = headphoneAccentColor, - w = w, - ) + val squishY = squishScaleY.value + val squishX = BlobAvatarGeometry.computeSquishScaleX(squishY) + + scale(scaleX = squishX, scaleY = squishY, pivot = center) { + rotate(degrees = animState.headBobAngle.value, pivot = center) { + val bodyWidth = BlobAvatarGeometry.computeBodyWidth(w, animState.breatheScaleX.value) + val bodyHeight = BlobAvatarGeometry.computeBodyHeight(h, animState.breatheScaleY.value) + + drawHeadband(center = center, bodyWidth = bodyWidth, bodyHeight = bodyHeight, bandColor = palette.headbandColor, w = w) + drawClayMochiBody(center = center, bodyWidth = bodyWidth, bodyHeight = bodyHeight, palette = palette) + drawRosyBlush(center = center, bodyWidth = bodyWidth, bodyHeight = bodyHeight, blushColor = palette.blushColor, w = w) + drawKawaiiEyes( + center = center, + bodyWidth = bodyWidth, + bodyHeight = bodyHeight, + animState = animState, + eyeColor = palette.facialColor, + isWinking = isWinking, + w = w, + ) + drawSweetSmile(center = center, bodyHeight = bodyHeight, smileColor = palette.facialColor, w = w) + drawCozyHeadphones( + center = center, + bodyWidth = bodyWidth, + bodyHeight = bodyHeight, + palette = palette, + bassScale = animState.bassPulseScale.value, + w = w, + ) + } } } } } +private fun DrawScope.drawDualSpotlights( + mood: BlobAvatarGenreMood, + w: Float, + h: Float, + isDark: Boolean, +) { + val keyAlpha = if (isDark) 0.32f else 0.16f + val rimAlpha = if (isDark) 0.28f else 0.14f + + val keyLightOrigin = Offset(w * 0.15f, h * 0.10f) + drawCircle( + brush = Brush.radialGradient( + colors = listOf(mood.keyLightColor.copy(alpha = keyAlpha), Color.Transparent), + center = keyLightOrigin, + radius = w * 0.65f, + ), + radius = w * 0.65f, + center = keyLightOrigin, + ) + + val rimLightOrigin = Offset(w * 0.85f, h * 0.90f) + drawCircle( + brush = Brush.radialGradient( + colors = listOf(mood.rimLightColor.copy(alpha = rimAlpha), Color.Transparent), + center = rimLightOrigin, + radius = w * 0.60f, + ), + radius = w * 0.60f, + center = rimLightOrigin, + ) +} + private fun DrawScope.drawGroundShadow( center: Offset, w: Float, @@ -323,13 +454,96 @@ private fun DrawScope.drawGroundShadow( ) } +private fun DrawScope.drawSoundwaveRipples( + center: Offset, + w: Float, + h: Float, + animProgress: Float, + waveColor: Color, +) { + val earSpacing = w * 0.42f + val earY = center.y - (h * 0.01f) + + val waves = listOf(animProgress, (animProgress + 0.5f) % 1f) + + for (p in waves) { + val radius = BlobAvatarGeometry.computeSoundwaveRadius(w, p) + val alpha = BlobAvatarGeometry.computeSoundwaveAlpha(p) + val stroke = (w * 0.016f * (1f - (p * 0.4f))).coerceAtLeast(1f) + + // Left ear wave arc + drawArc( + color = waveColor.copy(alpha = alpha), + startAngle = 110f, + sweepAngle = 140f, + useCenter = false, + topLeft = Offset(center.x - earSpacing - radius, earY - radius), + size = Size(radius * 2f, radius * 2f), + style = Stroke(width = stroke, cap = StrokeCap.Round), + ) + + // Right ear wave arc + drawArc( + color = waveColor.copy(alpha = alpha), + startAngle = -70f, + sweepAngle = 140f, + useCenter = false, + topLeft = Offset(center.x + earSpacing - radius, earY - radius), + size = Size(radius * 2f, radius * 2f), + style = Stroke(width = stroke, cap = StrokeCap.Round), + ) + } +} + +private fun DrawScope.drawGenreParticles( + center: Offset, + w: Float, + mood: BlobAvatarGenreMood, + progress: Float, +) { + val color = mood.keyLightColor + val count = 3 + for (i in 0 until count) { + val phase = (progress + (i.toFloat() / count)) % 1f + val particleY = center.y - (w * 0.15f) - (phase * w * 0.35f) + val sway = sin((phase * 2f * Math.PI) + i).toFloat() * (w * 0.08f) + val particleX = if (i % 2 == 0) center.x - (w * 0.30f) + sway else center.x + (w * 0.30f) + sway + val alpha = (sin(phase * Math.PI).toFloat() * 0.85f).coerceIn(0f, 1f) + val size = w * 0.040f + + when (mood.particleType) { + BlobAvatarGenreMood.ParticleType.MusicNote -> { + drawCircle(color = color.copy(alpha = alpha), radius = size * 0.5f, center = Offset(particleX, particleY)) + drawLine( + color = color.copy(alpha = alpha), + start = Offset(particleX + (size * 0.5f), particleY), + end = Offset(particleX + (size * 0.5f), particleY - (size * 1.4f)), + strokeWidth = (w * 0.012f).coerceAtLeast(1f), + cap = StrokeCap.Round, + ) + } + BlobAvatarGenreMood.ParticleType.EqualizerBar -> { + val barHeight = size * (1f + sin(phase * Math.PI * 4).toFloat().coerceAtLeast(0f)) + drawLine( + color = color.copy(alpha = alpha), + start = Offset(particleX, particleY - barHeight), + end = Offset(particleX, particleY + barHeight), + strokeWidth = (w * 0.015f).coerceAtLeast(1.5f), + cap = StrokeCap.Round, + ) + } + else -> { + drawCircle(color = color.copy(alpha = alpha), radius = size * 0.55f, center = Offset(particleX, particleY)) + } + } + } +} + private fun DrawScope.drawClayMochiBody( center: Offset, bodyWidth: Float, bodyHeight: Float, - baseColor: Color, - shadowColor: Color, - bounceColor: Color, + palette: BlobAvatarPalette, ) { val cornerRadius = CornerRadius(bodyWidth * 0.46f, bodyHeight * 0.46f) val bodyRect = RoundRect( @@ -341,36 +555,33 @@ private fun DrawScope.drawClayMochiBody( ) val bodyPath = Path().apply { addRoundRect(bodyRect) } - // Soft 3D volumetric radial gradient (light from top-left) val lightSource = Offset(center.x - (bodyWidth * 0.20f), center.y - (bodyHeight * 0.22f)) val bodyBrush = Brush.radialGradient( colors = listOf( - Color.White.copy(alpha = 0.50f), - baseColor, - shadowColor, + Color.White, + palette.bodyBase, + palette.bodyShadow, ), center = lightSource, radius = bodyWidth * 0.85f, ) drawPath(path = bodyPath, brush = bodyBrush) - // Soft subsurface bounce light on bottom curve val bounceBrush = Brush.linearGradient( - colors = listOf(Color.Transparent, bounceColor), + colors = listOf(Color.Transparent, palette.bodyBounce), start = Offset(center.x, center.y), end = Offset(center.x, center.y + (bodyHeight / 2f)), ) drawPath(path = bodyPath, brush = bounceBrush) - // Gentle glossy shine on top-left val specWidth = bodyWidth * 0.34f val specHeight = bodyHeight * 0.14f val specCenter = Offset(center.x - (bodyWidth * 0.18f), center.y - (bodyHeight * 0.26f)) drawOval( brush = Brush.radialGradient( colors = listOf( - Color.White.copy(alpha = 0.55f), - Color.White.copy(alpha = 0.15f), + Color.White.copy(alpha = 0.70f), + Color.White.copy(alpha = 0.25f), Color.Transparent, ), center = specCenter, @@ -395,14 +606,7 @@ private fun DrawScope.drawHeadband( val bandPath = Path().apply { moveTo(bandLeftX, bandStartY) - cubicTo( - bandLeftX, - bandTopY, - bandRightX, - bandTopY, - bandRightX, - bandStartY, - ) + cubicTo(bandLeftX, bandTopY, bandRightX, bandTopY, bandRightX, bandStartY) } val bandStroke = BlobAvatarGeometry.computeHeadbandStroke(w) @@ -410,21 +614,13 @@ private fun DrawScope.drawHeadband( drawPath( path = bandPath, color = bandColor, - style = Stroke( - width = bandStroke, - cap = StrokeCap.Round, - join = StrokeJoin.Round, - ), + style = Stroke(width = bandStroke, cap = StrokeCap.Round, join = StrokeJoin.Round), ) drawPath( path = bandPath, - color = Color.White.copy(alpha = 0.30f), - style = Stroke( - width = bandStroke * 0.32f, - cap = StrokeCap.Round, - join = StrokeJoin.Round, - ), + color = Color.White.copy(alpha = 0.45f), + style = Stroke(width = bandStroke * 0.30f, cap = StrokeCap.Round, join = StrokeJoin.Round), ) } @@ -432,16 +628,13 @@ private fun DrawScope.drawCozyHeadphones( center: Offset, bodyWidth: Float, bodyHeight: Float, - cupColor: Color, - cushionColor: Color, - accentColor: Color, + palette: BlobAvatarPalette, + bassScale: Float, w: Float, ) { val cupCenterY = center.y - (bodyHeight * 0.02f) - val cupSpacingX = BlobAvatarGeometry.computeCupSpacing(bodyWidth) - - val cupWidth = BlobAvatarGeometry.computeCupWidth(w) - val cupHeight = w * BlobAvatarGeometry.CUP_HEIGHT_RATIO + val (cupSpacingX, baseCupWidth) = BlobAvatarGeometry.computeCupParams(w, bodyWidth, bassScale) + val cupHeight = w * BlobAvatarGeometry.CUP_HEIGHT_RATIO * bassScale val ears = listOf( Pair(center.x - cupSpacingX, -4f), @@ -450,36 +643,34 @@ private fun DrawScope.drawCozyHeadphones( for ((cupX, tilt) in ears) { rotate(degrees = tilt, pivot = Offset(cupX, cupCenterY)) { - // Soft inner cushion pill val cushionRect = RoundRect( - left = cupX - (cupWidth * 0.48f), + left = cupX - (baseCupWidth * 0.48f), top = cupCenterY - (cupHeight * 0.48f), - right = cupX + (cupWidth * 0.48f), + right = cupX + (baseCupWidth * 0.48f), bottom = cupCenterY + (cupHeight * 0.48f), - cornerRadius = CornerRadius(cupWidth * 0.44f, cupWidth * 0.44f), + cornerRadius = CornerRadius(baseCupWidth * 0.44f, baseCupWidth * 0.44f), ) drawRoundRect( - color = cushionColor, + color = palette.cushionColor, topLeft = Offset(cushionRect.left, cushionRect.top), size = Size(cushionRect.width, cushionRect.height), - cornerRadius = CornerRadius(cupWidth * 0.44f, cupWidth * 0.44f), + cornerRadius = CornerRadius(baseCupWidth * 0.44f, baseCupWidth * 0.44f), ) - // Outer pastel dome shell - val shellWidth = cupWidth * 0.76f - val shellHeight = cupHeight * 0.86f - val shellLeft = if (cupX < center.x) cupX - (cupWidth * 0.50f) else cupX - (shellWidth * 0.46f) + val shellWidth = baseCupWidth * 0.78f + val shellHeight = cupHeight * 0.88f + val shellLeft = if (cupX < center.x) cupX - (baseCupWidth * 0.50f) else cupX - (shellWidth * 0.46f) val shellTop = cupCenterY - (shellHeight * 0.50f) val shellCenter = Offset(shellLeft + (shellWidth / 2f), shellTop + (shellHeight / 2f)) val shellBrush = Brush.radialGradient( colors = listOf( - Color.White.copy(alpha = 0.45f), - cupColor, - cupColor.copy(alpha = 0.80f), + Color.White.copy(alpha = 0.60f), + palette.headphoneCup, + palette.headphoneCup.copy(alpha = 0.85f), ), center = Offset(shellCenter.x - (shellWidth * 0.18f), shellCenter.y - (shellHeight * 0.18f)), - radius = shellWidth * 0.72f, + radius = shellWidth * 0.75f, ) drawRoundRect( @@ -489,27 +680,17 @@ private fun DrawScope.drawCozyHeadphones( cornerRadius = CornerRadius(shellWidth * 0.44f, shellWidth * 0.44f), ) - // Metallic rim line drawRoundRect( - color = Color.White.copy(alpha = 0.38f), + color = Color.White.copy(alpha = 0.50f), topLeft = Offset(shellLeft, shellTop), size = Size(shellWidth, shellHeight), cornerRadius = CornerRadius(shellWidth * 0.44f, shellWidth * 0.44f), - style = Stroke(width = (w * 0.014f).coerceAtLeast(1f)), + style = Stroke(width = (w * 0.015f).coerceAtLeast(1f)), ) - // Center audio dot - val dotRadius = shellWidth * 0.20f - drawCircle( - color = accentColor, - radius = dotRadius, - center = shellCenter, - ) - drawCircle( - color = Color.White.copy(alpha = 0.65f), - radius = dotRadius * 0.45f, - center = shellCenter, - ) + val dotRadius = shellWidth * 0.22f + drawCircle(color = palette.headphoneAccent, radius = dotRadius, center = shellCenter) + drawCircle(color = Color.White.copy(alpha = 0.75f), radius = dotRadius * 0.45f, center = shellCenter) } } } @@ -522,8 +703,7 @@ private fun DrawScope.drawRosyBlush( w: Float, ) { val blushY = center.y + (bodyHeight * 0.10f) - val blushSpacing = BlobAvatarGeometry.computeBlushSpacing(bodyWidth) - val blushRadius = BlobAvatarGeometry.computeBlushRadius(w) + val (blushSpacing, blushRadius) = BlobAvatarGeometry.computeBlushParams(w, bodyWidth) for (sign in listOf(-1f, 1f)) { val blushCenter = Offset(center.x + (sign * blushSpacing), blushY) @@ -545,38 +725,38 @@ private fun DrawScope.drawKawaiiEyes( bodyHeight: Float, animState: BlobAvatarAnimationState, eyeColor: Color, + isWinking: Boolean, w: Float, ) { val eyeSpacing = bodyWidth * BlobAvatarGeometry.EYE_SPACING_RATIO val eyeCenterY = center.y - (bodyHeight * 0.02f) val (eyeWidth, eyeHeight) = BlobAvatarGeometry.computeEyeDimensions(w) val strokeWidth = BlobAvatarGeometry.computeStrokeWidth(w) - val eyeOpenScale = animState.eyeOpenScale.value + val eyeOpenScale = if (isWinking) 0.08f else animState.eyeOpenScale.value val (maxShiftX, maxShiftY) = BlobAvatarGeometry.computeEyeMaxShifts(eyeWidth, eyeHeight) val shiftX = maxShiftX * animState.pupilLookRatioX.value val shiftY = maxShiftY * animState.pupilLookRatioY.value val eyes = listOf( - Offset(center.x - eyeSpacing, eyeCenterY), - Offset(center.x + eyeSpacing, eyeCenterY), + Pair(Offset(center.x - eyeSpacing, eyeCenterY), false), + Pair(Offset(center.x + eyeSpacing, eyeCenterY), isWinking), ) - for (eyeCenter in eyes) { - if (eyeOpenScale > 0.25f) { - val currentEyeHeight = eyeHeight * eyeOpenScale - val currentEyeTop = eyeCenter.y - (currentEyeHeight / 2f) + (shiftY * eyeOpenScale) + for ((eyeCenter, isThisWinking) in eyes) { + val currentScale = if (isThisWinking) 0.08f else eyeOpenScale + if (currentScale > 0.25f) { + val currentEyeHeight = eyeHeight * currentScale + val currentEyeTop = eyeCenter.y - (currentEyeHeight / 2f) + (shiftY * currentScale) val currentEyeLeft = eyeCenter.x - (eyeWidth / 2f) + shiftX - // Glossy Dark Button Eye Oval (Super friendly, like Kirby/Sanrio) drawOval( color = eyeColor, topLeft = Offset(currentEyeLeft, currentEyeTop), size = Size(eyeWidth, currentEyeHeight), ) - // Main Sweet White Catchlight Sparkle (top-right) - val mainGlintRadius = eyeWidth * 0.36f * eyeOpenScale + val mainGlintRadius = eyeWidth * 0.36f * currentScale drawCircle( color = Color.White, radius = mainGlintRadius, @@ -586,8 +766,7 @@ private fun DrawScope.drawKawaiiEyes( ), ) - // Secondary Tiny Sparkle (bottom-left) - val secGlintRadius = eyeWidth * 0.18f * eyeOpenScale + val secGlintRadius = eyeWidth * 0.18f * currentScale drawCircle( color = Color.White.copy(alpha = 0.90f), radius = secGlintRadius, @@ -597,7 +776,6 @@ private fun DrawScope.drawKawaiiEyes( ), ) } else { - // Sweet happy closed eyes (^_^) during blink drawArc( color = eyeColor, startAngle = 200f, @@ -621,7 +799,6 @@ private fun DrawScope.drawSweetSmile( val smileHeight = w * 0.065f val strokeWidth = (w * 0.026f).coerceAtLeast(1.5f) - // Sweet, gentle, friendly smile arc (◡) drawArc( color = smileColor, startAngle = 20f, diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarGenreMood.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarGenreMood.kt new file mode 100644 index 000000000..88d552ad1 --- /dev/null +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarGenreMood.kt @@ -0,0 +1,90 @@ +package cx.aswin.boxlore.feature.settings.pages + +import androidx.compose.ui.graphics.Color +import kotlin.random.Random + +/** + * Visual themes and micro-animation styles representing classic podcast genres. + * Designed to be randomized or cycled interactively, and later bound to a user's + * top subscribed genre. + */ +internal enum class BlobAvatarGenreMood( + val id: String, + val displayName: String, + val keyLightColor: Color, + val rimLightColor: Color, + val particleType: ParticleType, +) { + ChillMusic( + id = "chill_music", + displayName = "Chill Music", + keyLightColor = Color(0xFF8A5CF6), // Neon Violet + rimLightColor = Color(0xFF00E5FF), // Electric Cyan + particleType = ParticleType.MusicNote, + ), + TrueCrime( + id = "true_crime", + displayName = "True Crime", + keyLightColor = Color(0xFF00E5FF), // Mystery Cyan + rimLightColor = Color(0xFF1E40AF), // Deep Sapphire + particleType = ParticleType.RadarPulse, + ), + TechSciFi( + id = "tech_scifi", + displayName = "Tech & Sci-Fi", + keyLightColor = Color(0xFF00F5D4), // Aqua Mint + rimLightColor = Color(0xFF00E676), // Neon Emerald + particleType = ParticleType.EqualizerBar, + ), + ComedyTalk( + id = "comedy_talk", + displayName = "Comedy & Talk", + keyLightColor = Color(0xFFFFB300), // Warm Amber + rimLightColor = Color(0xFFFF4081), // Candy Coral + particleType = ParticleType.LaughSparkle, + ), + LoreStories( + id = "lore_stories", + displayName = "Lore & Stories", + keyLightColor = Color(0xFFFF9E00), // Campfire Gold + rimLightColor = Color(0xFF7C3AED), // Starry Violet + particleType = ParticleType.StoryEmber, + ), + NewsBriefing( + id = "news_briefing", + displayName = "News & Briefing", + keyLightColor = Color(0xFFFF3366), // Studio Crimson + rimLightColor = Color(0xFF80DEEA), // Ice Cyan + particleType = ParticleType.BroadcastArc, + ), + SportsAthletics( + id = "sports_athletics", + displayName = "Sports & Athletics", + keyLightColor = Color(0xFFFFC107), // Stadium Amber + rimLightColor = Color(0xFF2979FF), // Electric Blue + particleType = ParticleType.EnergySpark, + ); + + enum class ParticleType { + MusicNote, + RadarPulse, + EqualizerBar, + LaughSparkle, + StoryEmber, + BroadcastArc, + EnergySpark, + } + + companion object { + fun random(random: Random = Random): BlobAvatarGenreMood { + val all = entries + return all[random.nextInt(all.size)] + } + + fun next(current: BlobAvatarGenreMood): BlobAvatarGenreMood { + val all = entries + val nextIndex = (current.ordinal + 1) % all.size + return all[nextIndex] + } + } +} diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarGeometry.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarGeometry.kt index 3ea84eb5f..b44295478 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarGeometry.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarGeometry.kt @@ -1,5 +1,7 @@ package cx.aswin.boxlore.feature.settings.pages +import kotlin.math.sqrt + internal object BlobAvatarGeometry { const val BODY_WIDTH_RATIO = 0.72f const val BODY_HEIGHT_RATIO = 0.68f @@ -25,6 +27,9 @@ internal object BlobAvatarGeometry { const val EYE_MAX_SHIFT_X_RATIO = 0.28f const val EYE_MAX_SHIFT_Y_RATIO = 0.18f + const val SOUNDWAVE_MAX_RADIUS_RATIO = 0.20f + const val SPOTLIGHT_BACKDROP_RADIUS_RATIO = 0.45f + fun computeBodyWidth(w: Float, breatheX: Float = 1f): Float = w * BODY_WIDTH_RATIO * breatheX @@ -43,27 +48,24 @@ internal object BlobAvatarGeometry { fun computeStrokeWidth(w: Float): Float = (w * STROKE_WIDTH_RATIO).coerceAtLeast(MIN_STROKE_WIDTH) - fun computeCupSpacing(bodyWidth: Float): Float = - bodyWidth * CUP_SPACING_RATIO - - fun computeCupWidth(w: Float, pulse: Float = 1f): Float = - w * CUP_WIDTH_RATIO * pulse - - fun computeOuterEarCupExtent(w: Float, bodyWidth: Float, pulse: Float = 1f): Float { - val spacing = computeCupSpacing(bodyWidth) - val cupW = computeCupWidth(w, pulse) - return spacing + (cupW * 0.50f) - } + fun computeCupParams(w: Float, bodyWidth: Float, pulse: Float = 1f): Pair = + Pair(bodyWidth * CUP_SPACING_RATIO, w * CUP_WIDTH_RATIO * pulse) - fun computeBlushSpacing(bodyWidth: Float): Float = - bodyWidth * BLUSH_SPACING_RATIO - - fun computeBlushRadius(w: Float): Float = - w * BLUSH_RADIUS_RATIO + fun computeBlushParams(w: Float, bodyWidth: Float): Pair = + Pair(bodyWidth * BLUSH_SPACING_RATIO, w * BLUSH_RADIUS_RATIO) fun computeEyeDimensions(w: Float): Pair = Pair(w * EYE_WIDTH_RATIO, w * EYE_HEIGHT_RATIO) fun computeEyeMaxShifts(eyeWidth: Float, eyeHeight: Float): Pair = Pair(eyeWidth * EYE_MAX_SHIFT_X_RATIO, eyeHeight * EYE_MAX_SHIFT_Y_RATIO) + + fun computeSoundwaveRadius(w: Float, progress: Float): Float = + w * (0.07f + (progress.coerceIn(0f, 1f) * SOUNDWAVE_MAX_RADIUS_RATIO)) + + fun computeSoundwaveAlpha(progress: Float): Float = + ((1f - progress.coerceIn(0f, 1f)) * 0.65f).coerceIn(0f, 1f) + + fun computeSquishScaleX(squishY: Float): Float = + if (squishY <= 0f) 1f else (1f / sqrt(squishY)).coerceIn(0.80f, 1.25f) } diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatarTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatarTest.kt index 763bd1cc2..3707ff299 100644 --- a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatarTest.kt +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatarTest.kt @@ -60,8 +60,7 @@ class AnimatedBlobAvatarTest { fun blobAvatar_blushSpacing_staysWithinBodyWidth() { val width = 250f val bodyWidth = BlobAvatarGeometry.computeBodyWidth(width) - val blushSpacing = BlobAvatarGeometry.computeBlushSpacing(bodyWidth) - val blushRadius = BlobAvatarGeometry.computeBlushRadius(width) + val (blushSpacing, blushRadius) = BlobAvatarGeometry.computeBlushParams(width, bodyWidth) val blushOuterEdge = blushSpacing + blushRadius val bodyHalfWidth = bodyWidth / 2f @@ -100,7 +99,8 @@ class AnimatedBlobAvatarTest { val bodyWidth = BlobAvatarGeometry.computeBodyWidth(width, maxBreatheX) val bodyHeight = BlobAvatarGeometry.computeBodyHeight(height, maxBreatheY) - val outerEarcupExtent = BlobAvatarGeometry.computeOuterEarCupExtent(width, bodyWidth, maxPulse) + val (cupSpacing, cupWidth) = BlobAvatarGeometry.computeCupParams(width, bodyWidth, maxPulse) + val outerEarcupExtent = cupSpacing + (cupWidth * 0.50f) // Outer earcup edge must remain safely within half the canvas width assertTrue( @@ -117,4 +117,53 @@ class AnimatedBlobAvatarTest { // Headband top must stay above zero (not clipped at top) assertTrue("Top edge of headband ($topBandEdge) must stay above 0", topBandEdge > 0f) } + + @Test + fun blobAvatar_soundwaveRadius_andAlpha_behaveDeterministically() { + val width = 200f + val r0 = BlobAvatarGeometry.computeSoundwaveRadius(width, 0f) + val r1 = BlobAvatarGeometry.computeSoundwaveRadius(width, 1f) + assertTrue("Soundwave radius at 1f ($r1) must exceed radius at 0f ($r0)", r1 > r0) + + val a0 = BlobAvatarGeometry.computeSoundwaveAlpha(0f) + val a1 = BlobAvatarGeometry.computeSoundwaveAlpha(1f) + assertEquals(0.65f, a0, 0.001f) + assertEquals(0.0f, a1, 0.001f) + + // Middle progress + val rMid = BlobAvatarGeometry.computeSoundwaveRadius(width, 0.5f) + assertTrue(rMid > r0 && rMid < r1) + } + + @Test + fun blobAvatar_squishScaleX_preservesVolume() { + val normalX = BlobAvatarGeometry.computeSquishScaleX(1.0f) + assertEquals(1.0f, normalX, 0.001f) + + val squishedX = BlobAvatarGeometry.computeSquishScaleX(0.80f) + assertTrue("When squished vertically (0.8f), scaleX must expand (> 1.0f)", squishedX > 1.0f) + + val stretchedX = BlobAvatarGeometry.computeSquishScaleX(1.15f) + assertTrue("When stretched vertically (1.15f), scaleX must contract (< 1.0f)", stretchedX < 1.0f) + } + + @Test + fun blobAvatar_genreMoods_provideDistinctPalettesAndCycleCleanly() { + val moods = BlobAvatarGenreMood.entries + assertEquals("Must support 7 distinct podcast genre moods", 7, moods.size) + + for (mood in moods) { + assertTrue("Mood ID must not be blank", mood.id.isNotBlank()) + assertTrue("Display name must not be blank", mood.displayName.isNotBlank()) + val nextMood = BlobAvatarGenreMood.next(mood) + assertTrue("Next mood must be valid", moods.contains(nextMood)) + } + + // Verify full cycle returns to original + var current = moods.first() + repeat(moods.size) { + current = BlobAvatarGenreMood.next(current) + } + assertEquals("Full cycle must return to start", moods.first(), current) + } } From 5be2db427baecb5f88c23bbd2c8c2fb09166bc56 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Sat, 26 Sep 2026 21:08:24 +0530 Subject: [PATCH 16/21] feat(settings): add physical mood accessories and environmental staging to avatar --- feature/settings/README.md | 3 + .../settings/pages/AnimatedBlobAvatar.kt | 424 ++++------------- .../settings/pages/BlobAvatarAccessories.kt | 450 ++++++++++++++++++ .../settings/pages/BlobAvatarCharacter.kt | 355 ++++++++++++++ .../settings/pages/BlobAvatarEnvironments.kt | 411 ++++++++++++++++ .../settings/pages/BlobAvatarGenreMood.kt | 49 +- .../settings/pages/AnimatedBlobAvatarTest.kt | 14 + 7 files changed, 1374 insertions(+), 332 deletions(-) create mode 100644 feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarAccessories.kt create mode 100644 feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarCharacter.kt create mode 100644 feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarEnvironments.kt diff --git a/feature/settings/README.md b/feature/settings/README.md index 4b7fc4555..448cddd32 100644 --- a/feature/settings/README.md +++ b/feature/settings/README.md @@ -46,6 +46,9 @@ src/main/java/cx/aswin/boxlore/feature/settings/ AccountSettingsPage.kt AccountSignedOutContent.kt AnimatedBlobAvatar.kt + BlobAvatarAccessories.kt + BlobAvatarCharacter.kt + BlobAvatarEnvironments.kt BlobAvatarGeometry.kt BlobAvatarGenreMood.kt AppearanceSettingsPage.kt diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatar.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatar.kt index 5e62b67c6..b3b9ccb21 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatar.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatar.kt @@ -1,5 +1,6 @@ package cx.aswin.boxlore.feature.settings.pages +import android.widget.Toast import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.LinearEasing @@ -26,15 +27,11 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.RoundRect import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.rotate @@ -42,6 +39,7 @@ import androidx.compose.ui.graphics.drawscope.scale import androidx.compose.ui.graphics.luminance import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -276,6 +274,21 @@ internal fun resolveAvatarPalette( ) } +internal data class BlobFacialExpression( + val isWinking: Boolean, + val isClosed: Boolean, + val isLaughing: Boolean, +) + +internal data class BlobAvatarRenderContext( + val mood: BlobAvatarGenreMood, + val palette: BlobAvatarPalette, + val animState: BlobAvatarAnimationState, + val expression: BlobFacialExpression, + val squishY: Float, + val isDark: Boolean, +) + /** * An ultra-expressive, tactile 3D mochi mascot avatar with dual studio spotlights, * concentric headphone soundwaves, spring-physics squish on tap, and podcast genre moods. @@ -295,6 +308,7 @@ internal fun AnimatedBlobAvatar( val palette = resolveAvatarPalette(activeMood, isDark) val haptic = LocalHapticFeedback.current + val context = LocalContext.current val coroutineScope = rememberCoroutineScope() val squishScaleY = remember { Animatable(1f) } @@ -303,6 +317,11 @@ internal fun AnimatedBlobAvatar( val onTapAvatar = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) activeMood = BlobAvatarGenreMood.next(activeMood) + Toast.makeText( + context, + "${activeMood.emoji} ${activeMood.displayName}", + Toast.LENGTH_SHORT, + ).show() isWinking = true coroutineScope.launch { squishScaleY.snapTo(0.80f) @@ -320,6 +339,21 @@ internal fun AnimatedBlobAvatar( } } + val expression = BlobFacialExpression( + isWinking = isWinking, + isClosed = activeMood == BlobAvatarGenreMood.ChillMusic, + isLaughing = activeMood == BlobAvatarGenreMood.ComedyTalk, + ) + + val renderContext = BlobAvatarRenderContext( + mood = activeMood, + palette = palette, + animState = animState, + expression = expression, + squishY = squishScaleY.value, + isDark = isDark, + ) + Box( modifier = modifier .size(size) @@ -329,68 +363,71 @@ internal fun AnimatedBlobAvatar( contentAlignment = Alignment.Center, ) { Canvas(modifier = Modifier.fillMaxSize()) { - val w = this.size.width - val h = this.size.height + drawAvatarLayers(renderContext, this.size.width, this.size.height) + } + } +} - val floatOffsetPx = BlobAvatarGeometry.computeFloatOffset(h, animState.floatProgress.value) - val center = Offset(w / 2f, (h / 2f) + floatOffsetPx) +private fun DrawScope.drawAvatarLayers( + renderContext: BlobAvatarRenderContext, + w: Float, + h: Float, +) { + val mood = renderContext.mood + val palette = renderContext.palette + val animState = renderContext.animState + + val floatOffsetPx = BlobAvatarGeometry.computeFloatOffset(h, animState.floatProgress.value) + val center = Offset(w / 2f, (h / 2f) + floatOffsetPx) + + drawDualSpotlights(mood = mood, w = w, h = h, isDark = renderContext.isDark) + + BlobAvatarEnvironments.drawEnvironment( + drawScope = this, + environmentType = mood.environmentType, + center = center, + w = w, + h = h, + mood = mood, + progress = animState.particleProgress.value, + ) - drawDualSpotlights(mood = activeMood, w = w, h = h, isDark = isDark) + drawGroundShadow( + center = center, + w = w, + h = h, + floatProgress = animState.floatProgress.value, + baseAlpha = palette.shadowAlpha, + ) - drawGroundShadow( - center = center, - w = w, - h = h, - floatProgress = animState.floatProgress.value, - baseAlpha = palette.shadowAlpha, - ) + drawSoundwaveRipples( + center = center, + w = w, + h = h, + animProgress = animState.soundwaveProgress.value, + waveColor = mood.rimLightColor, + ) - drawSoundwaveRipples( - center = center, - w = w, - h = h, - animProgress = animState.soundwaveProgress.value, - waveColor = activeMood.rimLightColor, - ) + drawGenreParticles( + center = center, + w = w, + mood = mood, + progress = animState.particleProgress.value, + ) - drawGenreParticles( + val squishX = BlobAvatarGeometry.computeSquishScaleX(renderContext.squishY) + + scale(scaleX = squishX, scaleY = renderContext.squishY, pivot = center) { + rotate(degrees = animState.headBobAngle.value, pivot = center) { + val bodyWidth = BlobAvatarGeometry.computeBodyWidth(w, animState.breatheScaleX.value) + val bodyHeight = BlobAvatarGeometry.computeBodyHeight(h, animState.breatheScaleY.value) + val dimensions = CharacterDimensions( center = center, + bodyWidth = bodyWidth, + bodyHeight = bodyHeight, w = w, - mood = activeMood, - progress = animState.particleProgress.value, ) - - val squishY = squishScaleY.value - val squishX = BlobAvatarGeometry.computeSquishScaleX(squishY) - - scale(scaleX = squishX, scaleY = squishY, pivot = center) { - rotate(degrees = animState.headBobAngle.value, pivot = center) { - val bodyWidth = BlobAvatarGeometry.computeBodyWidth(w, animState.breatheScaleX.value) - val bodyHeight = BlobAvatarGeometry.computeBodyHeight(h, animState.breatheScaleY.value) - - drawHeadband(center = center, bodyWidth = bodyWidth, bodyHeight = bodyHeight, bandColor = palette.headbandColor, w = w) - drawClayMochiBody(center = center, bodyWidth = bodyWidth, bodyHeight = bodyHeight, palette = palette) - drawRosyBlush(center = center, bodyWidth = bodyWidth, bodyHeight = bodyHeight, blushColor = palette.blushColor, w = w) - drawKawaiiEyes( - center = center, - bodyWidth = bodyWidth, - bodyHeight = bodyHeight, - animState = animState, - eyeColor = palette.facialColor, - isWinking = isWinking, - w = w, - ) - drawSweetSmile(center = center, bodyHeight = bodyHeight, smileColor = palette.facialColor, w = w) - drawCozyHeadphones( - center = center, - bodyWidth = bodyWidth, - bodyHeight = bodyHeight, - palette = palette, - bassScale = animState.bassPulseScale.value, - w = w, - ) - } - } + BlobAvatarCharacter.drawCharacter(this, dimensions, renderContext) } } } @@ -538,274 +575,3 @@ private fun DrawScope.drawGenreParticles( } } } - -private fun DrawScope.drawClayMochiBody( - center: Offset, - bodyWidth: Float, - bodyHeight: Float, - palette: BlobAvatarPalette, -) { - val cornerRadius = CornerRadius(bodyWidth * 0.46f, bodyHeight * 0.46f) - val bodyRect = RoundRect( - left = center.x - (bodyWidth / 2f), - top = center.y - (bodyHeight / 2f), - right = center.x + (bodyWidth / 2f), - bottom = center.y + (bodyHeight / 2f), - cornerRadius = cornerRadius, - ) - val bodyPath = Path().apply { addRoundRect(bodyRect) } - - val lightSource = Offset(center.x - (bodyWidth * 0.20f), center.y - (bodyHeight * 0.22f)) - val bodyBrush = Brush.radialGradient( - colors = listOf( - Color.White, - palette.bodyBase, - palette.bodyShadow, - ), - center = lightSource, - radius = bodyWidth * 0.85f, - ) - drawPath(path = bodyPath, brush = bodyBrush) - - val bounceBrush = Brush.linearGradient( - colors = listOf(Color.Transparent, palette.bodyBounce), - start = Offset(center.x, center.y), - end = Offset(center.x, center.y + (bodyHeight / 2f)), - ) - drawPath(path = bodyPath, brush = bounceBrush) - - val specWidth = bodyWidth * 0.34f - val specHeight = bodyHeight * 0.14f - val specCenter = Offset(center.x - (bodyWidth * 0.18f), center.y - (bodyHeight * 0.26f)) - drawOval( - brush = Brush.radialGradient( - colors = listOf( - Color.White.copy(alpha = 0.70f), - Color.White.copy(alpha = 0.25f), - Color.Transparent, - ), - center = specCenter, - radius = specWidth / 2f, - ), - topLeft = Offset(specCenter.x - (specWidth / 2f), specCenter.y - (specHeight / 2f)), - size = Size(specWidth, specHeight), - ) -} - -private fun DrawScope.drawHeadband( - center: Offset, - bodyWidth: Float, - bodyHeight: Float, - bandColor: Color, - w: Float, -) { - val bandTopY = BlobAvatarGeometry.computeHeadbandTopY(center.y, bodyHeight) - val bandLeftX = center.x - (bodyWidth * 0.44f) - val bandRightX = center.x + (bodyWidth * 0.44f) - val bandStartY = center.y - (bodyHeight * 0.10f) - - val bandPath = Path().apply { - moveTo(bandLeftX, bandStartY) - cubicTo(bandLeftX, bandTopY, bandRightX, bandTopY, bandRightX, bandStartY) - } - - val bandStroke = BlobAvatarGeometry.computeHeadbandStroke(w) - - drawPath( - path = bandPath, - color = bandColor, - style = Stroke(width = bandStroke, cap = StrokeCap.Round, join = StrokeJoin.Round), - ) - - drawPath( - path = bandPath, - color = Color.White.copy(alpha = 0.45f), - style = Stroke(width = bandStroke * 0.30f, cap = StrokeCap.Round, join = StrokeJoin.Round), - ) -} - -private fun DrawScope.drawCozyHeadphones( - center: Offset, - bodyWidth: Float, - bodyHeight: Float, - palette: BlobAvatarPalette, - bassScale: Float, - w: Float, -) { - val cupCenterY = center.y - (bodyHeight * 0.02f) - val (cupSpacingX, baseCupWidth) = BlobAvatarGeometry.computeCupParams(w, bodyWidth, bassScale) - val cupHeight = w * BlobAvatarGeometry.CUP_HEIGHT_RATIO * bassScale - - val ears = listOf( - Pair(center.x - cupSpacingX, -4f), - Pair(center.x + cupSpacingX, 4f), - ) - - for ((cupX, tilt) in ears) { - rotate(degrees = tilt, pivot = Offset(cupX, cupCenterY)) { - val cushionRect = RoundRect( - left = cupX - (baseCupWidth * 0.48f), - top = cupCenterY - (cupHeight * 0.48f), - right = cupX + (baseCupWidth * 0.48f), - bottom = cupCenterY + (cupHeight * 0.48f), - cornerRadius = CornerRadius(baseCupWidth * 0.44f, baseCupWidth * 0.44f), - ) - drawRoundRect( - color = palette.cushionColor, - topLeft = Offset(cushionRect.left, cushionRect.top), - size = Size(cushionRect.width, cushionRect.height), - cornerRadius = CornerRadius(baseCupWidth * 0.44f, baseCupWidth * 0.44f), - ) - - val shellWidth = baseCupWidth * 0.78f - val shellHeight = cupHeight * 0.88f - val shellLeft = if (cupX < center.x) cupX - (baseCupWidth * 0.50f) else cupX - (shellWidth * 0.46f) - val shellTop = cupCenterY - (shellHeight * 0.50f) - val shellCenter = Offset(shellLeft + (shellWidth / 2f), shellTop + (shellHeight / 2f)) - - val shellBrush = Brush.radialGradient( - colors = listOf( - Color.White.copy(alpha = 0.60f), - palette.headphoneCup, - palette.headphoneCup.copy(alpha = 0.85f), - ), - center = Offset(shellCenter.x - (shellWidth * 0.18f), shellCenter.y - (shellHeight * 0.18f)), - radius = shellWidth * 0.75f, - ) - - drawRoundRect( - brush = shellBrush, - topLeft = Offset(shellLeft, shellTop), - size = Size(shellWidth, shellHeight), - cornerRadius = CornerRadius(shellWidth * 0.44f, shellWidth * 0.44f), - ) - - drawRoundRect( - color = Color.White.copy(alpha = 0.50f), - topLeft = Offset(shellLeft, shellTop), - size = Size(shellWidth, shellHeight), - cornerRadius = CornerRadius(shellWidth * 0.44f, shellWidth * 0.44f), - style = Stroke(width = (w * 0.015f).coerceAtLeast(1f)), - ) - - val dotRadius = shellWidth * 0.22f - drawCircle(color = palette.headphoneAccent, radius = dotRadius, center = shellCenter) - drawCircle(color = Color.White.copy(alpha = 0.75f), radius = dotRadius * 0.45f, center = shellCenter) - } - } -} - -private fun DrawScope.drawRosyBlush( - center: Offset, - bodyWidth: Float, - bodyHeight: Float, - blushColor: Color, - w: Float, -) { - val blushY = center.y + (bodyHeight * 0.10f) - val (blushSpacing, blushRadius) = BlobAvatarGeometry.computeBlushParams(w, bodyWidth) - - for (sign in listOf(-1f, 1f)) { - val blushCenter = Offset(center.x + (sign * blushSpacing), blushY) - drawCircle( - brush = Brush.radialGradient( - colors = listOf(blushColor, blushColor.copy(alpha = 0.22f), Color.Transparent), - center = blushCenter, - radius = blushRadius, - ), - radius = blushRadius, - center = blushCenter, - ) - } -} - -private fun DrawScope.drawKawaiiEyes( - center: Offset, - bodyWidth: Float, - bodyHeight: Float, - animState: BlobAvatarAnimationState, - eyeColor: Color, - isWinking: Boolean, - w: Float, -) { - val eyeSpacing = bodyWidth * BlobAvatarGeometry.EYE_SPACING_RATIO - val eyeCenterY = center.y - (bodyHeight * 0.02f) - val (eyeWidth, eyeHeight) = BlobAvatarGeometry.computeEyeDimensions(w) - val strokeWidth = BlobAvatarGeometry.computeStrokeWidth(w) - val eyeOpenScale = if (isWinking) 0.08f else animState.eyeOpenScale.value - - val (maxShiftX, maxShiftY) = BlobAvatarGeometry.computeEyeMaxShifts(eyeWidth, eyeHeight) - val shiftX = maxShiftX * animState.pupilLookRatioX.value - val shiftY = maxShiftY * animState.pupilLookRatioY.value - - val eyes = listOf( - Pair(Offset(center.x - eyeSpacing, eyeCenterY), false), - Pair(Offset(center.x + eyeSpacing, eyeCenterY), isWinking), - ) - - for ((eyeCenter, isThisWinking) in eyes) { - val currentScale = if (isThisWinking) 0.08f else eyeOpenScale - if (currentScale > 0.25f) { - val currentEyeHeight = eyeHeight * currentScale - val currentEyeTop = eyeCenter.y - (currentEyeHeight / 2f) + (shiftY * currentScale) - val currentEyeLeft = eyeCenter.x - (eyeWidth / 2f) + shiftX - - drawOval( - color = eyeColor, - topLeft = Offset(currentEyeLeft, currentEyeTop), - size = Size(eyeWidth, currentEyeHeight), - ) - - val mainGlintRadius = eyeWidth * 0.36f * currentScale - drawCircle( - color = Color.White, - radius = mainGlintRadius, - center = Offset( - currentEyeLeft + (eyeWidth * 0.65f), - currentEyeTop + (currentEyeHeight * 0.32f), - ), - ) - - val secGlintRadius = eyeWidth * 0.18f * currentScale - drawCircle( - color = Color.White.copy(alpha = 0.90f), - radius = secGlintRadius, - center = Offset( - currentEyeLeft + (eyeWidth * 0.32f), - currentEyeTop + (currentEyeHeight * 0.72f), - ), - ) - } else { - drawArc( - color = eyeColor, - startAngle = 200f, - sweepAngle = 140f, - useCenter = false, - topLeft = Offset(eyeCenter.x - (eyeWidth * 0.90f), eyeCenter.y - (eyeHeight * 0.30f)), - size = Size(eyeWidth * 1.8f, eyeHeight * 0.75f), - style = Stroke(width = strokeWidth, cap = StrokeCap.Round), - ) - } - } -} - -private fun DrawScope.drawSweetSmile( - center: Offset, - bodyHeight: Float, - smileColor: Color, - w: Float, -) { - val smileWidth = w * 0.12f - val smileHeight = w * 0.065f - val strokeWidth = (w * 0.026f).coerceAtLeast(1.5f) - - drawArc( - color = smileColor, - startAngle = 20f, - sweepAngle = 140f, - useCenter = false, - topLeft = Offset(center.x - (smileWidth / 2f), center.y + (bodyHeight * 0.10f)), - size = Size(smileWidth, smileHeight), - style = Stroke(width = strokeWidth, cap = StrokeCap.Round), - ) -} diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarAccessories.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarAccessories.kt new file mode 100644 index 000000000..8cc58656c --- /dev/null +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarAccessories.kt @@ -0,0 +1,450 @@ +package cx.aswin.boxlore.feature.settings.pages + +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.rotate + +internal object BlobAvatarAccessories { + + fun drawAccessory( + drawScope: DrawScope, + accessoryType: BlobAvatarGenreMood.AccessoryType, + center: Offset, + bodyWidth: Float, + bodyHeight: Float, + w: Float, + mood: BlobAvatarGenreMood, + ) { + when (accessoryType) { + BlobAvatarGenreMood.AccessoryType.DetectiveFedora -> + drawDetectiveFedora(drawScope, center, bodyWidth, bodyHeight, w) + BlobAvatarGenreMood.AccessoryType.CyberVisor -> + drawCyberVisor(drawScope, center, bodyWidth, bodyHeight, w, mood.keyLightColor) + BlobAvatarGenreMood.AccessoryType.AthleticSweatband -> + drawAthleticSweatband(drawScope, center, bodyWidth, bodyHeight, w) + BlobAvatarGenreMood.AccessoryType.BroadcastBoomMic -> + drawBroadcastBoomMic(drawScope, center, bodyWidth, bodyHeight, w) + BlobAvatarGenreMood.AccessoryType.CozyBeanie -> + drawCozyBeanie(drawScope, center, bodyWidth, bodyHeight, w, mood.keyLightColor) + BlobAvatarGenreMood.AccessoryType.StarSunglasses -> + drawStarSunglasses(drawScope, center, bodyWidth, bodyHeight, w) + BlobAvatarGenreMood.AccessoryType.WizardHat -> + drawWizardHat(drawScope, center, bodyWidth, bodyHeight, w) + } + } + + private fun drawDetectiveFedora( + drawScope: DrawScope, + center: Offset, + bodyWidth: Float, + bodyHeight: Float, + w: Float, + ) { + val hatCenterY = center.y - (bodyHeight * 0.46f) + val hatWidth = bodyWidth * 0.88f + val hatHeight = bodyHeight * 0.30f + + drawScope.rotate(degrees = -6f, pivot = Offset(center.x, hatCenterY)) { + // Fedora Brim + val brimRect = RoundRect( + left = center.x - (hatWidth / 2f), + top = hatCenterY + (hatHeight * 0.20f), + right = center.x + (hatWidth / 2f), + bottom = hatCenterY + (hatHeight * 0.48f), + cornerRadius = CornerRadius(hatHeight * 0.20f, hatHeight * 0.20f), + ) + drawRoundRect( + color = Color(0xFF262E3B), + topLeft = Offset(brimRect.left, brimRect.top), + size = Size(brimRect.width, brimRect.height), + cornerRadius = CornerRadius(hatHeight * 0.20f, hatHeight * 0.20f), + ) + + // Fedora Crown + val crownPath = Path().apply { + moveTo(center.x - (hatWidth * 0.30f), hatCenterY + (hatHeight * 0.25f)) + cubicTo( + center.x - (hatWidth * 0.28f), + hatCenterY - (hatHeight * 0.60f), + center.x - (hatWidth * 0.05f), + hatCenterY - (hatHeight * 0.40f), + center.x, + hatCenterY - (hatHeight * 0.50f), + ) + cubicTo( + center.x + (hatWidth * 0.05f), + hatCenterY - (hatHeight * 0.40f), + center.x + (hatWidth * 0.28f), + hatCenterY - (hatHeight * 0.60f), + center.x + (hatWidth * 0.30f), + hatCenterY + (hatHeight * 0.25f), + ) + close() + } + drawPath(path = crownPath, color = Color(0xFF333E4F)) + + // Dark Ribbon Hatband + drawRoundRect( + color = Color(0xFF161A22), + topLeft = Offset(center.x - (hatWidth * 0.29f), hatCenterY + (hatHeight * 0.12f)), + size = Size(hatWidth * 0.58f, hatHeight * 0.16f), + cornerRadius = CornerRadius(2f, 2f), + ) + + // Ribbon Gold Buckle + drawRect( + color = Color(0xFFFFC107), + topLeft = Offset(center.x + (hatWidth * 0.10f), hatCenterY + (hatHeight * 0.13f)), + size = Size(hatWidth * 0.06f, hatHeight * 0.14f), + style = Stroke(width = (w * 0.012f).coerceAtLeast(1f)), + ) + + // Magnifying Glass + val glassX = center.x + (bodyWidth * 0.38f) + val glassY = center.y + (bodyHeight * 0.15f) + val glassRadius = w * 0.09f + drawCircle(color = Color(0xFF80DEEA).copy(alpha = 0.35f), radius = glassRadius, center = Offset(glassX, glassY)) + drawCircle( + color = Color(0xFFE2E8F0), + radius = glassRadius, + center = Offset(glassX, glassY), + style = Stroke(width = (w * 0.016f).coerceAtLeast(1.5f)), + ) + drawLine( + color = Color(0xFF8D6E63), + start = Offset(glassX + (glassRadius * 0.70f), glassY + (glassRadius * 0.70f)), + end = Offset(glassX + (glassRadius * 1.50f), glassY + (glassRadius * 1.50f)), + strokeWidth = (w * 0.024f).coerceAtLeast(2f), + cap = StrokeCap.Round, + ) + } + } + + private fun drawCyberVisor( + drawScope: DrawScope, + center: Offset, + bodyWidth: Float, + bodyHeight: Float, + w: Float, + neonColor: Color, + ) { + val visorY = center.y - (bodyHeight * 0.02f) + val visorWidth = bodyWidth * 0.72f + val visorHeight = bodyHeight * 0.18f + + // Visor glowing base + val visorRect = RoundRect( + left = center.x - (visorWidth / 2f), + top = visorY - (visorHeight / 2f), + right = center.x + (visorWidth / 2f), + bottom = visorY + (visorHeight / 2f), + cornerRadius = CornerRadius(visorHeight * 0.44f, visorHeight * 0.44f), + ) + + drawScope.drawRoundRect( + brush = Brush.horizontalGradient( + colors = listOf( + neonColor.copy(alpha = 0.85f), + Color(0xFF00E5FF).copy(alpha = 0.90f), + neonColor.copy(alpha = 0.85f), + ), + ), + topLeft = Offset(visorRect.left, visorRect.top), + size = Size(visorRect.width, visorRect.height), + cornerRadius = CornerRadius(visorHeight * 0.44f, visorHeight * 0.44f), + ) + + // Visor White Center Scanline + drawScope.drawLine( + color = Color.White.copy(alpha = 0.85f), + start = Offset(center.x - (visorWidth * 0.40f), visorY), + end = Offset(center.x + (visorWidth * 0.40f), visorY), + strokeWidth = (w * 0.012f).coerceAtLeast(1f), + cap = StrokeCap.Round, + ) + + // Headphone Antenna with Beacon + val antennaX = center.x - (bodyWidth * 0.46f) + val antennaTopY = center.y - (bodyHeight * 0.50f) + drawScope.drawLine( + color = Color(0xFF94A3B8), + start = Offset(antennaX, center.y - (bodyHeight * 0.20f)), + end = Offset(antennaX, antennaTopY), + strokeWidth = (w * 0.015f).coerceAtLeast(1.5f), + cap = StrokeCap.Round, + ) + drawScope.drawCircle(color = neonColor, radius = w * 0.035f, center = Offset(antennaX, antennaTopY)) + drawScope.drawCircle(color = Color.White, radius = w * 0.016f, center = Offset(antennaX, antennaTopY)) + } + + private fun drawAthleticSweatband( + drawScope: DrawScope, + center: Offset, + bodyWidth: Float, + bodyHeight: Float, + w: Float, + ) { + val bandY = center.y - (bodyHeight * 0.22f) + val bandWidth = bodyWidth * 0.86f + val bandHeight = bodyHeight * 0.14f + + val sweatbandRect = RoundRect( + left = center.x - (bandWidth / 2f), + top = bandY - (bandHeight / 2f), + right = center.x + (bandWidth / 2f), + bottom = bandY + (bandHeight / 2f), + cornerRadius = CornerRadius(bandHeight * 0.35f, bandHeight * 0.35f), + ) + + // Terrycloth Base + drawScope.drawRoundRect( + color = Color(0xFFF8FAFC), + topLeft = Offset(sweatbandRect.left, sweatbandRect.top), + size = Size(sweatbandRect.width, sweatbandRect.height), + cornerRadius = CornerRadius(bandHeight * 0.35f, bandHeight * 0.35f), + ) + + // Athletic Stripes (Navy + Crimson) + drawScope.drawLine( + color = Color(0xFF1E3A8A), + start = Offset(center.x - (bandWidth * 0.42f), bandY - (bandHeight * 0.20f)), + end = Offset(center.x + (bandWidth * 0.42f), bandY - (bandHeight * 0.20f)), + strokeWidth = (w * 0.014f).coerceAtLeast(1f), + ) + drawScope.drawLine( + color = Color(0xFFEF4444), + start = Offset(center.x - (bandWidth * 0.42f), bandY + (bandHeight * 0.20f)), + end = Offset(center.x + (bandWidth * 0.42f), bandY + (bandHeight * 0.20f)), + strokeWidth = (w * 0.014f).coerceAtLeast(1f), + ) + + // Cute Sweat Drop on Forehead + val dropX = center.x + (bodyWidth * 0.30f) + val dropY = center.y - (bodyHeight * 0.12f) + drawScope.drawCircle(color = Color(0xFF38BDF8), radius = w * 0.024f, center = Offset(dropX, dropY)) + drawScope.drawCircle(color = Color.White, radius = w * 0.010f, center = Offset(dropX - (w * 0.006f), dropY - (w * 0.006f))) + } + + private fun drawBroadcastBoomMic( + drawScope: DrawScope, + center: Offset, + bodyWidth: Float, + bodyHeight: Float, + w: Float, + ) { + val earCupLeft = center.x - (bodyWidth * 0.46f) + val earCupY = center.y - (bodyHeight * 0.02f) + val mouthX = center.x - (bodyWidth * 0.08f) + val mouthY = center.y + (bodyHeight * 0.12f) + + // Gooseneck Boom Arm + val boomPath = Path().apply { + moveTo(earCupLeft, earCupY) + cubicTo( + earCupLeft + (bodyWidth * 0.10f), + earCupY + (bodyHeight * 0.22f), + mouthX - (bodyWidth * 0.15f), + mouthY + (bodyHeight * 0.08f), + mouthX, + mouthY, + ) + } + drawScope.drawPath( + path = boomPath, + color = Color(0xFF475569), + style = Stroke(width = (w * 0.020f).coerceAtLeast(1.5f), cap = StrokeCap.Round), + ) + + // Foam Windscreen Capsule + val capsuleWidth = w * 0.08f + val capsuleHeight = w * 0.055f + drawScope.drawRoundRect( + color = Color(0xFF1E293B), + topLeft = Offset(mouthX - (capsuleWidth / 2f), mouthY - (capsuleHeight / 2f)), + size = Size(capsuleWidth, capsuleHeight), + cornerRadius = CornerRadius(capsuleHeight * 0.40f, capsuleHeight * 0.40f), + ) + // Red Audio Ring on Mic Tip + drawScope.drawLine( + color = Color(0xFFEF4444), + start = Offset(mouthX + (capsuleWidth * 0.20f), mouthY - (capsuleHeight * 0.40f)), + end = Offset(mouthX + (capsuleWidth * 0.20f), mouthY + (capsuleHeight * 0.40f)), + strokeWidth = (w * 0.010f).coerceAtLeast(1f), + ) + } + + private fun drawCozyBeanie( + drawScope: DrawScope, + center: Offset, + bodyWidth: Float, + bodyHeight: Float, + w: Float, + beanieColor: Color, + ) { + val beanieCenterY = center.y - (bodyHeight * 0.35f) + val beanieWidth = bodyWidth * 0.88f + val beanieHeight = bodyHeight * 0.34f + + // Slouchy Dome + val domePath = Path().apply { + moveTo(center.x - (beanieWidth * 0.44f), beanieCenterY + (beanieHeight * 0.20f)) + cubicTo( + center.x - (beanieWidth * 0.40f), + beanieCenterY - (beanieHeight * 0.85f), + center.x + (beanieWidth * 0.40f), + beanieCenterY - (beanieHeight * 0.85f), + center.x + (beanieWidth * 0.44f), + beanieCenterY + (beanieHeight * 0.20f), + ) + close() + } + drawScope.drawPath( + path = domePath, + brush = Brush.verticalGradient( + colors = listOf(beanieColor, beanieColor.copy(alpha = 0.85f)), + ), + ) + + // Folded Ribbed Brim + val brimRect = RoundRect( + left = center.x - (beanieWidth * 0.46f), + top = beanieCenterY + (beanieHeight * 0.10f), + right = center.x + (beanieWidth * 0.46f), + bottom = beanieCenterY + (beanieHeight * 0.36f), + cornerRadius = CornerRadius(beanieHeight * 0.12f, beanieHeight * 0.12f), + ) + drawScope.drawRoundRect( + color = beanieColor.copy(alpha = 0.95f), + topLeft = Offset(brimRect.left, brimRect.top), + size = Size(brimRect.width, brimRect.height), + cornerRadius = CornerRadius(beanieHeight * 0.12f, beanieHeight * 0.12f), + ) + + // Fluffy Pom-Pom Ball on Top + val pomPomCenter = Offset(center.x, beanieCenterY - (beanieHeight * 0.65f)) + drawScope.drawCircle(color = Color.White.copy(alpha = 0.90f), radius = w * 0.065f, center = pomPomCenter) + } + + private fun drawStarSunglasses( + drawScope: DrawScope, + center: Offset, + bodyWidth: Float, + bodyHeight: Float, + w: Float, + ) { + val glassY = center.y - (bodyHeight * 0.02f) + val eyeSpacing = bodyWidth * 0.18f + + for (sign in listOf(-1f, 1f)) { + val starCenter = Offset(center.x + (sign * eyeSpacing), glassY) + val starRadius = w * 0.075f + + // 5-Point Star Lens + val starPath = Path() + val points = 5 + val angleStep = Math.PI / points + for (i in 0 until (2 * points)) { + val r = if (i % 2 == 0) starRadius else starRadius * 0.52f + val angle = (i * angleStep) - (Math.PI / 2.0) + val x = starCenter.x + (r * kotlin.math.cos(angle)).toFloat() + val y = starCenter.y + (r * kotlin.math.sin(angle)).toFloat() + if (i == 0) starPath.moveTo(x, y) else starPath.lineTo(x, y) + } + starPath.close() + + drawScope.drawPath(path = starPath, color = Color(0xFF1E293B).copy(alpha = 0.90f)) + drawScope.drawPath( + path = starPath, + color = Color(0xFFFFD700), + style = Stroke(width = (w * 0.016f).coerceAtLeast(1.5f), join = StrokeJoin.Round), + ) + // Lens Glint + drawScope.drawCircle( + color = Color.White.copy(alpha = 0.85f), + radius = starRadius * 0.28f, + center = Offset(starCenter.x + (starRadius * 0.20f), starCenter.y - (starRadius * 0.20f)), + ) + } + + // Bridge connecting lenses + drawScope.drawLine( + color = Color(0xFFFFD700), + start = Offset(center.x - (w * 0.05f), glassY), + end = Offset(center.x + (w * 0.05f), glassY), + strokeWidth = (w * 0.016f).coerceAtLeast(1.5f), + ) + } + + private fun drawWizardHat( + drawScope: DrawScope, + center: Offset, + bodyWidth: Float, + bodyHeight: Float, + w: Float, + ) { + val hatCenterY = center.y - (bodyHeight * 0.40f) + val hatWidth = bodyWidth * 0.90f + val hatHeight = bodyHeight * 0.55f + + drawScope.rotate(degrees = 8f, pivot = Offset(center.x, hatCenterY)) { + // Hat Wide Brim + val brimRect = RoundRect( + left = center.x - (hatWidth / 2f), + top = hatCenterY + (hatHeight * 0.15f), + right = center.x + (hatWidth / 2f), + bottom = hatCenterY + (hatHeight * 0.38f), + cornerRadius = CornerRadius(hatHeight * 0.12f, hatHeight * 0.12f), + ) + drawRoundRect( + color = Color(0xFF4C1D95), + topLeft = Offset(brimRect.left, brimRect.top), + size = Size(brimRect.width, brimRect.height), + cornerRadius = CornerRadius(hatHeight * 0.12f, hatHeight * 0.12f), + ) + + // Hat Pointed Floppy Cone + val conePath = Path().apply { + moveTo(center.x - (hatWidth * 0.32f), hatCenterY + (hatHeight * 0.20f)) + cubicTo( + center.x - (hatWidth * 0.10f), + hatCenterY - (hatHeight * 0.30f), + center.x + (hatWidth * 0.10f), + hatCenterY - (hatHeight * 0.70f), + center.x + (hatWidth * 0.35f), + hatCenterY - (hatHeight * 0.85f), + ) + cubicTo( + center.x + (hatWidth * 0.20f), + hatCenterY - (hatHeight * 0.50f), + center.x + (hatWidth * 0.25f), + hatCenterY - (hatHeight * 0.20f), + center.x + (hatWidth * 0.32f), + hatCenterY + (hatHeight * 0.20f), + ) + close() + } + drawPath(path = conePath, color = Color(0xFF5B21B6)) + + // Starry Gold Hatband + drawRoundRect( + color = Color(0xFFF59E0B), + topLeft = Offset(center.x - (hatWidth * 0.30f), hatCenterY + (hatHeight * 0.10f)), + size = Size(hatWidth * 0.60f, hatHeight * 0.12f), + cornerRadius = CornerRadius(2f, 2f), + ) + // Golden Star Sparkle on Tip + val tipX = center.x + (hatWidth * 0.35f) + val tipY = hatCenterY - (hatHeight * 0.85f) + drawCircle(color = Color(0xFFFDE047), radius = w * 0.038f, center = Offset(tipX, tipY)) + } + } +} diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarCharacter.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarCharacter.kt new file mode 100644 index 000000000..1008627d4 --- /dev/null +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarCharacter.kt @@ -0,0 +1,355 @@ +package cx.aswin.boxlore.feature.settings.pages + +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.rotate + +internal data class CharacterDimensions( + val center: Offset, + val bodyWidth: Float, + val bodyHeight: Float, + val w: Float, +) + +internal object BlobAvatarCharacter { + + fun drawCharacter( + drawScope: DrawScope, + dimensions: CharacterDimensions, + renderContext: BlobAvatarRenderContext, + ) { + val palette = renderContext.palette + drawHeadband(drawScope, dimensions, palette.headbandColor) + drawClayMochiBody(drawScope, dimensions, palette) + drawRosyBlush(drawScope, dimensions, palette.blushColor) + drawKawaiiEyes(drawScope, dimensions, renderContext.animState, palette.facialColor, renderContext.expression) + drawSweetSmile(drawScope, dimensions, palette.facialColor, renderContext.expression.isLaughing) + drawCozyHeadphones(drawScope, dimensions, palette, renderContext.animState.bassPulseScale.value) + BlobAvatarAccessories.drawAccessory( + drawScope = drawScope, + accessoryType = renderContext.mood.accessoryType, + center = dimensions.center, + bodyWidth = dimensions.bodyWidth, + bodyHeight = dimensions.bodyHeight, + w = dimensions.w, + mood = renderContext.mood, + ) + } + + private fun drawClayMochiBody( + drawScope: DrawScope, + dimensions: CharacterDimensions, + palette: BlobAvatarPalette, + ) { + val center = dimensions.center + val bodyWidth = dimensions.bodyWidth + val bodyHeight = dimensions.bodyHeight + val cornerRadius = CornerRadius(bodyWidth * 0.46f, bodyHeight * 0.46f) + val bodyRect = RoundRect( + left = center.x - (bodyWidth / 2f), + top = center.y - (bodyHeight / 2f), + right = center.x + (bodyWidth / 2f), + bottom = center.y + (bodyHeight / 2f), + cornerRadius = cornerRadius, + ) + val bodyPath = Path().apply { addRoundRect(bodyRect) } + + val lightSource = Offset(center.x - (bodyWidth * 0.20f), center.y - (bodyHeight * 0.22f)) + val bodyBrush = Brush.radialGradient( + colors = listOf( + Color.White, + palette.bodyBase, + palette.bodyShadow, + ), + center = lightSource, + radius = bodyWidth * 0.85f, + ) + drawScope.drawPath(path = bodyPath, brush = bodyBrush) + + val bounceBrush = Brush.linearGradient( + colors = listOf(Color.Transparent, palette.bodyBounce), + start = Offset(center.x, center.y), + end = Offset(center.x, center.y + (bodyHeight / 2f)), + ) + drawScope.drawPath(path = bodyPath, brush = bounceBrush) + + val specWidth = bodyWidth * 0.34f + val specHeight = bodyHeight * 0.14f + val specCenter = Offset(center.x - (bodyWidth * 0.18f), center.y - (bodyHeight * 0.26f)) + drawScope.drawOval( + brush = Brush.radialGradient( + colors = listOf( + Color.White.copy(alpha = 0.70f), + Color.White.copy(alpha = 0.25f), + Color.Transparent, + ), + center = specCenter, + radius = specWidth / 2f, + ), + topLeft = Offset(specCenter.x - (specWidth / 2f), specCenter.y - (specHeight / 2f)), + size = Size(specWidth, specHeight), + ) + } + + private fun drawHeadband( + drawScope: DrawScope, + dimensions: CharacterDimensions, + bandColor: Color, + ) { + val center = dimensions.center + val bodyWidth = dimensions.bodyWidth + val bodyHeight = dimensions.bodyHeight + val w = dimensions.w + val bandTopY = BlobAvatarGeometry.computeHeadbandTopY(center.y, bodyHeight) + val bandLeftX = center.x - (bodyWidth * 0.44f) + val bandRightX = center.x + (bodyWidth * 0.44f) + val bandStartY = center.y - (bodyHeight * 0.10f) + + val bandPath = Path().apply { + moveTo(bandLeftX, bandStartY) + cubicTo(bandLeftX, bandTopY, bandRightX, bandTopY, bandRightX, bandStartY) + } + + val bandStroke = BlobAvatarGeometry.computeHeadbandStroke(w) + + drawScope.drawPath( + path = bandPath, + color = bandColor, + style = Stroke(width = bandStroke, cap = StrokeCap.Round, join = StrokeJoin.Round), + ) + + drawScope.drawPath( + path = bandPath, + color = Color.White.copy(alpha = 0.45f), + style = Stroke(width = bandStroke * 0.30f, cap = StrokeCap.Round, join = StrokeJoin.Round), + ) + } + + private fun drawCozyHeadphones( + drawScope: DrawScope, + dimensions: CharacterDimensions, + palette: BlobAvatarPalette, + bassScale: Float, + ) { + val center = dimensions.center + val bodyWidth = dimensions.bodyWidth + val bodyHeight = dimensions.bodyHeight + val w = dimensions.w + val cupCenterY = center.y - (bodyHeight * 0.02f) + val (cupSpacingX, baseCupWidth) = BlobAvatarGeometry.computeCupParams(w, bodyWidth, bassScale) + val cupHeight = w * BlobAvatarGeometry.CUP_HEIGHT_RATIO * bassScale + + val ears = listOf( + Pair(center.x - cupSpacingX, -4f), + Pair(center.x + cupSpacingX, 4f), + ) + + for ((cupX, tilt) in ears) { + drawScope.rotate(degrees = tilt, pivot = Offset(cupX, cupCenterY)) { + val cushionRect = RoundRect( + left = cupX - (baseCupWidth * 0.48f), + top = cupCenterY - (cupHeight * 0.48f), + right = cupX + (baseCupWidth * 0.48f), + bottom = cupCenterY + (cupHeight * 0.48f), + cornerRadius = CornerRadius(baseCupWidth * 0.44f, baseCupWidth * 0.44f), + ) + drawRoundRect( + color = palette.cushionColor, + topLeft = Offset(cushionRect.left, cushionRect.top), + size = Size(cushionRect.width, cushionRect.height), + cornerRadius = CornerRadius(baseCupWidth * 0.44f, baseCupWidth * 0.44f), + ) + + val shellWidth = baseCupWidth * 0.78f + val shellHeight = cupHeight * 0.88f + val shellLeft = if (cupX < center.x) cupX - (baseCupWidth * 0.50f) else cupX - (shellWidth * 0.46f) + val shellTop = cupCenterY - (shellHeight * 0.50f) + val shellCenter = Offset(shellLeft + (shellWidth / 2f), shellTop + (shellHeight / 2f)) + + val shellBrush = Brush.radialGradient( + colors = listOf( + Color.White.copy(alpha = 0.60f), + palette.headphoneCup, + palette.headphoneCup.copy(alpha = 0.85f), + ), + center = Offset(shellCenter.x - (shellWidth * 0.18f), shellCenter.y - (shellHeight * 0.18f)), + radius = shellWidth * 0.75f, + ) + + drawRoundRect( + brush = shellBrush, + topLeft = Offset(shellLeft, shellTop), + size = Size(shellWidth, shellHeight), + cornerRadius = CornerRadius(shellWidth * 0.44f, shellWidth * 0.44f), + ) + + drawRoundRect( + color = Color.White.copy(alpha = 0.50f), + topLeft = Offset(shellLeft, shellTop), + size = Size(shellWidth, shellHeight), + cornerRadius = CornerRadius(shellWidth * 0.44f, shellWidth * 0.44f), + style = Stroke(width = (w * 0.015f).coerceAtLeast(1f)), + ) + + val dotRadius = shellWidth * 0.22f + drawCircle(color = palette.headphoneAccent, radius = dotRadius, center = shellCenter) + drawCircle(color = Color.White.copy(alpha = 0.75f), radius = dotRadius * 0.45f, center = shellCenter) + } + } + } + + private fun drawRosyBlush( + drawScope: DrawScope, + dimensions: CharacterDimensions, + blushColor: Color, + ) { + val center = dimensions.center + val bodyWidth = dimensions.bodyWidth + val bodyHeight = dimensions.bodyHeight + val w = dimensions.w + val blushY = center.y + (bodyHeight * 0.10f) + val (blushSpacing, blushRadius) = BlobAvatarGeometry.computeBlushParams(w, bodyWidth) + + for (sign in listOf(-1f, 1f)) { + val blushCenter = Offset(center.x + (sign * blushSpacing), blushY) + drawScope.drawCircle( + brush = Brush.radialGradient( + colors = listOf(blushColor, blushColor.copy(alpha = 0.22f), Color.Transparent), + center = blushCenter, + radius = blushRadius, + ), + radius = blushRadius, + center = blushCenter, + ) + } + } + + private fun drawKawaiiEyes( + drawScope: DrawScope, + dimensions: CharacterDimensions, + animState: BlobAvatarAnimationState, + eyeColor: Color, + expression: BlobFacialExpression, + ) { + val center = dimensions.center + val bodyWidth = dimensions.bodyWidth + val bodyHeight = dimensions.bodyHeight + val w = dimensions.w + val eyeSpacing = bodyWidth * BlobAvatarGeometry.EYE_SPACING_RATIO + val eyeCenterY = center.y - (bodyHeight * 0.02f) + val (eyeWidth, eyeHeight) = BlobAvatarGeometry.computeEyeDimensions(w) + val strokeWidth = BlobAvatarGeometry.computeStrokeWidth(w) + val eyeOpenScale = if (expression.isWinking) 0.08f else animState.eyeOpenScale.value + + val (maxShiftX, maxShiftY) = BlobAvatarGeometry.computeEyeMaxShifts(eyeWidth, eyeHeight) + val shiftX = maxShiftX * animState.pupilLookRatioX.value + val shiftY = maxShiftY * animState.pupilLookRatioY.value + + val eyes = listOf( + Pair(Offset(center.x - eyeSpacing, eyeCenterY), expression.isClosed), + Pair(Offset(center.x + eyeSpacing, eyeCenterY), expression.isClosed || expression.isWinking), + ) + + for ((eyeCenter, isThisClosedOrWinking) in eyes) { + val currentScale = if (isThisClosedOrWinking) 0.08f else eyeOpenScale + if (currentScale > 0.25f) { + val currentEyeHeight = eyeHeight * currentScale + val currentEyeTop = eyeCenter.y - (currentEyeHeight / 2f) + (shiftY * currentScale) + val currentEyeLeft = eyeCenter.x - (eyeWidth / 2f) + shiftX + + drawScope.drawOval( + color = eyeColor, + topLeft = Offset(currentEyeLeft, currentEyeTop), + size = Size(eyeWidth, currentEyeHeight), + ) + + val mainGlintRadius = eyeWidth * 0.36f * currentScale + drawScope.drawCircle( + color = Color.White, + radius = mainGlintRadius, + center = Offset( + currentEyeLeft + (eyeWidth * 0.65f), + currentEyeTop + (currentEyeHeight * 0.32f), + ), + ) + + val secGlintRadius = eyeWidth * 0.18f * currentScale + drawScope.drawCircle( + color = Color.White.copy(alpha = 0.90f), + radius = secGlintRadius, + center = Offset( + currentEyeLeft + (eyeWidth * 0.32f), + currentEyeTop + (currentEyeHeight * 0.72f), + ), + ) + } else { + drawScope.drawArc( + color = eyeColor, + startAngle = 200f, + sweepAngle = 140f, + useCenter = false, + topLeft = Offset(eyeCenter.x - (eyeWidth * 0.90f), eyeCenter.y - (eyeHeight * 0.30f)), + size = Size(eyeWidth * 1.8f, eyeHeight * 0.75f), + style = Stroke(width = strokeWidth, cap = StrokeCap.Round), + ) + } + } + } + + private fun drawSweetSmile( + drawScope: DrawScope, + dimensions: CharacterDimensions, + smileColor: Color, + isLaughing: Boolean, + ) { + val center = dimensions.center + val bodyHeight = dimensions.bodyHeight + val w = dimensions.w + val smileWidth = w * (if (isLaughing) 0.16f else 0.12f) + val smileHeight = w * (if (isLaughing) 0.10f else 0.065f) + val strokeWidth = (w * 0.026f).coerceAtLeast(1.5f) + + if (isLaughing) { + val mouthTopY = center.y + (bodyHeight * 0.09f) + val mouthPath = Path().apply { + moveTo(center.x - (smileWidth / 2f), mouthTopY) + lineTo(center.x + (smileWidth / 2f), mouthTopY) + cubicTo( + center.x + (smileWidth / 2f), + mouthTopY + smileHeight, + center.x - (smileWidth / 2f), + mouthTopY + smileHeight, + center.x - (smileWidth / 2f), + mouthTopY, + ) + close() + } + drawScope.drawPath(path = mouthPath, color = smileColor) + val tongueRadius = smileWidth * 0.26f + drawScope.drawCircle( + color = Color(0xFFFF5277), + radius = tongueRadius, + center = Offset(center.x, mouthTopY + (smileHeight * 0.65f)), + ) + } else { + drawScope.drawArc( + color = smileColor, + startAngle = 20f, + sweepAngle = 140f, + useCenter = false, + topLeft = Offset(center.x - (smileWidth / 2f), center.y + (bodyHeight * 0.10f)), + size = Size(smileWidth, smileHeight), + style = Stroke(width = strokeWidth, cap = StrokeCap.Round), + ) + } + } +} diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarEnvironments.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarEnvironments.kt new file mode 100644 index 000000000..869949b92 --- /dev/null +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarEnvironments.kt @@ -0,0 +1,411 @@ +package cx.aswin.boxlore.feature.settings.pages + +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.rotate +import kotlin.math.cos +import kotlin.math.sin + +internal object BlobAvatarEnvironments { + + fun drawEnvironment( + drawScope: DrawScope, + environmentType: BlobAvatarGenreMood.EnvironmentType, + center: Offset, + w: Float, + h: Float, + mood: BlobAvatarGenreMood, + progress: Float, + ) { + when (environmentType) { + BlobAvatarGenreMood.EnvironmentType.VenetianBlinds -> + drawVenetianBlinds(drawScope, w, h, mood) + BlobAvatarGenreMood.EnvironmentType.CyberMatrix -> + drawCyberMatrix(drawScope, w, h, mood, progress) + BlobAvatarGenreMood.EnvironmentType.StadiumLights -> + drawStadiumLights(drawScope, w, h, mood) + BlobAvatarGenreMood.EnvironmentType.OnAirSign -> + drawOnAirSign(drawScope, w, h, mood) + BlobAvatarGenreMood.EnvironmentType.LofiBokeh -> + drawLofiBokeh(drawScope, w, h, mood, progress) + BlobAvatarGenreMood.EnvironmentType.ComedyStage -> + drawComedyStage(drawScope, center, w, h, mood) + BlobAvatarGenreMood.EnvironmentType.MysticRunes -> + drawMysticRunes(drawScope, center, w, h, mood, progress) + } + } + + private fun drawVenetianBlinds( + drawScope: DrawScope, + w: Float, + h: Float, + mood: BlobAvatarGenreMood, + ) { + // Flashlight beam from upper right + val lightOrigin = Offset(w * 0.92f, h * 0.08f) + drawScope.drawCircle( + brush = Brush.radialGradient( + colors = listOf( + mood.keyLightColor.copy(alpha = 0.26f), + mood.keyLightColor.copy(alpha = 0.08f), + Color.Transparent, + ), + center = lightOrigin, + radius = w * 0.85f, + ), + radius = w * 0.85f, + center = lightOrigin, + ) + + // Film-noir venetian blind shadow slats + val slatCount = 5 + val slatHeight = h * 0.06f + val slatSpacing = h * 0.18f + for (i in 0 until slatCount) { + val y = (i * slatSpacing) + (h * 0.04f) + drawScope.drawRoundRect( + color = Color.Black.copy(alpha = 0.22f), + topLeft = Offset(0f, y), + size = Size(w, slatHeight), + cornerRadius = CornerRadius(2f, 2f), + ) + } + } + + private fun drawCyberMatrix( + drawScope: DrawScope, + w: Float, + h: Float, + mood: BlobAvatarGenreMood, + progress: Float, + ) { + // Digital grid dots + val cols = 5 + val rows = 5 + val stepX = w / (cols + 1) + val stepY = h / (rows + 1) + for (r in 1..rows) { + for (c in 1..cols) { + drawScope.drawCircle( + color = mood.keyLightColor.copy(alpha = 0.16f), + radius = (w * 0.009f).coerceAtLeast(1.2f), + center = Offset(c * stepX, r * stepY), + ) + } + } + + // Horizontal audio laser scanline + val scanY = h * progress + drawScope.drawLine( + brush = Brush.horizontalGradient( + colors = listOf( + Color.Transparent, + mood.rimLightColor.copy(alpha = 0.55f), + Color.White.copy(alpha = 0.80f), + mood.rimLightColor.copy(alpha = 0.55f), + Color.Transparent, + ), + ), + start = Offset(0f, scanY), + end = Offset(w, scanY), + strokeWidth = (w * 0.012f).coerceAtLeast(1f), + ) + + // Tech HUD corner brackets + val bracketSize = w * 0.10f + val bracketStroke = (w * 0.012f).coerceAtLeast(1f) + val bracketColor = mood.keyLightColor.copy(alpha = 0.40f) + drawScope.drawLine(bracketColor, Offset(w * 0.06f, w * 0.06f), Offset(w * 0.06f + bracketSize, w * 0.06f), bracketStroke) + drawScope.drawLine(bracketColor, Offset(w * 0.06f, w * 0.06f), Offset(w * 0.06f, w * 0.06f + bracketSize), bracketStroke) + drawScope.drawLine(bracketColor, Offset(w * 0.94f, h * 0.94f), Offset(w * 0.94f - bracketSize, h * 0.94f), bracketStroke) + drawScope.drawLine(bracketColor, Offset(w * 0.94f, h * 0.94f), Offset(w * 0.94f, h * 0.94f - bracketSize), bracketStroke) + } + + private fun drawStadiumLights( + drawScope: DrawScope, + w: Float, + h: Float, + mood: BlobAvatarGenreMood, + ) { + val lightRadius = w * 0.024f + val lightColor = mood.keyLightColor + + // Top-left and Top-right stadium floodlight clusters + val towers = listOf(Offset(w * 0.12f, h * 0.10f), Offset(w * 0.88f, h * 0.10f)) + for (tower in towers) { + // Glow halo + drawScope.drawCircle( + brush = Brush.radialGradient( + colors = listOf(lightColor.copy(alpha = 0.35f), Color.Transparent), + center = tower, + radius = w * 0.35f, + ), + radius = w * 0.35f, + center = tower, + ) + // Cluster of 3 bulbs + for (dx in listOf(-lightRadius * 1.5f, 0f, lightRadius * 1.5f)) { + drawScope.drawCircle(color = Color.White, radius = lightRadius, center = Offset(tower.x + dx, tower.y)) + drawScope.drawCircle( + color = lightColor, + radius = lightRadius * 1.25f, + center = Offset(tower.x + dx, tower.y), + style = Stroke(width = (w * 0.008f).coerceAtLeast(1f)), + ) + } + } + + // Floating victory confetti + val confettiColors = listOf(Color(0xFFFFC107), Color(0xFF2979FF), Color(0xFFEF4444), Color(0xFF10B981)) + val confettiPositions = listOf( + Offset(w * 0.20f, h * 0.35f), + Offset(w * 0.80f, h * 0.30f), + Offset(w * 0.25f, h * 0.70f), + Offset(w * 0.75f, h * 0.65f), + ) + for ((idx, pos) in confettiPositions.withIndex()) { + drawScope.rotate(degrees = (idx * 35f) + 15f, pivot = pos) { + drawRoundRect( + color = confettiColors[idx % confettiColors.size].copy(alpha = 0.75f), + topLeft = Offset(pos.x - (w * 0.02f), pos.y - (w * 0.01f)), + size = Size(w * 0.04f, w * 0.02f), + cornerRadius = CornerRadius(2f, 2f), + ) + } + } + } + + private fun drawOnAirSign( + drawScope: DrawScope, + w: Float, + h: Float, + mood: BlobAvatarGenreMood, + ) { + // Acoustic damping tile mesh pattern in corners + val meshColor = mood.rimLightColor.copy(alpha = 0.18f) + val meshStroke = (w * 0.010f).coerceAtLeast(1f) + for (i in 1..3) { + val offset = i * (w * 0.06f) + drawScope.drawLine(meshColor, Offset(0f, offset), Offset(offset, 0f), meshStroke) + drawScope.drawLine(meshColor, Offset(w - offset, 0f), Offset(w, offset), meshStroke) + } + + // Glowing "ON AIR" studio pill badge at top center + val badgeWidth = w * 0.46f + val badgeHeight = h * 0.12f + val badgeLeft = (w - badgeWidth) / 2f + val badgeTop = h * 0.04f + val badgeCenter = Offset(w / 2f, badgeTop + (badgeHeight / 2f)) + + // Neon Red Halo + drawScope.drawCircle( + brush = Brush.radialGradient( + colors = listOf(Color(0xFFEF4444).copy(alpha = 0.35f), Color.Transparent), + center = badgeCenter, + radius = badgeWidth * 0.80f, + ), + radius = badgeWidth * 0.80f, + center = badgeCenter, + ) + + // Badge pill body + drawScope.drawRoundRect( + color = Color(0xFF1E293B), + topLeft = Offset(badgeLeft, badgeTop), + size = Size(badgeWidth, badgeHeight), + cornerRadius = CornerRadius(badgeHeight * 0.35f, badgeHeight * 0.35f), + ) + // Red glowing border + drawScope.drawRoundRect( + color = Color(0xFFEF4444), + topLeft = Offset(badgeLeft, badgeTop), + size = Size(badgeWidth, badgeHeight), + cornerRadius = CornerRadius(badgeHeight * 0.35f, badgeHeight * 0.35f), + style = Stroke(width = (w * 0.015f).coerceAtLeast(1.5f)), + ) + + // Studio Live Dot + "ON AIR" Indicator bar + val liveDotRadius = badgeHeight * 0.22f + val dotCenter = Offset(badgeLeft + (badgeWidth * 0.22f), badgeCenter.y) + drawScope.drawCircle(color = Color(0xFFEF4444), radius = liveDotRadius, center = dotCenter) + drawScope.drawCircle(color = Color.White, radius = liveDotRadius * 0.45f, center = dotCenter) + + // Broadcast horizontal status wave + drawScope.drawLine( + color = Color.White.copy(alpha = 0.90f), + start = Offset(badgeLeft + (badgeWidth * 0.40f), badgeCenter.y), + end = Offset(badgeLeft + (badgeWidth * 0.82f), badgeCenter.y), + strokeWidth = (w * 0.016f).coerceAtLeast(1.5f), + cap = StrokeCap.Round, + ) + } + + private fun drawLofiBokeh( + drawScope: DrawScope, + w: Float, + h: Float, + mood: BlobAvatarGenreMood, + progress: Float, + ) { + // Floating fairy light bokeh orbs + val orbs = listOf( + Triple(0.20f, 0.25f, 0.12f), + Triple(0.80f, 0.20f, 0.15f), + Triple(0.15f, 0.75f, 0.14f), + Triple(0.85f, 0.70f, 0.11f), + Triple(0.50f, 0.88f, 0.13f), + ) + for ((idx, orb) in orbs.withIndex()) { + val (baseX, baseY, radiusRatio) = orb + val floatY = (baseY * h) + (sin((progress * 2f * Math.PI) + idx).toFloat() * (h * 0.03f)) + val center = Offset(baseX * w, floatY) + val orbRadius = w * radiusRatio + val orbColor = if (idx % 2 == 0) mood.keyLightColor else mood.rimLightColor + + drawScope.drawCircle( + brush = Brush.radialGradient( + colors = listOf(orbColor.copy(alpha = 0.28f), orbColor.copy(alpha = 0.08f), Color.Transparent), + center = center, + radius = orbRadius, + ), + radius = orbRadius, + center = center, + ) + } + + // Cozy angled lofi rain streaks + val rainColor = Color.White.copy(alpha = 0.18f) + val rainStroke = (w * 0.010f).coerceAtLeast(1f) + val rainX = listOf(w * 0.25f, w * 0.45f, w * 0.70f) + for (rx in rainX) { + drawScope.drawLine( + color = rainColor, + start = Offset(rx, h * 0.15f), + end = Offset(rx - (w * 0.06f), h * 0.35f), + strokeWidth = rainStroke, + cap = StrokeCap.Round, + ) + } + } + + private fun drawComedyStage( + drawScope: DrawScope, + center: Offset, + w: Float, + h: Float, + mood: BlobAvatarGenreMood, + ) { + // Stage warm spotlight circle on the mascot + drawScope.drawCircle( + brush = Brush.radialGradient( + colors = listOf( + mood.keyLightColor.copy(alpha = 0.35f), + mood.keyLightColor.copy(alpha = 0.12f), + Color.Transparent, + ), + center = center, + radius = w * 0.55f, + ), + radius = w * 0.55f, + center = center, + ) + + // Top comedy curtain swag curves (Left & Right) + val curtainColor = Color(0xFF881337).copy(alpha = 0.85f) + val leftDrape = Path().apply { + moveTo(0f, 0f) + lineTo(w * 0.32f, 0f) + cubicTo(w * 0.22f, h * 0.18f, w * 0.06f, h * 0.16f, 0f, h * 0.12f) + close() + } + val rightDrape = Path().apply { + moveTo(w, 0f) + lineTo(w * 0.68f, 0f) + cubicTo(w * 0.78f, h * 0.18f, w * 0.94f, h * 0.16f, w, h * 0.12f) + close() + } + drawScope.drawPath(path = leftDrape, color = curtainColor) + drawScope.drawPath(path = rightDrape, color = curtainColor) + + // Laugh sparkles + val sparkleX = listOf(w * 0.20f, w * 0.82f) + val sparkleY = listOf(h * 0.30f, h * 0.28f) + for (i in sparkleX.indices) { + drawScope.drawCircle(color = Color(0xFFFFD700), radius = w * 0.022f, center = Offset(sparkleX[i], sparkleY[i])) + drawScope.drawCircle(color = Color.White, radius = w * 0.010f, center = Offset(sparkleX[i], sparkleY[i])) + } + } + + private fun drawMysticRunes( + drawScope: DrawScope, + center: Offset, + w: Float, + h: Float, + mood: BlobAvatarGenreMood, + progress: Float, + ) { + // Rotating arcane rune ring around the mascot + val runeRadius = w * 0.44f + val rotationAngle = progress * 360f + + drawScope.rotate(degrees = rotationAngle, pivot = center) { + drawCircle( + color = mood.rimLightColor.copy(alpha = 0.30f), + radius = runeRadius, + center = center, + style = Stroke(width = (w * 0.010f).coerceAtLeast(1f)), + ) + + // Arcane rune tick marks + val tickCount = 8 + val tickAngle = 360f / tickCount + for (i in 0 until tickCount) { + val rad = Math.toRadians((i * tickAngle).toDouble()) + val innerR = runeRadius - (w * 0.035f) + val outerR = runeRadius + (w * 0.035f) + val start = Offset(center.x + (innerR * cos(rad)).toFloat(), center.y + (innerR * sin(rad)).toFloat()) + val end = Offset(center.x + (outerR * cos(rad)).toFloat(), center.y + (outerR * sin(rad)).toFloat()) + drawLine( + color = mood.rimLightColor.copy(alpha = 0.50f), + start = start, + end = end, + strokeWidth = (w * 0.012f).coerceAtLeast(1f), + cap = StrokeCap.Round, + ) + } + } + + // Floating campfire embers + val embers = listOf( + Triple(0.25f, 0.80f, 0.40f), + Triple(0.72f, 0.75f, 0.65f), + Triple(0.35f, 0.60f, 0.85f), + Triple(0.65f, 0.55f, 0.20f), + ) + for ((idx, ember) in embers.withIndex()) { + val (baseX, startY, phaseOffset) = ember + val phase = (progress + phaseOffset) % 1f + val emberY = (startY * h) - (phase * h * 0.40f) + val emberX = (baseX * w) + (sin((phase * 2f * Math.PI) + idx).toFloat() * (w * 0.04f)) + val emberAlpha = (sin(phase * Math.PI).toFloat() * 0.85f).coerceIn(0f, 1f) + + drawScope.drawCircle( + color = mood.keyLightColor.copy(alpha = emberAlpha), + radius = (w * 0.020f).coerceAtLeast(1.5f), + center = Offset(emberX, emberY), + ) + drawScope.drawCircle( + color = Color.White.copy(alpha = emberAlpha), + radius = (w * 0.008f).coerceAtLeast(1f), + center = Offset(emberX, emberY), + ) + } + } +} diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarGenreMood.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarGenreMood.kt index 88d552ad1..703b37cb2 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarGenreMood.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarGenreMood.kt @@ -4,65 +4,88 @@ import androidx.compose.ui.graphics.Color import kotlin.random.Random /** - * Visual themes and micro-animation styles representing classic podcast genres. - * Designed to be randomized or cycled interactively, and later bound to a user's - * top subscribed genre. + * Visual themes, physical accessories, and environmental backdrops representing classic podcast genres. + * Designed to be randomized or cycled interactively, and later bound to a user's top subscribed genre. */ internal enum class BlobAvatarGenreMood( val id: String, val displayName: String, + val emoji: String, val keyLightColor: Color, val rimLightColor: Color, val particleType: ParticleType, + val accessoryType: AccessoryType, + val environmentType: EnvironmentType, ) { ChillMusic( id = "chill_music", displayName = "Chill Music", + emoji = "🎧", keyLightColor = Color(0xFF8A5CF6), // Neon Violet rimLightColor = Color(0xFF00E5FF), // Electric Cyan particleType = ParticleType.MusicNote, + accessoryType = AccessoryType.CozyBeanie, + environmentType = EnvironmentType.LofiBokeh, ), TrueCrime( id = "true_crime", displayName = "True Crime", + emoji = "🕵️", keyLightColor = Color(0xFF00E5FF), // Mystery Cyan rimLightColor = Color(0xFF1E40AF), // Deep Sapphire particleType = ParticleType.RadarPulse, + accessoryType = AccessoryType.DetectiveFedora, + environmentType = EnvironmentType.VenetianBlinds, ), TechSciFi( id = "tech_scifi", displayName = "Tech & Sci-Fi", + emoji = "🤖", keyLightColor = Color(0xFF00F5D4), // Aqua Mint rimLightColor = Color(0xFF00E676), // Neon Emerald particleType = ParticleType.EqualizerBar, + accessoryType = AccessoryType.CyberVisor, + environmentType = EnvironmentType.CyberMatrix, ), ComedyTalk( id = "comedy_talk", displayName = "Comedy & Talk", + emoji = "😂", keyLightColor = Color(0xFFFFB300), // Warm Amber rimLightColor = Color(0xFFFF4081), // Candy Coral particleType = ParticleType.LaughSparkle, + accessoryType = AccessoryType.StarSunglasses, + environmentType = EnvironmentType.ComedyStage, ), LoreStories( id = "lore_stories", displayName = "Lore & Stories", + emoji = "🧙", keyLightColor = Color(0xFFFF9E00), // Campfire Gold rimLightColor = Color(0xFF7C3AED), // Starry Violet particleType = ParticleType.StoryEmber, + accessoryType = AccessoryType.WizardHat, + environmentType = EnvironmentType.MysticRunes, ), NewsBriefing( id = "news_briefing", displayName = "News & Briefing", + emoji = "🎙️", keyLightColor = Color(0xFFFF3366), // Studio Crimson rimLightColor = Color(0xFF80DEEA), // Ice Cyan particleType = ParticleType.BroadcastArc, + accessoryType = AccessoryType.BroadcastBoomMic, + environmentType = EnvironmentType.OnAirSign, ), SportsAthletics( id = "sports_athletics", displayName = "Sports & Athletics", + emoji = "🏃", keyLightColor = Color(0xFFFFC107), // Stadium Amber rimLightColor = Color(0xFF2979FF), // Electric Blue particleType = ParticleType.EnergySpark, + accessoryType = AccessoryType.AthleticSweatband, + environmentType = EnvironmentType.StadiumLights, ); enum class ParticleType { @@ -75,6 +98,26 @@ internal enum class BlobAvatarGenreMood( EnergySpark, } + enum class AccessoryType { + DetectiveFedora, + CyberVisor, + AthleticSweatband, + BroadcastBoomMic, + CozyBeanie, + StarSunglasses, + WizardHat, + } + + enum class EnvironmentType { + VenetianBlinds, + CyberMatrix, + StadiumLights, + OnAirSign, + LofiBokeh, + ComedyStage, + MysticRunes, + } + companion object { fun random(random: Random = Random): BlobAvatarGenreMood { val all = entries diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatarTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatarTest.kt index 3707ff299..152620aa0 100644 --- a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatarTest.kt +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatarTest.kt @@ -155,6 +155,7 @@ class AnimatedBlobAvatarTest { for (mood in moods) { assertTrue("Mood ID must not be blank", mood.id.isNotBlank()) assertTrue("Display name must not be blank", mood.displayName.isNotBlank()) + assertTrue("Emoji must not be blank", mood.emoji.isNotBlank()) val nextMood = BlobAvatarGenreMood.next(mood) assertTrue("Next mood must be valid", moods.contains(nextMood)) } @@ -166,4 +167,17 @@ class AnimatedBlobAvatarTest { } assertEquals("Full cycle must return to start", moods.first(), current) } + + @Test + fun blobAvatar_moodAccessoriesAndEnvironments_areUniqueAndConfigured() { + val moods = BlobAvatarGenreMood.entries + val accessories = moods.map { it.accessoryType }.toSet() + val environments = moods.map { it.environmentType }.toSet() + + assertEquals("Each of the 7 moods must have a unique accessory", 7, accessories.size) + assertEquals("Each of the 7 moods must have a unique environment backdrop", 7, environments.size) + + assertEquals(7, BlobAvatarGenreMood.AccessoryType.entries.size) + assertEquals(7, BlobAvatarGenreMood.EnvironmentType.entries.size) + } } From 35c1d28bea01bf150e0594131b64b9797b38475a Mon Sep 17 00:00:00 2001 From: Aswin C Date: Sat, 26 Sep 2026 21:11:01 +0530 Subject: [PATCH 17/21] fix(settings): intensify avatar backdrop glow and add contour outline for light themes --- .../settings/pages/AnimatedBlobAvatar.kt | 39 +++++++++++++++---- .../settings/pages/BlobAvatarCharacter.kt | 6 +++ 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatar.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatar.kt index b3b9ccb21..f410e92d3 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatar.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatar.kt @@ -217,6 +217,7 @@ internal data class BlobAvatarPalette( val bodyBase: Color, val bodyShadow: Color, val bodyBounce: Color, + val bodyOutline: Color, val facialColor: Color, val blushColor: Color, val headbandColor: Color, @@ -231,24 +232,28 @@ internal fun resolveAvatarPalette( genreMood: BlobAvatarGenreMood, isDark: Boolean, ): BlobAvatarPalette { - val primaryColor = MaterialTheme.colorScheme.primary - val bodyBase = if (isDark) { Color(0xFFF1F5F9) } else { - Color(0xFFFFFDF8) + Color(0xFFF8FAFC) } val bodyShadow = if (isDark) { Color(0xFFCBD5E1) } else { - Color(0xFFE2E8F0) + Color(0xFFCBD5E1) } val bodyBounce = if (isDark) { genreMood.rimLightColor.copy(alpha = 0.38f) } else { - primaryColor.copy(alpha = 0.20f) + genreMood.rimLightColor.copy(alpha = 0.34f) + } + + val bodyOutline = if (isDark) { + Color.White.copy(alpha = 0.18f) + } else { + Color(0xFF94A3B8).copy(alpha = 0.45f) } val facialColor = Color(0xFF0F172A) @@ -258,12 +263,13 @@ internal fun resolveAvatarPalette( val cushionColor = if (isDark) Color(0xFF0F172A) else Color(0xFF1E293B) val headphoneCup = genreMood.keyLightColor val headphoneAccent = genreMood.rimLightColor - val shadowAlpha = if (isDark) 0.22f else 0.18f + val shadowAlpha = if (isDark) 0.22f else 0.26f return BlobAvatarPalette( bodyBase = bodyBase, bodyShadow = bodyShadow, bodyBounce = bodyBounce, + bodyOutline = bodyOutline, facialColor = facialColor, blushColor = blushColor, headbandColor = headbandColor, @@ -438,8 +444,25 @@ private fun DrawScope.drawDualSpotlights( h: Float, isDark: Boolean, ) { - val keyAlpha = if (isDark) 0.32f else 0.16f - val rimAlpha = if (isDark) 0.28f else 0.14f + val auraAlpha = if (isDark) 0.22f else 0.38f + val keyAlpha = if (isDark) 0.35f else 0.42f + val rimAlpha = if (isDark) 0.30f else 0.38f + + // Ambient backlight halo behind the mascot to create sharp contrast and luminous definition against light surfaces + val auraOrigin = Offset(w / 2f, h / 2f) + drawCircle( + brush = Brush.radialGradient( + colors = listOf( + mood.keyLightColor.copy(alpha = auraAlpha), + mood.rimLightColor.copy(alpha = auraAlpha * 0.65f), + Color.Transparent, + ), + center = auraOrigin, + radius = w * 0.52f, + ), + radius = w * 0.52f, + center = auraOrigin, + ) val keyLightOrigin = Offset(w * 0.15f, h * 0.10f) drawCircle( diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarCharacter.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarCharacter.kt index 1008627d4..999e212f9 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarCharacter.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarCharacter.kt @@ -82,6 +82,12 @@ internal object BlobAvatarCharacter { ) drawScope.drawPath(path = bodyPath, brush = bounceBrush) + drawScope.drawPath( + path = bodyPath, + color = palette.bodyOutline, + style = Stroke(width = (dimensions.w * 0.008f).coerceAtLeast(1f)), + ) + val specWidth = bodyWidth * 0.34f val specHeight = bodyHeight * 0.14f val specCenter = Offset(center.x - (bodyWidth * 0.18f), center.y - (bodyHeight * 0.26f)) From a552feb3315557388624cb3d206b7e797b6dfc01 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Sat, 26 Sep 2026 21:19:19 +0530 Subject: [PATCH 18/21] feat(settings): refine avatar body shading, cute comedy state, and remove chill music cap --- .../settings/pages/AnimatedBlobAvatar.kt | 16 -- .../settings/pages/BlobAvatarAccessories.kt | 143 +++++++----------- .../settings/pages/BlobAvatarCharacter.kt | 42 ++--- .../settings/pages/BlobAvatarEnvironments.kt | 12 +- .../settings/pages/BlobAvatarGenreMood.kt | 23 ++- .../settings/pages/AnimatedBlobAvatarTest.kt | 14 ++ 6 files changed, 112 insertions(+), 138 deletions(-) diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatar.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatar.kt index f410e92d3..79d90ddae 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatar.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatar.kt @@ -1,6 +1,5 @@ package cx.aswin.boxlore.feature.settings.pages -import android.widget.Toast import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.LinearEasing @@ -39,7 +38,6 @@ import androidx.compose.ui.graphics.drawscope.scale import androidx.compose.ui.graphics.luminance import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -216,7 +214,6 @@ private fun rememberPupilLookAnimations( internal data class BlobAvatarPalette( val bodyBase: Color, val bodyShadow: Color, - val bodyBounce: Color, val bodyOutline: Color, val facialColor: Color, val blushColor: Color, @@ -244,12 +241,6 @@ internal fun resolveAvatarPalette( Color(0xFFCBD5E1) } - val bodyBounce = if (isDark) { - genreMood.rimLightColor.copy(alpha = 0.38f) - } else { - genreMood.rimLightColor.copy(alpha = 0.34f) - } - val bodyOutline = if (isDark) { Color.White.copy(alpha = 0.18f) } else { @@ -268,7 +259,6 @@ internal fun resolveAvatarPalette( return BlobAvatarPalette( bodyBase = bodyBase, bodyShadow = bodyShadow, - bodyBounce = bodyBounce, bodyOutline = bodyOutline, facialColor = facialColor, blushColor = blushColor, @@ -314,7 +304,6 @@ internal fun AnimatedBlobAvatar( val palette = resolveAvatarPalette(activeMood, isDark) val haptic = LocalHapticFeedback.current - val context = LocalContext.current val coroutineScope = rememberCoroutineScope() val squishScaleY = remember { Animatable(1f) } @@ -323,11 +312,6 @@ internal fun AnimatedBlobAvatar( val onTapAvatar = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) activeMood = BlobAvatarGenreMood.next(activeMood) - Toast.makeText( - context, - "${activeMood.emoji} ${activeMood.displayName}", - Toast.LENGTH_SHORT, - ).show() isWinking = true coroutineScope.launch { squishScaleY.snapTo(0.80f) diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarAccessories.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarAccessories.kt index 8cc58656c..30a7b4b93 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarAccessories.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarAccessories.kt @@ -8,7 +8,6 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.rotate @@ -25,6 +24,9 @@ internal object BlobAvatarAccessories { mood: BlobAvatarGenreMood, ) { when (accessoryType) { + BlobAvatarGenreMood.AccessoryType.None -> { + // No accessory - clean headphones & mochi body + } BlobAvatarGenreMood.AccessoryType.DetectiveFedora -> drawDetectiveFedora(drawScope, center, bodyWidth, bodyHeight, w) BlobAvatarGenreMood.AccessoryType.CyberVisor -> @@ -33,10 +35,8 @@ internal object BlobAvatarAccessories { drawAthleticSweatband(drawScope, center, bodyWidth, bodyHeight, w) BlobAvatarGenreMood.AccessoryType.BroadcastBoomMic -> drawBroadcastBoomMic(drawScope, center, bodyWidth, bodyHeight, w) - BlobAvatarGenreMood.AccessoryType.CozyBeanie -> - drawCozyBeanie(drawScope, center, bodyWidth, bodyHeight, w, mood.keyLightColor) - BlobAvatarGenreMood.AccessoryType.StarSunglasses -> - drawStarSunglasses(drawScope, center, bodyWidth, bodyHeight, w) + BlobAvatarGenreMood.AccessoryType.ComedianBowtie -> + drawComedianBowtie(drawScope, center, bodyWidth, bodyHeight, w) BlobAvatarGenreMood.AccessoryType.WizardHat -> drawWizardHat(drawScope, center, bodyWidth, bodyHeight, w) } @@ -282,105 +282,70 @@ internal object BlobAvatarAccessories { ) } - private fun drawCozyBeanie( + private fun drawComedianBowtie( drawScope: DrawScope, center: Offset, bodyWidth: Float, bodyHeight: Float, w: Float, - beanieColor: Color, ) { - val beanieCenterY = center.y - (bodyHeight * 0.35f) - val beanieWidth = bodyWidth * 0.88f - val beanieHeight = bodyHeight * 0.34f + val tieY = center.y + (bodyHeight * 0.42f) + val knotWidth = bodyWidth * 0.12f + val knotHeight = bodyHeight * 0.12f + val wingWidth = bodyWidth * 0.18f + val wingHeight = bodyHeight * 0.18f + val bowtieColor = Color(0xFFFF4081) + + // Left wing + val leftWing = Path().apply { + moveTo(center.x - (knotWidth * 0.40f), tieY) + lineTo(center.x - (knotWidth * 0.40f) - wingWidth, tieY - (wingHeight * 0.48f)) + lineTo(center.x - (knotWidth * 0.40f) - (wingWidth * 0.85f), tieY) + lineTo(center.x - (knotWidth * 0.40f) - wingWidth, tieY + (wingHeight * 0.48f)) + close() + } + drawScope.drawPath(path = leftWing, color = bowtieColor) + drawScope.drawPath( + path = leftWing, + color = Color.White.copy(alpha = 0.50f), + style = Stroke(width = (w * 0.010f).coerceAtLeast(1f)), + ) - // Slouchy Dome - val domePath = Path().apply { - moveTo(center.x - (beanieWidth * 0.44f), beanieCenterY + (beanieHeight * 0.20f)) - cubicTo( - center.x - (beanieWidth * 0.40f), - beanieCenterY - (beanieHeight * 0.85f), - center.x + (beanieWidth * 0.40f), - beanieCenterY - (beanieHeight * 0.85f), - center.x + (beanieWidth * 0.44f), - beanieCenterY + (beanieHeight * 0.20f), - ) + // Right wing + val rightWing = Path().apply { + moveTo(center.x + (knotWidth * 0.40f), tieY) + lineTo(center.x + (knotWidth * 0.40f) + wingWidth, tieY - (wingHeight * 0.48f)) + lineTo(center.x + (knotWidth * 0.40f) + (wingWidth * 0.85f), tieY) + lineTo(center.x + (knotWidth * 0.40f) + wingWidth, tieY + (wingHeight * 0.48f)) close() } + drawScope.drawPath(path = rightWing, color = bowtieColor) drawScope.drawPath( - path = domePath, - brush = Brush.verticalGradient( - colors = listOf(beanieColor, beanieColor.copy(alpha = 0.85f)), - ), + path = rightWing, + color = Color.White.copy(alpha = 0.50f), + style = Stroke(width = (w * 0.010f).coerceAtLeast(1f)), ) - // Folded Ribbed Brim - val brimRect = RoundRect( - left = center.x - (beanieWidth * 0.46f), - top = beanieCenterY + (beanieHeight * 0.10f), - right = center.x + (beanieWidth * 0.46f), - bottom = beanieCenterY + (beanieHeight * 0.36f), - cornerRadius = CornerRadius(beanieHeight * 0.12f, beanieHeight * 0.12f), + // Center knot (soft rounded button) + val knotRect = RoundRect( + left = center.x - (knotWidth / 2f), + top = tieY - (knotHeight / 2f), + right = center.x + (knotWidth / 2f), + bottom = tieY + (knotHeight / 2f), + cornerRadius = CornerRadius(knotHeight * 0.35f, knotHeight * 0.35f), ) drawScope.drawRoundRect( - color = beanieColor.copy(alpha = 0.95f), - topLeft = Offset(brimRect.left, brimRect.top), - size = Size(brimRect.width, brimRect.height), - cornerRadius = CornerRadius(beanieHeight * 0.12f, beanieHeight * 0.12f), + color = Color(0xFFFFD54F), + topLeft = Offset(knotRect.left, knotRect.top), + size = Size(knotRect.width, knotRect.height), + cornerRadius = CornerRadius(knotHeight * 0.35f, knotHeight * 0.35f), ) - - // Fluffy Pom-Pom Ball on Top - val pomPomCenter = Offset(center.x, beanieCenterY - (beanieHeight * 0.65f)) - drawScope.drawCircle(color = Color.White.copy(alpha = 0.90f), radius = w * 0.065f, center = pomPomCenter) - } - - private fun drawStarSunglasses( - drawScope: DrawScope, - center: Offset, - bodyWidth: Float, - bodyHeight: Float, - w: Float, - ) { - val glassY = center.y - (bodyHeight * 0.02f) - val eyeSpacing = bodyWidth * 0.18f - - for (sign in listOf(-1f, 1f)) { - val starCenter = Offset(center.x + (sign * eyeSpacing), glassY) - val starRadius = w * 0.075f - - // 5-Point Star Lens - val starPath = Path() - val points = 5 - val angleStep = Math.PI / points - for (i in 0 until (2 * points)) { - val r = if (i % 2 == 0) starRadius else starRadius * 0.52f - val angle = (i * angleStep) - (Math.PI / 2.0) - val x = starCenter.x + (r * kotlin.math.cos(angle)).toFloat() - val y = starCenter.y + (r * kotlin.math.sin(angle)).toFloat() - if (i == 0) starPath.moveTo(x, y) else starPath.lineTo(x, y) - } - starPath.close() - - drawScope.drawPath(path = starPath, color = Color(0xFF1E293B).copy(alpha = 0.90f)) - drawScope.drawPath( - path = starPath, - color = Color(0xFFFFD700), - style = Stroke(width = (w * 0.016f).coerceAtLeast(1.5f), join = StrokeJoin.Round), - ) - // Lens Glint - drawScope.drawCircle( - color = Color.White.copy(alpha = 0.85f), - radius = starRadius * 0.28f, - center = Offset(starCenter.x + (starRadius * 0.20f), starCenter.y - (starRadius * 0.20f)), - ) - } - - // Bridge connecting lenses - drawScope.drawLine( - color = Color(0xFFFFD700), - start = Offset(center.x - (w * 0.05f), glassY), - end = Offset(center.x + (w * 0.05f), glassY), - strokeWidth = (w * 0.016f).coerceAtLeast(1.5f), + drawScope.drawRoundRect( + color = Color.White.copy(alpha = 0.70f), + topLeft = Offset(knotRect.left, knotRect.top), + size = Size(knotRect.width, knotRect.height), + cornerRadius = CornerRadius(knotHeight * 0.35f, knotHeight * 0.35f), + style = Stroke(width = (w * 0.012f).coerceAtLeast(1f)), ) } diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarCharacter.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarCharacter.kt index 999e212f9..abd47b39b 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarCharacter.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarCharacter.kt @@ -75,13 +75,6 @@ internal object BlobAvatarCharacter { ) drawScope.drawPath(path = bodyPath, brush = bodyBrush) - val bounceBrush = Brush.linearGradient( - colors = listOf(Color.Transparent, palette.bodyBounce), - start = Offset(center.x, center.y), - end = Offset(center.x, center.y + (bodyHeight / 2f)), - ) - drawScope.drawPath(path = bodyPath, brush = bounceBrush) - drawScope.drawPath( path = bodyPath, color = palette.bodyOutline, @@ -260,9 +253,10 @@ internal object BlobAvatarCharacter { val shiftX = maxShiftX * animState.pupilLookRatioX.value val shiftY = maxShiftY * animState.pupilLookRatioY.value + val isClosedOrLaughing = expression.isClosed || expression.isLaughing val eyes = listOf( - Pair(Offset(center.x - eyeSpacing, eyeCenterY), expression.isClosed), - Pair(Offset(center.x + eyeSpacing, eyeCenterY), expression.isClosed || expression.isWinking), + Pair(Offset(center.x - eyeSpacing, eyeCenterY), isClosedOrLaughing), + Pair(Offset(center.x + eyeSpacing, eyeCenterY), isClosedOrLaughing || expression.isWinking), ) for ((eyeCenter, isThisClosedOrWinking) in eyes) { @@ -320,31 +314,37 @@ internal object BlobAvatarCharacter { val center = dimensions.center val bodyHeight = dimensions.bodyHeight val w = dimensions.w - val smileWidth = w * (if (isLaughing) 0.16f else 0.12f) - val smileHeight = w * (if (isLaughing) 0.10f else 0.065f) + val smileWidth = w * (if (isLaughing) 0.15f else 0.12f) + val smileHeight = w * (if (isLaughing) 0.08f else 0.065f) val strokeWidth = (w * 0.026f).coerceAtLeast(1.5f) if (isLaughing) { val mouthTopY = center.y + (bodyHeight * 0.09f) val mouthPath = Path().apply { moveTo(center.x - (smileWidth / 2f), mouthTopY) - lineTo(center.x + (smileWidth / 2f), mouthTopY) cubicTo( - center.x + (smileWidth / 2f), + center.x - (smileWidth * 0.35f), mouthTopY + smileHeight, - center.x - (smileWidth / 2f), + center.x + (smileWidth * 0.35f), mouthTopY + smileHeight, - center.x - (smileWidth / 2f), + center.x + (smileWidth / 2f), mouthTopY, ) close() } - drawScope.drawPath(path = mouthPath, color = smileColor) - val tongueRadius = smileWidth * 0.26f - drawScope.drawCircle( - color = Color(0xFFFF5277), - radius = tongueRadius, - center = Offset(center.x, mouthTopY + (smileHeight * 0.65f)), + drawScope.drawPath(path = mouthPath, color = Color(0xFFFF5277)) + drawScope.drawPath( + path = mouthPath, + color = smileColor, + style = Stroke(width = strokeWidth, cap = StrokeCap.Round, join = StrokeJoin.Round), + ) + val glintWidth = smileWidth * 0.35f + drawScope.drawLine( + color = Color.White.copy(alpha = 0.90f), + start = Offset(center.x - (glintWidth / 2f), mouthTopY + (strokeWidth * 0.6f)), + end = Offset(center.x + (glintWidth / 2f), mouthTopY + (strokeWidth * 0.6f)), + strokeWidth = (w * 0.014f).coerceAtLeast(1f), + cap = StrokeCap.Round, ) } else { drawScope.drawArc( diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarEnvironments.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarEnvironments.kt index 869949b92..83cc75013 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarEnvironments.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarEnvironments.kt @@ -317,18 +317,18 @@ internal object BlobAvatarEnvironments { center = center, ) - // Top comedy curtain swag curves (Left & Right) - val curtainColor = Color(0xFF881337).copy(alpha = 0.85f) + // Top comedy curtain swag curves (Left & Right in warm golden amber) + val curtainColor = Color(0xFFF59E0B).copy(alpha = 0.35f) val leftDrape = Path().apply { moveTo(0f, 0f) - lineTo(w * 0.32f, 0f) - cubicTo(w * 0.22f, h * 0.18f, w * 0.06f, h * 0.16f, 0f, h * 0.12f) + lineTo(w * 0.30f, 0f) + cubicTo(w * 0.20f, h * 0.14f, w * 0.05f, h * 0.12f, 0f, h * 0.09f) close() } val rightDrape = Path().apply { moveTo(w, 0f) - lineTo(w * 0.68f, 0f) - cubicTo(w * 0.78f, h * 0.18f, w * 0.94f, h * 0.16f, w, h * 0.12f) + lineTo(w * 0.70f, 0f) + cubicTo(w * 0.80f, h * 0.14f, w * 0.95f, h * 0.12f, w, h * 0.09f) close() } drawScope.drawPath(path = leftDrape, color = curtainColor) diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarGenreMood.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarGenreMood.kt index 703b37cb2..b9a161230 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarGenreMood.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarGenreMood.kt @@ -24,7 +24,7 @@ internal enum class BlobAvatarGenreMood( keyLightColor = Color(0xFF8A5CF6), // Neon Violet rimLightColor = Color(0xFF00E5FF), // Electric Cyan particleType = ParticleType.MusicNote, - accessoryType = AccessoryType.CozyBeanie, + accessoryType = AccessoryType.None, environmentType = EnvironmentType.LofiBokeh, ), TrueCrime( @@ -54,7 +54,7 @@ internal enum class BlobAvatarGenreMood( keyLightColor = Color(0xFFFFB300), // Warm Amber rimLightColor = Color(0xFFFF4081), // Candy Coral particleType = ParticleType.LaughSparkle, - accessoryType = AccessoryType.StarSunglasses, + accessoryType = AccessoryType.ComedianBowtie, environmentType = EnvironmentType.ComedyStage, ), LoreStories( @@ -99,12 +99,12 @@ internal enum class BlobAvatarGenreMood( } enum class AccessoryType { + None, DetectiveFedora, CyberVisor, AthleticSweatband, BroadcastBoomMic, - CozyBeanie, - StarSunglasses, + ComedianBowtie, WizardHat, } @@ -119,15 +119,26 @@ internal enum class BlobAvatarGenreMood( } companion object { + private var lastShownMood: BlobAvatarGenreMood? = null + fun random(random: Random = Random): BlobAvatarGenreMood { val all = entries - return all[random.nextInt(all.size)] + val candidates = if (all.size > 1 && lastShownMood != null) { + all.filter { it != lastShownMood } + } else { + all + } + val chosen = candidates[random.nextInt(candidates.size)] + lastShownMood = chosen + return chosen } fun next(current: BlobAvatarGenreMood): BlobAvatarGenreMood { val all = entries val nextIndex = (current.ordinal + 1) % all.size - return all[nextIndex] + val nextMood = all[nextIndex] + lastShownMood = nextMood + return nextMood } } } diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatarTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatarTest.kt index 152620aa0..620d44921 100644 --- a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatarTest.kt +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatarTest.kt @@ -180,4 +180,18 @@ class AnimatedBlobAvatarTest { assertEquals(7, BlobAvatarGenreMood.AccessoryType.entries.size) assertEquals(7, BlobAvatarGenreMood.EnvironmentType.entries.size) } + + @Test + fun blobAvatar_randomMood_avoidsConsecutiveDuplicates() { + var lastMood = BlobAvatarGenreMood.random() + repeat(20) { + val nextMood = BlobAvatarGenreMood.random() + org.junit.Assert.assertNotEquals( + "Consecutive random moods must not duplicate", + lastMood, + nextMood, + ) + lastMood = nextMood + } + } } From 8819974d88dab600e6dcaecb55ad979414897e95 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Sat, 26 Sep 2026 21:48:26 +0530 Subject: [PATCH 19/21] fix(settings): resolve lint permissions, regex complexity, and sonar smells --- .../NavGraphSettingsDestinations.kt | 2 +- core/catalog/README.md | 2 +- .../aswin/boxlore/core/prefs/BoxcastPrefs.kt | 6 +- .../boxlore/core/prefs/BoxcastPrefsTest.kt | 34 +++++ .../aswin/boxlore/feature/home/HomeScreen.kt | 1 + feature/settings/src/main/AndroidManifest.xml | 6 + .../settings/feedback/DiagnosticCollector.kt | 28 ++-- .../settings/feedback/FeedbackDialogs.kt | 2 +- .../settings/feedback/FeedbackScreen.kt | 33 ++++- .../settings/feedback/FeedbackSuccessView.kt | 54 ++++---- .../settings/feedback/FeedbackViewModel.kt | 125 ++++++++++-------- .../settings/feedback/LogcatCollector.kt | 65 +++++---- .../feedback/FeedbackViewModelTest.kt | 26 ++++ 13 files changed, 252 insertions(+), 132 deletions(-) create mode 100644 feature/settings/src/main/AndroidManifest.xml 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 c0ebc88b9..632757563 100644 --- a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt +++ b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt @@ -313,7 +313,7 @@ private fun androidx.navigation.NavGraphBuilder.addFeedbackRoute(w: NavGraphWiri composable("feedback") { cx.aswin.boxlore.feature.settings.feedback.FeedbackScreen( podcastRepository = container.podcastRepository, - boxcastPrefs = cx.aswin.boxlore.core.prefs.BoxcastPrefs(application), + boxcastPrefs = container.boxcastPrefs, onBack = { navController.popBackStack() }, ) } diff --git a/core/catalog/README.md b/core/catalog/README.md index be39a8ad5..d0f9ecd79 100644 --- a/core/catalog/README.md +++ b/core/catalog/README.md @@ -6,7 +6,7 @@ Owns catalog orchestration: Podcast Index access through `PodcastRepository`, su ## Public API -- `PodcastRepository` coordinates Podcast Index calls, recommendation endpoints, curated vibes, home bootstrap, similar episodes (with content languages), content catalog/v3, and RSS delegation. Canonical `PodcastEntity.toPodcast()` mapper standardizes entity conversion across data and feature modules, with module-boundary isolation between `:core:catalog` and `:core:rss`. Subscribed PI shows whose local catalog is **ready** (`LocalEpisodeCatalogPort.isReady`) serve list/search/`getEpisode` from Room only. Unsubscribe stamps a 14-day TTL; that catalog stays on disk but is **not** ready, so unsubscribed reads keep today’s PI pages plus cached extras merge. True `rss:` rows are unchanged. `getEpisodes` uses a bounded window (200), not `getAllNewest`. `loadPiEpisodesForBaseline` remains for **first** catalog persist rematch only. `invalidateEpisodesCache` drops the 5-minute PI page cache for a feed (Podcast Info pull-to-refresh). In-memory caching and pagination safety are managed by `PodcastRepositoryMemoryPolicy` (`clampPageLimit` caps requests to 100, and `TimedLruCache` bounds cache entries with LRU and TTL eviction to prevent OutOfMemoryError). +- `PodcastRepository` coordinates Podcast Index calls, recommendation endpoints, curated vibes, home bootstrap, similar episodes (with content languages), content catalog/v3, user feedback submission (`submitFeedback` transmitting categorized feedback, repro steps, optional email, and diagnostics to proxy), and RSS delegation. Canonical `PodcastEntity.toPodcast()` mapper standardizes entity conversion across data and feature modules, with module-boundary isolation between `:core:catalog` and `:core:rss`. Subscribed PI shows whose local catalog is **ready** (`LocalEpisodeCatalogPort.isReady`) serve list/search/`getEpisode` from Room only. Unsubscribe stamps a 14-day TTL; that catalog stays on disk but is **not** ready, so unsubscribed reads keep today’s PI pages plus cached extras merge. True `rss:` rows are unchanged. `getEpisodes` uses a bounded window (200), not `getAllNewest`. `loadPiEpisodesForBaseline` remains for **first** catalog persist rematch only. `invalidateEpisodesCache` drops the 5-minute PI page cache for a feed (Podcast Info pull-to-refresh). In-memory caching and pagination safety are managed by `PodcastRepositoryMemoryPolicy` (`clampPageLimit` caps requests to 100, and `TimedLruCache` bounds cache entries with LRU and TTL eviction to prevent OutOfMemoryError). - `syncSubscriptions` (`POST /sync`) is PI-only for shows that are **not** catalog-ready and **not** still on extras opt-in. Ready ids contribute the Room newest tip; opted-in not-ready ids contribute the cached extras tip. Live feed freshen stays on `SubscriptionForegroundSync`. `getEpisodeWindow` is the bounded Smart Queue / Auto entry (never `getAllNewest`). - Show search: legacy `searchPodcasts` / `searchPodcastsWithCorrection` keep calling `GET /search` (hybrid). New clients use `searchPodcastsTypeahead` (`GET /search/typeahead` / Meili) and `searchPodcastsGrouped` (parallel typeahead + hybrid → Matches / Also found). Concept search uses `searchSemanticGrouped` / `searchEpisodesSemantic` (`GET /search/semantic`: one CF embed → Qdrant podcasts + episodes; additive `feeds` + legacy `items`). - `logic.mergeShowSearchResults` dedupes Meili vs hybrid by id / itunes / feed URL. diff --git a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/BoxcastPrefs.kt b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/BoxcastPrefs.kt index 097f57de8..cf66bbd13 100644 --- a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/BoxcastPrefs.kt +++ b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/BoxcastPrefs.kt @@ -245,11 +245,11 @@ class BoxcastPrefs(context: Context) { // ── Feedback Draft ──────────────────────────────────────────────────────── fun getFeedbackDraft(): FeedbackDraft? { - val message = prefs.getString(KEY_FEEDBACK_DRAFT_MESSAGE, null) ?: return null - if (message.isBlank()) return null + val message = prefs.getString(KEY_FEEDBACK_DRAFT_MESSAGE, null).orEmpty() + val steps = prefs.getString(KEY_FEEDBACK_DRAFT_STEPS, null).orEmpty() + if (message.isBlank() && steps.isBlank()) return null val category = prefs.getString(KEY_FEEDBACK_DRAFT_CATEGORY, "feature") ?: "feature" val email = prefs.getString(KEY_FEEDBACK_DRAFT_EMAIL, "") ?: "" - val steps = prefs.getString(KEY_FEEDBACK_DRAFT_STEPS, "") ?: "" val attachDiagnostics = prefs.getBoolean(KEY_FEEDBACK_DRAFT_ATTACH_DIAGNOSTICS, true) return FeedbackDraft( category = category, diff --git a/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/BoxcastPrefsTest.kt b/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/BoxcastPrefsTest.kt index d8d710a40..239efc580 100644 --- a/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/BoxcastPrefsTest.kt +++ b/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/BoxcastPrefsTest.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.test.core.app.ApplicationProvider import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before @@ -192,4 +193,37 @@ class BoxcastPrefsTest { prefs.clearFeedbackDraft() assertNull(prefs.getFeedbackDraft()) } + + @Test + fun feedbackDraft_returnsDraft_whenOnlyStepsArePresent() { + val draft = FeedbackDraft( + category = "bug", + message = "", + email = "tester@example.com", + stepsToReproduce = "1. Crash on click", + attachDiagnostics = true, + ) + prefs.saveFeedbackDraft(draft) + + val retrieved = prefs.getFeedbackDraft() + assertNotNull(retrieved) + assertEquals("", retrieved?.message) + assertEquals("1. Crash on click", retrieved?.stepsToReproduce) + assertEquals("tester@example.com", retrieved?.email) + } + + @Test + fun feedbackDraft_returnsNull_whenBothMessageAndStepsAreBlank() { + val draft = FeedbackDraft( + category = "bug", + message = " ", + email = "tester@example.com", + stepsToReproduce = " ", + attachDiagnostics = true, + ) + prefs.saveFeedbackDraft(draft) + + val retrieved = prefs.getFeedbackDraft() + assertNull(retrieved) + } } diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeScreen.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeScreen.kt index 1859a0d43..c9dd44b9a 100644 --- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeScreen.kt +++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeScreen.kt @@ -313,6 +313,7 @@ fun HomeRoute( onImportClick, onAiOnboardingClick, onBriefingClick, + onFeedbackClick, onNavigateToLatestEpisodes, ) { HomeScreenCallbacks( diff --git a/feature/settings/src/main/AndroidManifest.xml b/feature/settings/src/main/AndroidManifest.xml new file mode 100644 index 000000000..15fea438b --- /dev/null +++ b/feature/settings/src/main/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/DiagnosticCollector.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/DiagnosticCollector.kt index d526676af..ef09514bc 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/DiagnosticCollector.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/DiagnosticCollector.kt @@ -1,5 +1,6 @@ package cx.aswin.boxlore.feature.settings.feedback +import android.annotation.SuppressLint import android.content.Context import android.media.AudioDeviceInfo import android.media.AudioManager @@ -86,18 +87,23 @@ object DiagnosticCollector { ) } + @SuppressLint("MissingPermission") private fun determineNetworkType(context: Context): String { - val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager - ?: return "Unknown" - val network = cm.activeNetwork ?: return "Offline" - val caps = cm.getNetworkCapabilities(network) ?: return "Offline" - - return when { - caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "Wi-Fi" - caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "Cellular" - caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "Ethernet" - caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> "VPN" - else -> "Connected" + return try { + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + ?: return "Unknown" + val network = cm.activeNetwork ?: return "Offline" + val caps = cm.getNetworkCapabilities(network) ?: return "Offline" + + when { + caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "Wi-Fi" + caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "Cellular" + caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "Ethernet" + caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> "VPN" + else -> "Connected" + } + } catch (_: SecurityException) { + "Unknown" } } diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackDialogs.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackDialogs.kt index 1e52c184b..1f728e254 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackDialogs.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackDialogs.kt @@ -80,7 +80,7 @@ fun LogsPreviewDialog( verticalArrangement = Arrangement.spacedBy(10.dp), ) { Text( - text = "These diagnostics and logs are already included with your submission. Tokens and sensitive keys are scrubbed.\nTip: Reproduce the issue first so relevant error logs appear.", + text = "These diagnostics and sanitized logs will be included with your submission when diagnostics are enabled.\nTip: Reproduce the issue first so relevant error logs appear.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) 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 a49408826..efc1dc985 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 @@ -1,5 +1,6 @@ package cx.aswin.boxlore.feature.settings.feedback +import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.net.Uri @@ -615,10 +616,14 @@ private fun FeedbackCommunityRow( ) { OutlinedButton( onClick = { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(gitHubUrl)).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + try { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(gitHubUrl)).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } catch (_: ActivityNotFoundException) { + // Ignored if no browser/app can handle the intent } - context.startActivity(intent) }, modifier = Modifier.weight(1f), shape = MaterialTheme.shapes.large, @@ -635,15 +640,29 @@ private fun FeedbackCommunityRow( OutlinedButton( onClick = { val pkgName = context.packageName - val uri = try { + val marketUri = try { Uri.parse("market://details?id=$pkgName") } catch (_: Exception) { Uri.parse("https://play.google.com/store/apps/details?id=$pkgName") } - val intent = Intent(Intent.ACTION_VIEW, uri).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + try { + val intent = Intent(Intent.ACTION_VIEW, marketUri).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } catch (_: ActivityNotFoundException) { + try { + val webIntent = Intent( + Intent.ACTION_VIEW, + Uri.parse("https://play.google.com/store/apps/details?id=$pkgName"), + ).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(webIntent) + } catch (_: ActivityNotFoundException) { + // Ignored if neither store nor browser is available + } } - context.startActivity(intent) }, modifier = Modifier.weight(1f), shape = MaterialTheme.shapes.large, diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSuccessView.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSuccessView.kt index cc194a34b..14e61b228 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSuccessView.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSuccessView.kt @@ -43,6 +43,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight +private const val STATUS_TITLE_CONTACT = "Reach out within 24 hours" +private const val STATUS_TITLE_ANONYMOUS = "Submitted anonymously" + internal data class FeedbackSuccessContent( val title: String, val subtitle: String, @@ -57,12 +60,13 @@ internal fun getFeedbackSuccessContent( ): FeedbackSuccessContent { val trimmedEmail = email.trim() val hasEmail = trimmedEmail.isNotBlank() + val statusTitle = if (hasEmail) STATUS_TITLE_CONTACT else STATUS_TITLE_ANONYMOUS return when (category) { FeedbackCategory.BUG -> FeedbackSuccessContent( title = "Bug report submitted", subtitle = "Thank you for helping us squash this bug. We obsess over getting boxlore right and fixing issues at the source.", - statusTitle = if (hasEmail) "Reach out within 24 hours" else "Submitted anonymously", + statusTitle = statusTitle, statusDescription = if (hasEmail) { "Rather than applying a quick band-aid, we would like to chat more about the issue to ensure it is truly solved. We will review your diagnostics and reach out to $trimmedEmail within 24 hours." } else { @@ -73,7 +77,7 @@ internal fun getFeedbackSuccessContent( FeedbackCategory.AUDIO -> FeedbackSuccessContent( title = "Audio report submitted", subtitle = "Playback reliability is core to boxlore. Thank you for reporting this stream behavior so we can investigate and resolve it.", - statusTitle = if (hasEmail) "Reach out within 24 hours" else "Submitted anonymously", + statusTitle = statusTitle, statusDescription = if (hasEmail) { "We obsess over getting boxlore right. We'll analyze your playback diagnostics and reach out to $trimmedEmail within 24 hours to help troubleshoot." } else { @@ -84,7 +88,7 @@ internal fun getFeedbackSuccessContent( FeedbackCategory.FEATURE -> FeedbackSuccessContent( title = "Feature idea submitted", subtitle = "We love shaping boxlore alongside our listeners. Your suggestion helps steer what we build next.", - statusTitle = if (hasEmail) "Reach out within 24 hours" else "Submitted anonymously", + statusTitle = statusTitle, statusDescription = if (hasEmail) { "We discuss community ideas regularly. If we explore this feature further or need your perspective, we'll reach out to $trimmedEmail within 24 hours." } else { @@ -95,7 +99,7 @@ internal fun getFeedbackSuccessContent( FeedbackCategory.OTHER -> FeedbackSuccessContent( title = "Feedback received", subtitle = "Thank you for taking the time to write to us. Every note and perspective helps make boxlore better.", - statusTitle = if (hasEmail) "Reach out within 24 hours" else "Submitted anonymously", + statusTitle = statusTitle, statusDescription = if (hasEmail) { "We personally review every note. If your message requires a follow-up or reply, we'll reach out to $trimmedEmail within 24 hours." } else { @@ -204,13 +208,27 @@ private fun FeedbackStatusCard( content: FeedbackSuccessContent, modifier: Modifier = Modifier, ) { + val isAttached = content.isEmailAttached + val cardColor = if (isAttached) { + MaterialTheme.colorScheme.surfaceContainerHigh + } else { + MaterialTheme.colorScheme.surfaceContainer + } + val iconContainerColor = if (isAttached) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.secondaryContainer + } + val iconTint = if (isAttached) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSecondaryContainer + } + val iconVector = if (isAttached) Icons.Rounded.Schedule else Icons.Rounded.DoneAll + Surface( shape = MaterialTheme.shapes.large, - color = if (content.isEmailAttached) { - MaterialTheme.colorScheme.surfaceContainerHigh - } else { - MaterialTheme.colorScheme.surfaceContainer - }, + color = cardColor, modifier = modifier.fillMaxWidth(), ) { Row( @@ -220,26 +238,14 @@ private fun FeedbackStatusCard( ) { Surface( shape = CircleShape, - color = if (content.isEmailAttached) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.secondaryContainer - }, + color = iconContainerColor, modifier = Modifier.size(40.dp), ) { Box(contentAlignment = Alignment.Center) { Icon( - imageVector = if (content.isEmailAttached) { - Icons.Rounded.Schedule - } else { - Icons.Rounded.DoneAll - }, + imageVector = iconVector, contentDescription = null, - tint = if (content.isEmailAttached) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSecondaryContainer - }, + tint = iconTint, modifier = Modifier.size(22.dp), ) } diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackViewModel.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackViewModel.kt index 4f65204e3..b4cfac4c6 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackViewModel.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackViewModel.kt @@ -76,7 +76,7 @@ class FeedbackViewModel( val diagnostics = DiagnosticCollector.collect(context) val draft = boxcastPrefs.getFeedbackDraft() - if (draft != null && draft.message.isNotBlank()) { + if (draft != null && (draft.message.isNotBlank() || draft.stepsToReproduce.isNotBlank())) { val matchingCategory = FeedbackCategory.entries.find { it.id == draft.category } ?: FeedbackCategory.FEATURE @@ -142,6 +142,7 @@ class FeedbackViewModel( category = FeedbackCategory.FEATURE, message = "", stepsToReproduce = "", + email = "", attachDiagnostics = false, isDraftRestored = false, errorMessage = null, @@ -186,6 +187,8 @@ class FeedbackViewModel( fun onSubmit() { val state = _uiState.value + if (state.isSubmitting) return + val trimmedMessage = state.message.trim() if (trimmedMessage.isBlank()) { _uiState.update { it.copy(errorMessage = "Please enter your feedback before submitting.") } @@ -195,45 +198,12 @@ class FeedbackViewModel( _uiState.update { it.copy(isSubmitting = true, errorMessage = null) } viewModelScope.launch { - // Map category: proxy endpoint accepts ["feature", "bug", "other"] - val wireCategory = when (state.category) { - FeedbackCategory.FEATURE -> "feature" - FeedbackCategory.BUG, FeedbackCategory.AUDIO -> "bug" - FeedbackCategory.OTHER -> "other" - } - + val wireCategory = mapWireCategory(state.category) val appVersion = state.diagnosticInfo?.appVersion ?: "unknown" - val diagnosticsInfo = state.diagnosticInfo ?: DiagnosticCollector.collect(context) - val fullMessage = buildString { - if (state.category == FeedbackCategory.AUDIO) { - append("[Category: Audio / Stream Issue]\n\n") - } - append(trimmedMessage) - if (state.stepsToReproduce.isNotBlank()) { - append("\n\nSteps to reproduce:\n") - append(state.stepsToReproduce.trim()) - } - if (state.attachDiagnostics) { - append("\n\n---\nDiagnostics: ") - append(diagnosticsInfo.toCondensedSummary()) - } - }.take(2000) - - val diagnosticsReport = if (state.attachDiagnostics) { - diagnosticsInfo.toMarkdownReport() - } else { - null - } - - val sanitizedLogs = if (state.attachDiagnostics) { - withContext(ioDispatcher) { - LogcatCollector.collectSanitizedLogcat(context = context, maxLines = 100) - } - } else { - null - } + val fullMessage = buildFullFeedbackMessage(state, trimmedMessage, diagnosticsInfo) + val (diagnosticsReport, sanitizedLogs) = prepareDiagnosticPayload(state, diagnosticsInfo) val submit = submitFeedbackAction ?: { cat, msg, ver, mail, diag, logs -> podcastRepository.submitFeedback(cat, msg, ver, mail, diag, logs) @@ -248,22 +218,39 @@ class FeedbackViewModel( sanitizedLogs, ) - if (success) { - boxcastPrefs.clearFeedbackDraft() - _uiState.update { - it.copy( - isSubmitting = false, - isSuccess = true, - isDraftRestored = false, - ) - } - } else { - _uiState.update { - it.copy( - isSubmitting = false, - errorMessage = "Couldn't send feedback right now. Please check your connection and try again.", - ) - } + handleSubmissionResult(success) + } + } + + private suspend fun prepareDiagnosticPayload( + state: FeedbackUiState, + diagnosticsInfo: DiagnosticInfo, + ): Pair { + if (!state.attachDiagnostics) return null to null + + val diagnosticsReport = diagnosticsInfo.toMarkdownReport() + val sanitizedLogs = state.logsPreview ?: withContext(ioDispatcher) { + LogcatCollector.collectSanitizedLogcat(context = context, maxLines = 100) + } + return diagnosticsReport to sanitizedLogs + } + + private fun handleSubmissionResult(success: Boolean) { + if (success) { + boxcastPrefs.clearFeedbackDraft() + _uiState.update { + it.copy( + isSubmitting = false, + isSuccess = true, + isDraftRestored = false, + ) + } + } else { + _uiState.update { + it.copy( + isSubmitting = false, + errorMessage = "Couldn't send feedback right now. Please check your connection and try again.", + ) } } } @@ -278,6 +265,31 @@ class FeedbackViewModel( } } +private fun mapWireCategory(category: FeedbackCategory): String = when (category) { + FeedbackCategory.FEATURE -> "feature" + FeedbackCategory.BUG, FeedbackCategory.AUDIO -> "bug" + FeedbackCategory.OTHER -> "other" +} + +private fun buildFullFeedbackMessage( + state: FeedbackUiState, + trimmedMessage: String, + diagnosticsInfo: DiagnosticInfo, +): String = buildString { + if (state.category == FeedbackCategory.AUDIO) { + append("[Category: Audio / Stream Issue]\n\n") + } + append(trimmedMessage) + if (state.stepsToReproduce.isNotBlank()) { + append("\n\nSteps to reproduce:\n") + append(state.stepsToReproduce.trim()) + } + if (state.attachDiagnostics) { + append("\n\n---\nDiagnostics: ") + append(diagnosticsInfo.toCondensedSummary()) + } +}.take(2000) + fun buildFeedbackGitHubIssueUrl(state: FeedbackUiState): String { val title = "[${state.category.label}]: " + state.message.take(60).replace("\n", " ").trim() val body = buildString { @@ -289,10 +301,11 @@ fun buildFeedbackGitHubIssueUrl(state: FeedbackUiState): String { append(state.stepsToReproduce) append("\n\n") } - if (state.diagnosticInfo != null) { - append(state.diagnosticInfo.toMarkdownReport(state.logsPreview)) + if (state.attachDiagnostics && state.diagnosticInfo != null) { + append(state.diagnosticInfo.toMarkdownReport()) + append("\n*Note: To attach app logs, please copy them from the app's diagnostic preview and paste here.*\n") } - } + }.take(2000) return try { "https://github.com/boxcreate/boxlore/issues/new?title=" + diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/LogcatCollector.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/LogcatCollector.kt index 9c717816d..e58d660d6 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/LogcatCollector.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/LogcatCollector.kt @@ -20,7 +20,7 @@ object LogcatCollector { private const val SCRUBBED_APP_KEY_LABEL = "public key" private val BEARER_AUTH_PATTERN = Pattern.compile( - """(?i)(["']?authorization["']?\s*:\s*)?Bearer\s+([A-Za-z0-9\-_./+=]{6,})""", + """(?i)(["']?authorization["']?\s*:\s*)?Bearer\s+([a-z0-9\-_./+=]{6,})""", ) private val FIREBASE_API_KEY_PATTERN = Pattern.compile( @@ -33,7 +33,7 @@ object LogcatCollector { ) private val X_APP_KEY_VALUE_PATTERN = Pattern.compile( - """(?i)["']?(?:x[-_ ]app[-_ ]key|boxlore[-_]public[-_]key|boxcast[-_]public[-_]key)["']?\s*[:=]\s*(?:\[REDACTED\]|["']?[^"'\s,;&?]+["']?)""", + """(?i)["']?[a-z0-9_-]+(?:app|public)[-_ ]key["']?\s*[:=]\s*(?:\[REDACTED\]|["']?[^\s"']+)""", ) private val X_APP_KEY_NAME_PATTERN = Pattern.compile( @@ -41,7 +41,7 @@ object LogcatCollector { ) private val SENSITIVE_KEY_VALUE_PATTERN = Pattern.compile( - """(?i)(["']?(?:authorization|token|secret|api[-_]?key|password|app[-_]?key)["']?\s*[:=]\s*["']?)(?!\s*(?:Bearer|\[REDACTED))([^"'\s,;&?]{6,})(["']?)""", + """(?i)(["']?(?:authorization|token|secret|password|(?:api|app)[-_]?key)["']?\s*[:=]\s*["']?)(?!Bearer|\[REDACTED)([^"'\s,;&?]{6,})(["']?)""", ) private val EMAIL_ADDRESS_PATTERN = Pattern.compile( @@ -49,15 +49,15 @@ object LogcatCollector { ) private val API_URL_PATTERN = Pattern.compile( - """(?i)https?://[a-zA-Z0-9.-]*aswin\.cx(:[0-9]+)?(/[^\s"']*)?""", + """(?i)https?://[a-z0-9.-]*aswin\.cx(:[0-9]+)?(/[^\s"']*)?""", ) private val ASWIN_HOST_PATTERN = Pattern.compile( - """(?i)\b[a-zA-Z0-9.-]*aswin\.cx(:[0-9]+)?\b""", + """(?i)\b[a-z0-9.-]*aswin\.cx(:[0-9]+)?\b""", ) private val API_CONFIG_URL_PATTERN = Pattern.compile( - """(?i)(["']?(?:api[-_]?base[-_]?url|api[-_]?url|base[-_]?url)["']?\s*[:=]\s*["']?)https?://[^"'\s]+(["']?)""", + """(?i)(["']?(?:api|base)[-_a-z]*url["']?\s*[:=]\s*["']?)https?://[^"'\s]+(["']?)""", ) /** @@ -66,42 +66,51 @@ object LogcatCollector { */ @WorkerThread fun collectSanitizedLogcat(context: Context? = null, maxLines: Int = 150): String = try { + val process = startLogcatProcess(maxLines) + val rawLog = readProcessOutput(process) + + if (rawLog.isBlank()) { + "[No logcat entries recorded for current session]" + } else { + val (apiBaseUrl, publicKey) = readApiConfig(context) + sanitizeLogcatOutput(rawLog, apiBaseUrl, publicKey) + } + } catch (e: Exception) { + "[Logcat collection unavailable: ${e.javaClass.simpleName}]" + } + + private fun startLogcatProcess(maxLines: Int): java.lang.Process { val pid = Process.myPid() - val process = try { + return try { ProcessBuilder("logcat", "--pid=$pid", "-d", "-v", "time", "-t", maxLines.toString()) .redirectErrorStream(true) .start() } catch (_: Exception) { - // Fallback without --pid if platform logcat doesn't support the flag ProcessBuilder("logcat", "-d", "-v", "time", "-t", maxLines.toString()) .redirectErrorStream(true) .start() } + } - val reader = BufferedReader(InputStreamReader(process.inputStream)) + private fun readProcessOutput(process: java.lang.Process): String { val output = StringBuilder() - var line: String? - while (reader.readLine().also { line = it } != null) { - line?.let { - output.append(it).append('\n') + try { + BufferedReader(InputStreamReader(process.inputStream)).use { reader -> + reader.lineSequence().forEach { line -> + output.append(line).append('\n') + } } + process.waitFor() + } finally { + process.destroy() } - process.waitFor() + return output.toString() + } - val rawLog = output.toString() - if (rawLog.isBlank()) { - "[No logcat entries recorded for current session]" - } else { - val (apiBaseUrl, publicKey) = if (context != null) { - val prefs = context.getSharedPreferences("boxlore_api_config", Context.MODE_PRIVATE) - prefs.getString("base_url", null) to prefs.getString("public_key", null) - } else { - null to null - } - sanitizeLogcatOutput(rawLog, apiBaseUrl, publicKey) - } - } catch (e: Exception) { - "[Logcat collection unavailable: ${e.message ?: e.javaClass.simpleName}]" + private fun readApiConfig(context: Context?): Pair { + if (context == null) return null to null + val prefs = context.getSharedPreferences("boxlore_api_config", Context.MODE_PRIVATE) + return prefs.getString("base_url", null) to prefs.getString("public_key", null) } /** 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 4e6e93af2..ad7b52f72 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 @@ -106,21 +106,47 @@ class FeedbackViewModelTest { category = "other", message = "Something random", stepsToReproduce = "step 1", + email = "user@example.com", ), ) val vm = FeedbackViewModel(dummyPodcastRepository, prefs, context) assertTrue(vm.uiState.value.isDraftRestored) + assertEquals("user@example.com", vm.uiState.value.email) vm.onDiscardDraft() val state = vm.uiState.value assertEquals("", state.message) assertEquals("", state.stepsToReproduce) + assertEquals("", state.email) assertFalse(state.isDraftRestored) assertNull(prefs.getFeedbackDraft()) } + @Test + fun initial_state_restores_draft_when_only_steps_are_present() { + prefs.saveFeedbackDraft( + FeedbackDraft( + category = "bug", + message = "", + email = "listener@example.com", + stepsToReproduce = "1. Play\n2. Wait", + attachDiagnostics = true, + ), + ) + + val vm = FeedbackViewModel(dummyPodcastRepository, prefs, context) + val state = vm.uiState.value + + assertEquals(FeedbackCategory.BUG, state.category) + assertEquals("", state.message) + assertEquals("listener@example.com", state.email) + assertEquals("1. Play\n2. Wait", state.stepsToReproduce) + assertTrue(state.attachDiagnostics) + assertTrue(state.isDraftRestored) + } + @Test fun onSubmit_with_blank_message_sets_error_and_does_not_call_repository() = runTest(testDispatcher) { var called = false From 81883a02f69fab6f0bb6aa38cf8102a6f3ae1915 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Sat, 26 Sep 2026 22:17:49 +0530 Subject: [PATCH 20/21] fix(settings): resolve remaining sonar smells, redact basic auth, and harden feedback inputs --- .../NavGraphSettingsDestinations.kt | 1 - core/auth/README.md | 2 +- .../core/auth/FirebaseAuthRepository.kt | 2 +- .../core/catalog/sync/UserSyncCoordinator.kt | 2 +- .../settings/feedback/FeedbackScreen.kt | 9 ++++ .../settings/feedback/FeedbackViewModel.kt | 31 +++++++----- .../settings/feedback/LogcatCollector.kt | 8 +-- .../settings/pages/AccountSettingsPage.kt | 8 ++- .../settings/pages/AnimatedBlobAvatar.kt | 34 ++++++++----- .../settings/pages/BlobAvatarGenreMood.kt | 19 +++---- .../settings/pages/SyncAndBackupsPage.kt | 26 ++++++---- .../settings/feedback/LogcatCollectorTest.kt | 9 ++++ .../settings/pages/AnimatedBlobAvatarTest.kt | 9 ++-- .../settings/pages/SyncAndBackupsPageTest.kt | 50 +++++++++++++++++++ 14 files changed, 152 insertions(+), 58 deletions(-) create mode 100644 feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/SyncAndBackupsPageTest.kt 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 632757563..59d0106ec 100644 --- a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt +++ b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt @@ -308,7 +308,6 @@ private fun androidx.navigation.NavGraphBuilder.addDownloadSettingsRoutes(w: Nav private fun androidx.navigation.NavGraphBuilder.addFeedbackRoute(w: NavGraphWiring) { val navController = w.navController val container = w.container - val application = w.application composable("feedback") { cx.aswin.boxlore.feature.settings.feedback.FeedbackScreen( diff --git a/core/auth/README.md b/core/auth/README.md index 7cd7770d3..f1eddf2ac 100644 --- a/core/auth/README.md +++ b/core/auth/README.md @@ -8,7 +8,7 @@ Provides authentication abstractions and implementations (Firebase). - `AuthRepository` (authentication operations, Google, Magic Link, Email/Password, `sendEmailVerification()`, `reloadUser()`) - `AuthException`, `RecentLoginRequiredException` -- `FirebaseAuthRepository` +- `FirebaseAuthRepository` (supports optional `onPreDeleteAccount` callback invoked before Firebase account deletion) ## Internal structure diff --git a/core/auth/src/main/java/cx/aswin/boxlore/core/auth/FirebaseAuthRepository.kt b/core/auth/src/main/java/cx/aswin/boxlore/core/auth/FirebaseAuthRepository.kt index 3ae2c3079..f7b645af3 100644 --- a/core/auth/src/main/java/cx/aswin/boxlore/core/auth/FirebaseAuthRepository.kt +++ b/core/auth/src/main/java/cx/aswin/boxlore/core/auth/FirebaseAuthRepository.kt @@ -128,7 +128,7 @@ class FirebaseAuthRepository( "recent-login", "requires-recent-login", "credential_too_old", - "401", + "ERROR_REQUIRES_RECENT_LOGIN", ) } } diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/sync/UserSyncCoordinator.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/sync/UserSyncCoordinator.kt index a72c6874b..083f80620 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/sync/UserSyncCoordinator.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/sync/UserSyncCoordinator.kt @@ -458,7 +458,7 @@ open class UserSyncCoordinator( if (userId.isNullOrBlank()) { return@runCatching } - val token = tokenProvider() ?: return@runCatching + val token = tokenProvider() ?: error("Missing authentication token for cloud sync data deletion") val call = requireBoxLoreApi.deleteSyncAccount( publicKey = publicKey, authorization = "Bearer $token", 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 efc1dc985..de676a518 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 @@ -190,12 +190,14 @@ private fun FeedbackFormView( FeedbackMessageInput( category = uiState.category, message = uiState.message, + enabled = !uiState.isSubmitting, onMessageChanged = { viewModel.onMessageChanged(it) }, ) if (uiState.category.isBugReport) { FeedbackStepsInput( steps = uiState.stepsToReproduce, + enabled = !uiState.isSubmitting, onStepsChanged = { viewModel.onStepsChanged(it) }, ) } @@ -203,6 +205,7 @@ private fun FeedbackFormView( FeedbackEmailInput( category = uiState.category, email = uiState.email, + enabled = !uiState.isSubmitting, onEmailChanged = { viewModel.onEmailChanged(it) }, ) @@ -335,12 +338,14 @@ private fun FeedbackCategorySelector( private fun FeedbackMessageInput( category: FeedbackCategory, message: String, + enabled: Boolean = true, onMessageChanged: (String) -> Unit, ) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { OutlinedTextField( value = message, onValueChange = onMessageChanged, + enabled = enabled, modifier = Modifier .fillMaxWidth() .height(150.dp), @@ -372,6 +377,7 @@ private fun FeedbackMessageInput( @Composable private fun FeedbackStepsInput( steps: String, + enabled: Boolean = true, onStepsChanged: (String) -> Unit, ) { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { @@ -409,6 +415,7 @@ private fun FeedbackStepsInput( OutlinedTextField( value = steps, onValueChange = onStepsChanged, + enabled = enabled, modifier = Modifier .fillMaxWidth() .height(100.dp), @@ -442,6 +449,7 @@ private fun getEmailSupportingText(category: FeedbackCategory): String = when (c private fun FeedbackEmailInput( category: FeedbackCategory, email: String, + enabled: Boolean = true, onEmailChanged: (String) -> Unit, ) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { @@ -455,6 +463,7 @@ private fun FeedbackEmailInput( OutlinedTextField( value = email, onValueChange = onEmailChanged, + enabled = enabled, modifier = Modifier.fillMaxWidth(), placeholder = { Text( diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackViewModel.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackViewModel.kt index b4cfac4c6..96941f8bb 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackViewModel.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackViewModel.kt @@ -275,20 +275,27 @@ private fun buildFullFeedbackMessage( state: FeedbackUiState, trimmedMessage: String, diagnosticsInfo: DiagnosticInfo, -): String = buildString { - if (state.category == FeedbackCategory.AUDIO) { - append("[Category: Audio / Stream Issue]\n\n") +): String { + val prefix = if (state.category == FeedbackCategory.AUDIO) { + "[Category: Audio / Stream Issue]\n\n" + } else { + "" } - append(trimmedMessage) - if (state.stepsToReproduce.isNotBlank()) { - append("\n\nSteps to reproduce:\n") - append(state.stepsToReproduce.trim()) + val steps = if (state.category.isBugReport && state.stepsToReproduce.isNotBlank()) { + "\n\nSteps to reproduce:\n${state.stepsToReproduce.trim()}" + } else { + "" } - if (state.attachDiagnostics) { - append("\n\n---\nDiagnostics: ") - append(diagnosticsInfo.toCondensedSummary()) + val diagnostics = if (state.attachDiagnostics) { + "\n\n---\nDiagnostics: ${diagnosticsInfo.toCondensedSummary()}" + } else { + "" } -}.take(2000) + val overhead = prefix.length + steps.length + diagnostics.length + val availableForMessage = (2000 - overhead).coerceAtLeast(100) + val cappedMessage = trimmedMessage.take(availableForMessage) + return "$prefix$cappedMessage$steps$diagnostics".take(2000) +} fun buildFeedbackGitHubIssueUrl(state: FeedbackUiState): String { val title = "[${state.category.label}]: " + state.message.take(60).replace("\n", " ").trim() @@ -296,7 +303,7 @@ fun buildFeedbackGitHubIssueUrl(state: FeedbackUiState): String { append("### Description\n") append(state.message.ifBlank { "Describe the issue or feature request here." }) append("\n\n") - if (state.stepsToReproduce.isNotBlank()) { + if (state.category.isBugReport && state.stepsToReproduce.isNotBlank()) { append("### Steps to Reproduce\n") append(state.stepsToReproduce) append("\n\n") diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/LogcatCollector.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/LogcatCollector.kt index e58d660d6..ba5ec0bcc 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/LogcatCollector.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/LogcatCollector.kt @@ -19,8 +19,8 @@ object LogcatCollector { private const val SCRUBBED_API_HOST = "api.boxlore.app" private const val SCRUBBED_APP_KEY_LABEL = "public key" - private val BEARER_AUTH_PATTERN = Pattern.compile( - """(?i)(["']?authorization["']?\s*:\s*)?Bearer\s+([a-z0-9\-_./+=]{6,})""", + private val AUTH_HEADER_PATTERN = Pattern.compile( + """(?i)(["']?authorization["']?\s*:\s*)?(Bearer|Basic)\s+([a-z0-9\-_./+=]{4,})""", ) private val FIREBASE_API_KEY_PATTERN = Pattern.compile( @@ -41,7 +41,7 @@ object LogcatCollector { ) private val SENSITIVE_KEY_VALUE_PATTERN = Pattern.compile( - """(?i)(["']?(?:authorization|token|secret|password|(?:api|app)[-_]?key)["']?\s*[:=]\s*["']?)(?!Bearer|\[REDACTED)([^"'\s,;&?]{6,})(["']?)""", + """(?i)(["']?[a-z0-9_]*(?:auth|token|secret|password|key)["']?\s*[:=]\s*["']?)([^"'\s,;&?\[]{6,})(["']?)""", ) private val EMAIL_ADDRESS_PATTERN = Pattern.compile( @@ -133,7 +133,7 @@ object LogcatCollector { } sanitized = URL_QUERY_PARAM_PATTERN.matcher(sanitized).replaceAll("$1[REDACTED]") - sanitized = BEARER_AUTH_PATTERN.matcher(sanitized).replaceAll("$1Bearer [REDACTED]") + sanitized = AUTH_HEADER_PATTERN.matcher(sanitized).replaceAll("$1$2 [REDACTED]") sanitized = FIREBASE_API_KEY_PATTERN.matcher(sanitized).replaceAll("[REDACTED_API_KEY]") sanitized = X_APP_KEY_VALUE_PATTERN.matcher(sanitized).replaceAll("$SCRUBBED_APP_KEY_LABEL: [REDACTED]") sanitized = X_APP_KEY_NAME_PATTERN.matcher(sanitized).replaceAll(SCRUBBED_APP_KEY_LABEL) diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AccountSettingsPage.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AccountSettingsPage.kt index 04a44d3d2..5f72fbdbe 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AccountSettingsPage.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AccountSettingsPage.kt @@ -103,11 +103,17 @@ private fun openAccountDeletionInfo(context: Context, uriHandler: UriHandler) { true }.getOrDefault(false) if (!opened) { - runCatching { + val fallbackOpened = runCatching { val intent = Intent(Intent.ACTION_VIEW, Uri.parse(ACCOUNT_DELETION_URL)).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } context.startActivity(intent) + true + }.getOrDefault(false) + if (!fallbackOpened) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? android.content.ClipboardManager + clipboard?.setPrimaryClip(android.content.ClipData.newPlainText("Account Deletion URL", ACCOUNT_DELETION_URL)) + android.widget.Toast.makeText(context, "Link copied to clipboard", android.widget.Toast.LENGTH_SHORT).show() } } } diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatar.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatar.kt index 79d90ddae..92db02368 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatar.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatar.kt @@ -12,7 +12,8 @@ import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas -import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size @@ -23,6 +24,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -37,11 +39,14 @@ import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.graphics.drawscope.scale import androidx.compose.ui.graphics.luminance import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlin.math.sin +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -235,11 +240,7 @@ internal fun resolveAvatarPalette( Color(0xFFF8FAFC) } - val bodyShadow = if (isDark) { - Color(0xFFCBD5E1) - } else { - Color(0xFFCBD5E1) - } + val bodyShadow = Color(0xFFCBD5E1) val bodyOutline = if (isDark) { Color.White.copy(alpha = 0.18f) @@ -298,7 +299,7 @@ internal fun AnimatedBlobAvatar( val animState = rememberBlobAvatarAnimationState() val isDark = MaterialTheme.colorScheme.surface.luminance() < 0.5f - var activeMood by remember { + var activeMood by rememberSaveable { mutableStateOf(initialMood ?: BlobAvatarGenreMood.random()) } @@ -308,11 +309,11 @@ internal fun AnimatedBlobAvatar( val squishScaleY = remember { Animatable(1f) } var isWinking by remember { mutableStateOf(false) } + var winkJob by remember { mutableStateOf(null) } val onTapAvatar = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) activeMood = BlobAvatarGenreMood.next(activeMood) - isWinking = true coroutineScope.launch { squishScaleY.snapTo(0.80f) squishScaleY.animateTo( @@ -323,7 +324,9 @@ internal fun AnimatedBlobAvatar( ), ) } - coroutineScope.launch { + winkJob?.cancel() + winkJob = coroutineScope.launch { + isWinking = true delay(650) isWinking = false } @@ -347,8 +350,15 @@ internal fun AnimatedBlobAvatar( Box( modifier = modifier .size(size) - .pointerInput(Unit) { - detectTapGestures(onTap = { onTapAvatar() }) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + role = Role.Button, + onClickLabel = "Cycle avatar mood", + onClick = onTapAvatar, + ) + .semantics { + contentDescription = "Avatar mood: ${activeMood.displayName}" }, contentAlignment = Alignment.Center, ) { diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarGenreMood.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarGenreMood.kt index b9a161230..30b1882db 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarGenreMood.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/BlobAvatarGenreMood.kt @@ -119,26 +119,23 @@ internal enum class BlobAvatarGenreMood( } companion object { - private var lastShownMood: BlobAvatarGenreMood? = null - - fun random(random: Random = Random): BlobAvatarGenreMood { + fun random( + exclude: BlobAvatarGenreMood? = null, + random: Random = Random, + ): BlobAvatarGenreMood { val all = entries - val candidates = if (all.size > 1 && lastShownMood != null) { - all.filter { it != lastShownMood } + val candidates = if (all.size > 1 && exclude != null) { + all.filter { it != exclude } } else { all } - val chosen = candidates[random.nextInt(candidates.size)] - lastShownMood = chosen - return chosen + return candidates[random.nextInt(candidates.size)] } fun next(current: BlobAvatarGenreMood): BlobAvatarGenreMood { val all = entries val nextIndex = (current.ordinal + 1) % all.size - val nextMood = all[nextIndex] - lastShownMood = nextMood - return nextMood + return all[nextIndex] } } } diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/SyncAndBackupsPage.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/SyncAndBackupsPage.kt index 7bdd9259b..6ea5db030 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/SyncAndBackupsPage.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/SyncAndBackupsPage.kt @@ -117,24 +117,32 @@ private fun resolveSyncVisual( } } +internal fun resolveSyncBadgeText(syncStatus: CloudSyncUiStatus): String = when { + syncStatus is CloudSyncUiStatus.Error -> "Sync issue" + syncStatus is CloudSyncUiStatus.Syncing -> "Syncing..." + else -> "Cloud sync active" +} + +internal fun resolveCloudSyncSubtitle(accountStatus: String?): String = + if (accountStatus != null) { + "Signed in as $accountStatus" + } else { + "Sign in to backup and sync across devices" + } + @Composable private fun SyncStatusBadge( syncStatus: CloudSyncUiStatus, modifier: Modifier = Modifier, ) { val isSyncError = syncStatus is CloudSyncUiStatus.Error - val isSyncing = syncStatus is CloudSyncUiStatus.Syncing val statusDotColor = if (isSyncError) { MaterialTheme.colorScheme.error } else { MaterialTheme.colorScheme.primary } - val statusText = when { - isSyncError -> "Sync issue" - isSyncing -> "Syncing..." - else -> "Cloud sync active" - } + val statusText = resolveSyncBadgeText(syncStatus) Row( modifier = modifier, @@ -203,11 +211,7 @@ private fun CloudSyncCard( ) Text( - text = if (isSignedIn) { - "Signed in as $accountStatus" - } else { - "Sign in to backup and sync across devices" - }, + text = resolveCloudSyncSubtitle(accountStatus), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/LogcatCollectorTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/LogcatCollectorTest.kt index aaa2fe79f..195657026 100644 --- a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/LogcatCollectorTest.kt +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/LogcatCollectorTest.kt @@ -16,6 +16,15 @@ class LogcatCollectorTest { assertTrue(sanitized.contains("Bearer [REDACTED]")) } + @Test + fun `sanitizeLogcatOutput scrubs sensitive basic auth credentials`() { + val raw = "12-10 10:20:30.123 1000 1000 D OkHttp: Authorization: Basic dXNlcjpwYXNz" + val sanitized = LogcatCollector.sanitizeLogcatOutput(raw) + + assertFalse(sanitized.contains("dXNlcjpwYXNz")) + assertTrue(sanitized.contains("Basic [REDACTED]")) + } + @Test fun `sanitizeLogcatOutput scrubs Firebase API keys`() { val fakeKey = "AIza" + "SyD1234567890abcdefghijklmnopqrstuv" diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatarTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatarTest.kt index 620d44921..e3e546978 100644 --- a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatarTest.kt +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/AnimatedBlobAvatarTest.kt @@ -183,9 +183,12 @@ class AnimatedBlobAvatarTest { @Test fun blobAvatar_randomMood_avoidsConsecutiveDuplicates() { - var lastMood = BlobAvatarGenreMood.random() - repeat(20) { - val nextMood = BlobAvatarGenreMood.random() + var lastMood = BlobAvatarGenreMood.random(random = kotlin.random.Random(42)) + repeat(20) { index -> + val nextMood = BlobAvatarGenreMood.random( + exclude = lastMood, + random = kotlin.random.Random(42 + index), + ) org.junit.Assert.assertNotEquals( "Consecutive random moods must not duplicate", lastMood, diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/SyncAndBackupsPageTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/SyncAndBackupsPageTest.kt new file mode 100644 index 000000000..edeb9b9f3 --- /dev/null +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/pages/SyncAndBackupsPageTest.kt @@ -0,0 +1,50 @@ +package cx.aswin.boxlore.feature.settings.pages + +import cx.aswin.boxlore.core.catalog.sync.CloudSyncUiStatus +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class SyncAndBackupsPageTest { + + @Test + fun resolveSyncBadgeText_returnsExpectedStrings() { + assertEquals("Sync issue", resolveSyncBadgeText(CloudSyncUiStatus.Error("Network failure"))) + assertEquals("Syncing...", resolveSyncBadgeText(CloudSyncUiStatus.Syncing)) + assertEquals("Cloud sync active", resolveSyncBadgeText(CloudSyncUiStatus.Idle)) + assertEquals("Cloud sync active", resolveSyncBadgeText(CloudSyncUiStatus.Success(12345678L))) + } + + @Test + fun resolveCloudSyncSubtitle_returnsExpectedStrings() { + assertEquals("Signed in as listener@boxlore.cx", resolveCloudSyncSubtitle("listener@boxlore.cx")) + assertEquals("Sign in to backup and sync across devices", resolveCloudSyncSubtitle(null)) + } + + @Test + fun libraryBackupActions_triggersCallbacksCorrectly() { + var exportJsonCalled = false + var exportOpmlCalled = false + var importJsonCalled = false + var importOpmlCalled = false + + val actions = LibraryBackupActions( + onExportJson = { exportJsonCalled = true }, + onExportOpml = { exportOpmlCalled = true }, + onImportJson = { importJsonCalled = true }, + onImportOpml = { importOpmlCalled = true }, + ) + + actions.onExportJson() + assertTrue(exportJsonCalled) + + actions.onExportOpml() + assertTrue(exportOpmlCalled) + + actions.onImportJson() + assertTrue(importJsonCalled) + + actions.onImportOpml() + assertTrue(importOpmlCalled) + } +} From cfd9699a7b23fe4a42d54250ed41d9d836f26e39 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Sat, 26 Sep 2026 22:45:24 +0530 Subject: [PATCH 21/21] fix(settings): redact short key-value credentials in logcat output --- .../feature/settings/feedback/LogcatCollector.kt | 2 +- .../feature/settings/feedback/LogcatCollectorTest.kt | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/LogcatCollector.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/LogcatCollector.kt index ba5ec0bcc..be0cc93be 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/LogcatCollector.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/LogcatCollector.kt @@ -41,7 +41,7 @@ object LogcatCollector { ) private val SENSITIVE_KEY_VALUE_PATTERN = Pattern.compile( - """(?i)(["']?[a-z0-9_]*(?:auth|token|secret|password|key)["']?\s*[:=]\s*["']?)([^"'\s,;&?\[]{6,})(["']?)""", + """(?i)(["']?[a-z0-9_]*(?:auth|token|secret|password|key)["']?\s*[:=]\s*["']?)([^"'\s,;&?\[]+)(["']?)""", ) private val EMAIL_ADDRESS_PATTERN = Pattern.compile( diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/LogcatCollectorTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/LogcatCollectorTest.kt index 195657026..f200e9582 100644 --- a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/LogcatCollectorTest.kt +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/LogcatCollectorTest.kt @@ -47,6 +47,16 @@ class LogcatCollectorTest { assertTrue(sanitized.contains("[REDACTED]")) } + @Test + fun `sanitizeLogcatOutput scrubs short key-value credentials`() { + val raw = """{"token": "abc12", "key": "123"}""" + val sanitized = LogcatCollector.sanitizeLogcatOutput(raw) + + assertFalse(sanitized.contains("abc12")) + assertFalse(sanitized.contains("123")) + assertTrue(sanitized.contains("[REDACTED]")) + } + @Test fun `sanitizeLogcatOutput scrubs sensitive query parameters`() { val raw = "GET https://api.boxlore.cx/query?token=xyz123456&category=news HTTP/1.1"