From 92340808b790e26dd8a9d76063a8d6d3e0831be0 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Mon, 2 Nov 2026 01:59:00 +0530 Subject: [PATCH 1/8] feat(feedback): send feedback as support conversations Feedback goes to the new support API through :core:support, falling back to the old form while it is unavailable. The screen is rebuilt around type pills, a roomy message card and a keyboard bar, and a sent screen offers notifications when the reply will show up in the app. --- ARCHITECTURE.md | 8 +- app/build.gradle.kts | 1 + .../java/cx/aswin/boxlore/AppContainer.kt | 11 + .../NavGraphSettingsDestinations.kt | 3 +- build.gradle.kts | 1 + .../boxlore/core/catalog/PodcastRepository.kt | 3 + .../main/assets/echo-character/manifest.json | 2 +- .../boxlore/core/network/NetworkModule.kt | 3 + .../aswin/boxlore/core/network/SupportApi.kt | 30 + .../core/network/model/SupportModels.kt | 67 ++ core/support/README.md | 65 ++ core/support/build.gradle.kts | 52 + .../aswin/boxlore/core/support/PhotoPrep.kt | 39 + .../boxlore/core/support/SupportModels.kt | 41 + .../boxlore/core/support/SupportRepository.kt | 130 +++ .../core/support/SupportRepositoryTest.kt | 133 +++ .../architecture/ArchitectureGuardTest.kt | 1 + feature/settings/README.md | 14 +- feature/settings/build.gradle.kts | 1 + .../settings/feedback/DiagnosticCollector.kt | 6 + .../settings/feedback/FeedbackComposeParts.kt | 371 ++++++++ .../feedback/FeedbackNotificationAsk.kt | 63 ++ .../settings/feedback/FeedbackScreen.kt | 901 +++++------------- .../settings/feedback/FeedbackSentScreen.kt | 173 ++++ .../settings/feedback/FeedbackSuccessView.kt | 431 --------- .../settings/feedback/FeedbackViewModel.kt | 311 +++--- .../settings/TabletSettingsCaptureTest.kt | 5 +- .../FeedbackAdaptiveTransitionTest.kt | 244 +++++ .../feedback/FeedbackLayoutPolicyTest.kt | 34 + .../feedback/FeedbackSuccessContentTest.kt | 99 -- .../feedback/FeedbackViewModelTest.kt | 387 ++++---- release-notes/upcoming.md | 1 + settings.gradle.kts | 1 + 33 files changed, 2082 insertions(+), 1550 deletions(-) create mode 100644 core/network/src/main/java/cx/aswin/boxlore/core/network/SupportApi.kt create mode 100644 core/network/src/main/java/cx/aswin/boxlore/core/network/model/SupportModels.kt create mode 100644 core/support/README.md create mode 100644 core/support/build.gradle.kts create mode 100644 core/support/src/main/java/cx/aswin/boxlore/core/support/PhotoPrep.kt create mode 100644 core/support/src/main/java/cx/aswin/boxlore/core/support/SupportModels.kt create mode 100644 core/support/src/main/java/cx/aswin/boxlore/core/support/SupportRepository.kt create mode 100644 core/support/src/test/java/cx/aswin/boxlore/core/support/SupportRepositoryTest.kt create mode 100644 feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackComposeParts.kt create mode 100644 feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackNotificationAsk.kt create mode 100644 feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSentScreen.kt delete 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/FeedbackAdaptiveTransitionTest.kt create mode 100644 feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackLayoutPolicyTest.kt delete mode 100644 feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSuccessContentTest.kt diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 27058b6c1..674c96351 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -43,7 +43,7 @@ These values are part of the shipping product. Renames or recreations break upgr :app :core:model | :core:auth | :core:network | :core:domain | :core:database | :core:prefs :core:analytics | :core:catalog | :core:rss | :core:downloads | :core:playback | :core:ranking -:core:designsystem | :core:testing +:core:designsystem | :core:testing | :core:support :feature:home | :feature:settings | :feature:history | :feature:player | :feature:info | :feature:explore :feature:library | :feature:onboarding | :feature:briefing | :feature:widgets ``` @@ -57,6 +57,7 @@ On disk, the folder path matches the Gradle id (`core/playback` → `:core:playb | `:app` | `Application`, `AppContainer`, navigation host, FCM, `WorkerFactory` | [`app/README.md`](app/README.md) | | `:core:model` | Shared models and enums | [`core/model/README.md`](core/model/README.md) | | `:core:auth` | Authentication domain, `AuthRepository`, and Firebase Auth implementation | [`core/auth/README.md`](core/auth/README.md) | +| `:core:support` | Support conversations with the API (`SupportRepository`): switches, sending, photo upload, old-form fallback | [`core/support/README.md`](core/support/README.md) | | `:core:network` | HTTP clients (`BoxLoreApi`, `BoxLoreApiV2` for `/v2` / `NetworkModule`) and network DTOs | [`core/network/README.md`](core/network/README.md) | | `:core:domain` | Thin ports and small result types (no Room or repositories) | [`core/domain/README.md`](core/domain/README.md) | | `:core:database` | Main Room database, entities, DAOs, migrations | [`core/database/README.md`](core/database/README.md) | @@ -92,6 +93,7 @@ On disk, the folder path matches the Gradle id (`core/playback` → `:core:playb | `:core:playback` | `cx.aswin.boxlore.core.playback` | | `:core:database` | `cx.aswin.boxlore.core.database` | | `:core:catalog` | `cx.aswin.boxlore.core.catalog` | +| `:core:support` | `cx.aswin.boxlore.core.support` | ## Dependency direction @@ -118,6 +120,7 @@ flowchart TB domain[:core:domain] database[:core:database] network[:core:network] + support[:core:support] design[:core:designsystem] model[:core:model] @@ -140,6 +143,9 @@ flowchart TB catalog --> database catalog --> ranking catalog --> network + features --> support + app --> support + support --> network catalog --> model features --> analytics features --> ranking diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 63ef8e489..9997f98d8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -182,6 +182,7 @@ dependencies { implementation(libs.androidx.media3.cast) implementation(libs.androidx.media3.session) implementation(projects.core.rss) + implementation(projects.core.support) implementation(projects.feature.home) implementation(projects.feature.settings) implementation(project(":feature:player")) diff --git a/app/src/main/java/cx/aswin/boxlore/AppContainer.kt b/app/src/main/java/cx/aswin/boxlore/AppContainer.kt index 85a4ebe7d..2ee17d75b 100644 --- a/app/src/main/java/cx/aswin/boxlore/AppContainer.kt +++ b/app/src/main/java/cx/aswin/boxlore/AppContainer.kt @@ -77,6 +77,7 @@ import cx.aswin.boxlore.core.rss.RenumberedShows import cx.aswin.boxlore.core.rss.RssPodcastRepository import cx.aswin.boxlore.core.rss.SharedPreferencesRenumberedShows import cx.aswin.boxlore.core.rss.ports.DownloadCacheRelinker +import cx.aswin.boxlore.core.support.SupportRepository import cx.aswin.boxlore.fcm.PodcastNotificationRegistrationWorker import cx.aswin.boxlore.share.EpisodeShareRegistrationWorker import cx.aswin.boxlore.sync.AnalyticsFeedTelemetry @@ -367,6 +368,16 @@ class AppContainer( val boxcastPrefs: BoxcastPrefs by lazy { BoxcastPrefs(appContext) } + /** Feedback conversations; falls back to the old one-way form while `/support` is unavailable. */ + val supportRepository: SupportRepository by lazy { + SupportRepository( + api = podcastRepository.supportApi, + legacySubmit = { category, message, version, diagnostics, logs -> + podcastRepository.submitFeedback(category, message, version, null, diagnostics, logs) + }, + ) + } + override val deviceIdentityPort: DeviceIdentityPort by lazy { DeviceIdentityPort { boxcastPrefs.getOrCreateSyncDeviceId() } } 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 79ab15870..3d88fdd38 100644 --- a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt +++ b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt @@ -396,9 +396,10 @@ private fun androidx.navigation.NavGraphBuilder.addFeedbackRoute(w: NavGraphWiri composable("feedback") { cx.aswin.boxlore.feature.settings.feedback.FeedbackScreen( - podcastRepository = container.podcastRepository, + supportRepository = container.supportRepository, boxcastPrefs = container.boxcastPrefs, onBack = { navController.popBackStack() }, + bottomContentPadding = w.session.miniPlayerPadding, ) } } diff --git a/build.gradle.kts b/build.gradle.kts index ce4c54f5b..a069546db 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -85,6 +85,7 @@ dependencies { kover(projects.feature.library) kover(projects.feature.history) kover(projects.core.auth) + kover(projects.core.support) kover(projects.feature.onboarding) kover(projects.feature.briefing) kover(projects.feature.widgets) 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 310ef260e..392bd2c41 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 @@ -100,6 +100,9 @@ class PodcastRepository( */ val apiV2: BoxLoreApiV2 by lazy { boxLoreApiV2 ?: NetworkModule.createBoxLoreApiV2(baseUrl, cachedClient) } + /** Support conversations (`/support/...`), on the same cached client and base URL (one HTTP cache per process). */ + val supportApi: cx.aswin.boxlore.core.network.SupportApi by lazy { NetworkModule.createSupportApi(baseUrl, cachedClient) } + /** * The feed-first show page: reads a show's own feed into the phone's stored copy, with its * failure ladder (P4 PR 16, own-IDs plan section 11). diff --git a/core/designsystem/src/main/assets/echo-character/manifest.json b/core/designsystem/src/main/assets/echo-character/manifest.json index cfa333f22..9ec7f2a7c 100644 --- a/core/designsystem/src/main/assets/echo-character/manifest.json +++ b/core/designsystem/src/main/assets/echo-character/manifest.json @@ -1 +1 @@ -{"schema":"echo-character","version":1,"note":"Echo stills and motion clips with a blank screen (the face is drawn in code). frames: stills, with the screen box as fractions of the frame, and reach (the still's extent). clips: alpha-packed H.264 (colour above, alpha below), one keyframe, with the screen tracked per frame as [centre x, centre y, width, height, tilt degrees], and reach (everything the clip covers); optionally a face script ([share of the clip, face, gaze x, gaze y] from that point on), frames with a blink, and lens frames ([centre x, centre y, glass radius, scale, blink]: one big eye shows through a magnifier, through the glass band). sequences: steps (a still with face and hold in ms, 0 = rest forever, or a clip) played once, then an optional loop.","frames":{"front-rest":{"screen":[0.3516,0.2344,0.6484,0.4688],"reach":[0.2539,0.1523,0.7461,0.8848]},"t1-rest":{"screen":[0.4387,0.3649,0.6566,0.565],"reach":[0.293,0.3008,0.7051,0.9199]},"ta-rest":{"screen":[0.4387,0.3622,0.6566,0.5676],"reach":[0.293,0.2969,0.707,0.9316]},"tb-rest":{"screen":[0.4387,0.3614,0.6566,0.5685],"reach":[0.2949,0.2949,0.709,0.9316]},"account-idle":{"screen":[0.3542,0.2396,0.6458,0.4688],"reach":[0.2552,0.1536,0.7448,0.8828]},"account-cloud":{"screen":[0.4896,0.2188,0.7812,0.4479],"reach":[0.388,0.138,0.8828,0.8672]},"account-cloud-hug":{"screen":[0.3542,0.2188,0.6458,0.4479],"reach":[0.2526,0.1354,0.7448,0.8672]},"account-comedy":{"screen":[0.3646,0.2396,0.6458,0.4583],"reach":[0.2552,0.1484,0.75,0.8802]},"account-truecrime":{"screen":[0.3646,0.2083,0.6562,0.4375],"reach":[0.1745,0.0443,0.7682,0.901]},"account-science":{"screen":[0.375,0.2292,0.6667,0.4479],"reach":[0.237,0.0911,0.7656,0.8698]},"account-history":{"screen":[0.3854,0.2396,0.6771,0.4688],"reach":[0.2839,0.1042,0.7786,0.8828]},"account-business":{"screen":[0.2604,0.2292,0.5417,0.4479],"reach":[0.1536,0.1354,0.6589,0.8672]},"account-wellness":{"screen":[0.375,0.2292,0.6562,0.4479],"reach":[0.1823,0.1224,0.8464,0.8698]},"account-fiction":{"screen":[0.4479,0.25,0.7292,0.4792],"reach":[0.3411,0.0755,0.8359,0.875]},"story-hourglass-rest":{"screen":[0.3315,0.156,0.644,0.4013],"reach":[0.1914,0.041,0.791,0.9199]},"story-business-rest":{"screen":[0.3239,0.171,0.6338,0.415],"reach":[0.1895,0.0605,0.7832,0.9199]},"story-celebrate-rest":{"screen":[0.3872,0.406,0.5851,0.565],"reach":[0.2188,0.252,0.7656,0.9199]},"story-comedy-rest":{"screen":[0.335,0.1823,0.6368,0.423],"reach":[0.2012,0.0742,0.7734,0.9199]},"story-daytime-rest":{"screen":[0.361,0.2119,0.6491,0.4408],"reach":[0.2305,0.041,0.7852,0.918]},"story-detective-rest":{"screen":[0.3488,0.2143,0.6197,0.4334],"reach":[0.1348,0.0352,0.75,0.9199]},"story-fiction-rest":{"screen":[0.3566,0.2356,0.6488,0.4637],"reach":[0.2285,0.0293,0.7812,0.9199]},"story-health-rest":{"screen":[0.3684,0.2718,0.636,0.4811],"reach":[0.1641,0.1602,0.8398,0.9199]},"story-history-rest":{"screen":[0.3485,0.1911,0.6494,0.4278],"reach":[0.2168,0.0312,0.7832,0.9199]},"story-morning-rest":{"screen":[0.3398,0.1511,0.657,0.4009],"reach":[0.2031,0.0371,0.7988,0.9199]},"story-news-rest":{"screen":[0.3609,0.2136,0.6451,0.4297],"reach":[0.2344,0.0312,0.7715,0.9199]},"story-night-rest":{"screen":[0.3056,0.2322,0.5692,0.4399],"reach":[0.1895,0.0312,0.7441,0.916]},"story-science-rest":{"screen":[0.3518,0.1918,0.648,0.428],"reach":[0.1836,0.0312,0.7812,0.918]},"story-tech-rest":{"screen":[0.4099,0.2432,0.6818,0.4576],"reach":[0.1895,0.0586,0.8086,0.9121]},"story-trophy-rest":{"screen":[0.4018,0.2294,0.6842,0.4475],"reach":[0.2734,0.127,0.8379,0.9199]}},"sequences":{"welcome":[{"frame":"front-rest","face":"idle","ms":0}],"onboarding-welcome":{"steps":[{"clip":"wave"},{"frame":"t1-rest","face":"idle","ms":0}]},"survey-ask":{"steps":[{"clip":"ask"},{"frame":"t1-rest","face":"idle","ms":0}]},"survey-notes":{"steps":[{"clip":"notebook"},{"frame":"ta-rest","face":"idle","ms":0}]},"survey-think":{"steps":[{"clip":"think-chin"},{"frame":"t1-rest","face":"curious","ms":0}]},"survey-idea":{"steps":[{"clip":"magnifier"},{"frame":"ta-rest","face":"curious","ms":0}]},"survey-thanks-calm":{"steps":[{"frame":"t1-rest","face":"idle","ms":0}]},"survey-thanks":{"steps":[{"clip":"thumbs-up"},{"frame":"t1-rest","face":"happy","ms":0}]},"survey-celebrate":{"steps":[{"clip":"story-celebrate"},{"frame":"story-celebrate-rest","face":"happy","ms":0}]},"onboarding-question":{"steps":[{"clip":"ask"},{"frame":"t1-rest","face":"idle","ms":0}]},"onboarding-thinking":{"steps":[{"clip":"thumbs-up"}],"loop":[{"frame":"t1-rest","face":"idle","ms":1400},{"clip":"notebook"},{"frame":"t1-rest","face":"idle","ms":1400},{"clip":"think-chin"}]},"onboarding-preparing":{"steps":[{"frame":"t1-rest","face":"idle","ms":400}],"loop":[{"clip":"cards-shuffle"},{"frame":"t1-rest","face":"idle","ms":1200},{"clip":"magnifier"},{"frame":"t1-rest","face":"idle","ms":1200}]},"onboarding-presenting":{"steps":[{"clip":"hop"},{"frame":"t1-rest","face":"idle","ms":0}]},"onboarding-ready":{"steps":[{"clip":"cards-fan"},{"frame":"t1-rest","face":"idle","ms":0}]},"onboarding-rest":{"steps":[{"frame":"t1-rest","face":"idle","ms":0}]},"account-cloud":{"steps":[{"frame":"account-cloud","face":"idle","ms":0}]},"account-syncing":{"steps":[{"frame":"account-cloud-hug","face":"idle","ms":0}]},"account-outfit-idle":{"steps":[{"frame":"account-idle","face":"idle","ms":0}]},"account-outfit-comedy":{"steps":[{"frame":"account-comedy","face":"idle","ms":0}]},"account-outfit-truecrime":{"steps":[{"frame":"account-truecrime","face":"idle","ms":0}]},"account-outfit-science":{"steps":[{"frame":"account-science","face":"idle","ms":0}]},"account-outfit-history":{"steps":[{"frame":"account-history","face":"idle","ms":0}]},"account-outfit-business":{"steps":[{"frame":"account-business","face":"idle","ms":0}]},"account-outfit-wellness":{"steps":[{"frame":"account-wellness","face":"idle","ms":0}]},"account-outfit-fiction":{"steps":[{"frame":"account-fiction","face":"idle","ms":0}]},"import-pick":{"steps":[{"clip":"wave"},{"frame":"t1-rest","face":"idle","ms":0}]},"import-working":{"steps":[{"frame":"t1-rest","face":"idle","ms":300}],"loop":[{"clip":"cards-shuffle"},{"frame":"t1-rest","face":"idle","ms":900}]},"import-done":{"steps":[{"clip":"hop"},{"frame":"t1-rest","face":"idle","ms":0}]},"import-failed":{"steps":[{"frame":"t1-rest","face":"curious","ms":0}]},"story-hello":{"steps":[{"clip":"wave"},{"frame":"t1-rest","face":"idle","ms":0}]},"story-hop":{"steps":[{"clip":"hop"},{"frame":"tb-rest","face":"idle","ms":0}]},"story-business":{"steps":[{"clip":"story-business"},{"frame":"story-business-rest","face":"idle","ms":0}]},"story-celebrate":{"steps":[{"clip":"story-celebrate"},{"frame":"story-celebrate-rest","face":"idle","ms":0}]},"story-comedy":{"steps":[{"clip":"story-comedy"},{"frame":"story-comedy-rest","face":"idle","ms":0}]},"story-daytime":{"steps":[{"clip":"story-daytime"},{"frame":"story-daytime-rest","face":"idle","ms":0}]},"story-detective":{"steps":[{"clip":"story-detective"},{"frame":"story-detective-rest","face":"idle","ms":0}]},"story-fiction":{"steps":[{"clip":"story-fiction"},{"frame":"story-fiction-rest","face":"idle","ms":0}]},"story-health":{"steps":[{"clip":"story-health"},{"frame":"story-health-rest","face":"idle","ms":0}]},"story-history":{"steps":[{"clip":"story-history"},{"frame":"story-history-rest","face":"idle","ms":0}]},"story-hourglass":{"steps":[{"clip":"story-hourglass"},{"frame":"story-hourglass-rest","face":"idle","ms":0}]},"story-morning":{"steps":[{"clip":"story-morning"},{"frame":"story-morning-rest","face":"idle","ms":0}]},"story-news":{"steps":[{"clip":"story-news"},{"frame":"story-news-rest","face":"idle","ms":0}]},"story-night":{"steps":[{"clip":"story-night"},{"frame":"story-night-rest","face":"idle","ms":0}]},"story-science":{"steps":[{"clip":"story-science"},{"frame":"story-science-rest","face":"idle","ms":0}]},"story-tech":{"steps":[{"clip":"story-tech"},{"frame":"story-tech-rest","face":"idle","ms":0}]},"story-trophy":{"steps":[{"clip":"story-trophy"},{"frame":"story-trophy-rest","face":"idle","ms":0}]},"story-entry":{"steps":[{"frame":"front-rest","face":"happy","ms":0}]},"story-hello-still":{"steps":[{"frame":"t1-rest","face":"idle","ms":0}]},"story-hourglass-still":{"steps":[{"frame":"story-hourglass-rest","face":"idle","ms":0}]},"story-business-still":{"steps":[{"frame":"story-business-rest","face":"idle","ms":0}]},"story-celebrate-still":{"steps":[{"frame":"story-celebrate-rest","face":"idle","ms":0}]},"story-comedy-still":{"steps":[{"frame":"story-comedy-rest","face":"idle","ms":0}]},"story-daytime-still":{"steps":[{"frame":"story-daytime-rest","face":"idle","ms":0}]},"story-detective-still":{"steps":[{"frame":"story-detective-rest","face":"idle","ms":0}]},"story-fiction-still":{"steps":[{"frame":"story-fiction-rest","face":"idle","ms":0}]},"story-health-still":{"steps":[{"frame":"story-health-rest","face":"idle","ms":0}]},"story-history-still":{"steps":[{"frame":"story-history-rest","face":"idle","ms":0}]},"story-morning-still":{"steps":[{"frame":"story-morning-rest","face":"idle","ms":0}]},"story-news-still":{"steps":[{"frame":"story-news-rest","face":"idle","ms":0}]},"story-night-still":{"steps":[{"frame":"story-night-rest","face":"idle","ms":0}]},"story-science-still":{"steps":[{"frame":"story-science-rest","face":"idle","ms":0}]},"story-tech-still":{"steps":[{"frame":"story-tech-rest","face":"idle","ms":0}]},"story-trophy-still":{"steps":[{"frame":"story-trophy-rest","face":"idle","ms":0}]}},"clips":{"think-chin":{"file":"clips/think-chin.mp4","fps":24,"frames":107,"size":384,"face":"curious","tracks":[[0.5481,0.4664,0.2174,0.2001,-3.16],[0.5479,0.467,0.2175,0.2,-3.12],[0.5477,0.4676,0.2175,0.2,-3.0],[0.5471,0.4687,0.2175,0.1999,-2.86],[0.5462,0.4696,0.2175,0.1999,-2.68],[0.5451,0.4701,0.2174,0.1999,-2.48],[0.5437,0.47,0.2174,0.1997,-2.19],[0.5423,0.4692,0.2172,0.1997,-1.99],[0.5409,0.4681,0.2171,0.1997,-1.74],[0.5399,0.4669,0.2171,0.1995,-1.51],[0.5391,0.4659,0.217,0.1996,-1.4],[0.5387,0.4652,0.217,0.1995,-1.36],[0.5388,0.4651,0.217,0.1995,-1.37],[0.5393,0.4653,0.217,0.1995,-1.52],[0.5403,0.4659,0.2172,0.1997,-1.71],[0.5417,0.4669,0.2174,0.1996,-1.96],[0.5435,0.4681,0.2175,0.1998,-2.34],[0.5454,0.4693,0.2177,0.1999,-2.72],[0.5476,0.4703,0.2179,0.1999,-3.05],[0.5496,0.4706,0.2183,0.2,-3.31],[0.5516,0.4703,0.2186,0.2,-3.49],[0.5533,0.4694,0.219,0.2001,-3.57],[0.555,0.4682,0.2192,0.2,-3.65],[0.5563,0.467,0.2194,0.2001,-3.71],[0.5573,0.466,0.2194,0.2002,-3.77],[0.558,0.4653,0.2194,0.2003,-3.82],[0.5584,0.4651,0.2192,0.2002,-3.82],[0.5583,0.4652,0.2193,0.2003,-3.78],[0.558,0.4658,0.2191,0.2001,-3.74],[0.5574,0.4667,0.219,0.2001,-3.62],[0.5566,0.468,0.2188,0.2,-3.51],[0.5558,0.4697,0.2185,0.1999,-3.41],[0.555,0.4716,0.2182,0.1997,-3.23],[0.5542,0.4733,0.2181,0.1997,-3.05],[0.5536,0.4748,0.2178,0.1995,-3.0],[0.5528,0.4757,0.2177,0.1994,-2.96],[0.5517,0.4759,0.2177,0.1994,-2.96],[0.5502,0.4752,0.2179,0.1996,-3.07],[0.5483,0.4737,0.2182,0.1997,-3.24],[0.546,0.4716,0.2186,0.1998,-3.46],[0.5434,0.4691,0.2191,0.2001,-3.68],[0.5408,0.4665,0.2198,0.2004,-3.95],[0.5385,0.464,0.2207,0.2006,-4.21],[0.5366,0.462,0.2214,0.2008,-4.43],[0.5351,0.4605,0.2219,0.201,-4.64],[0.5341,0.4594,0.222,0.2011,-4.83],[0.5334,0.4587,0.222,0.2011,-4.95],[0.533,0.4584,0.2218,0.2011,-5.04],[0.5327,0.4582,0.2216,0.201,-5.08],[0.5326,0.4581,0.2215,0.201,-5.08],[0.5327,0.4583,0.2215,0.2011,-5.11],[0.5328,0.4588,0.2215,0.201,-5.07],[0.5331,0.4597,0.2215,0.2011,-5.06],[0.5334,0.4608,0.2215,0.2011,-5.06],[0.534,0.4621,0.2213,0.2011,-5.05],[0.5345,0.4633,0.2211,0.201,-5.06],[0.5349,0.4642,0.2209,0.2011,-5.1],[0.5351,0.4646,0.2207,0.201,-5.1],[0.535,0.4645,0.2207,0.2011,-5.09],[0.5347,0.4638,0.2209,0.2011,-5.07],[0.5342,0.4626,0.2211,0.2011,-5.1],[0.5337,0.4613,0.2212,0.2011,-5.13],[0.5331,0.46,0.2215,0.2011,-5.11],[0.5327,0.4589,0.2216,0.201,-5.12],[0.5326,0.4583,0.2215,0.201,-5.17],[0.5326,0.4582,0.2216,0.201,-5.12],[0.5327,0.4587,0.2217,0.2011,-5.11],[0.533,0.4596,0.2215,0.2012,-5.13],[0.5334,0.4607,0.2213,0.2012,-5.16],[0.5337,0.462,0.2214,0.2012,-5.1],[0.534,0.463,0.2215,0.2011,-5.05],[0.5342,0.4636,0.2215,0.201,-5.01],[0.5342,0.4637,0.2215,0.2011,-5.02],[0.5341,0.4633,0.2215,0.201,-4.99],[0.5339,0.4623,0.2214,0.2012,-5.07],[0.5335,0.4611,0.2213,0.2012,-5.14],[0.5331,0.46,0.2215,0.2012,-5.15],[0.5328,0.4591,0.2216,0.2011,-5.13],[0.5326,0.4584,0.2217,0.2011,-5.13],[0.5326,0.4583,0.2217,0.2011,-5.13],[0.5327,0.4585,0.2216,0.2012,-5.15],[0.533,0.4592,0.2215,0.2012,-5.14],[0.5334,0.4601,0.2214,0.2013,-5.1],[0.5338,0.4612,0.2215,0.2013,-5.09],[0.5341,0.4622,0.2215,0.2013,-5.05],[0.5343,0.4629,0.2217,0.2012,-4.99],[0.5344,0.463,0.2217,0.2012,-4.98],[0.5343,0.4627,0.2217,0.2013,-5.06],[0.5341,0.4619,0.2216,0.2013,-5.1],[0.5338,0.4609,0.2215,0.2013,-5.16],[0.5336,0.4599,0.2214,0.2013,-5.21],[0.5335,0.459,0.2212,0.2013,-5.26],[0.5335,0.4585,0.2211,0.2012,-5.21],[0.5338,0.4582,0.221,0.2011,-5.15],[0.5344,0.4584,0.2209,0.2011,-5.04],[0.5354,0.4588,0.2206,0.201,-4.88],[0.5366,0.4595,0.2204,0.201,-4.66],[0.5381,0.4603,0.2201,0.2008,-4.39],[0.5397,0.4613,0.2197,0.2007,-4.13],[0.5413,0.4623,0.2194,0.2005,-3.8],[0.5429,0.4632,0.2192,0.2004,-3.49],[0.5444,0.4641,0.219,0.2002,-3.22],[0.5457,0.4647,0.2185,0.2002,-3.14],[0.5467,0.4652,0.2183,0.2001,-3.03],[0.5475,0.4655,0.2179,0.2001,-3.02],[0.548,0.4656,0.2177,0.2002,-3.06],[0.5483,0.4657,0.2175,0.2002,-3.12]],"rest":"t1-rest","mask":true,"reach":[0.2917,0.2969,0.7109,0.9219]},"wave":{"file":"clips/wave.mp4","fps":24,"frames":61,"size":384,"face":"happy","tracks":[[0.5483,0.4658,0.2174,0.2001,-3.16],[0.5484,0.4661,0.2174,0.2,-3.11],[0.5488,0.4665,0.2173,0.2,-3.03],[0.5494,0.4671,0.2173,0.1998,-2.84],[0.5504,0.4678,0.2175,0.1997,-2.51],[0.5516,0.4683,0.2176,0.1995,-2.09],[0.553,0.4685,0.2177,0.1995,-1.61],[0.5544,0.4681,0.218,0.1995,-1.01],[0.5558,0.4671,0.2182,0.1996,-0.39],[0.5571,0.4657,0.2183,0.1996,0.2],[0.5583,0.4639,0.2184,0.1997,0.79],[0.5595,0.4621,0.2186,0.1995,1.18],[0.5607,0.4604,0.2184,0.1995,1.63],[0.5617,0.459,0.2182,0.1995,1.98],[0.5626,0.458,0.2181,0.1995,2.17],[0.5632,0.4574,0.2179,0.1993,2.26],[0.5636,0.4571,0.2177,0.1994,2.44],[0.5636,0.4569,0.2178,0.1993,2.45],[0.5632,0.4569,0.2182,0.1993,2.43],[0.5628,0.4568,0.2186,0.1993,2.47],[0.5622,0.4568,0.219,0.1993,2.51],[0.5619,0.4568,0.2194,0.1993,2.51],[0.5618,0.4568,0.2194,0.1993,2.51],[0.5621,0.4568,0.2191,0.1993,2.51],[0.5628,0.4568,0.2184,0.1993,2.51],[0.5637,0.4569,0.2176,0.1993,2.5],[0.5645,0.4569,0.2168,0.1994,2.49],[0.5649,0.457,0.2164,0.1994,2.49],[0.5649,0.457,0.2164,0.1994,2.48],[0.5645,0.4569,0.2168,0.1994,2.46],[0.5637,0.4569,0.2176,0.1993,2.44],[0.5628,0.4568,0.2183,0.1993,2.41],[0.562,0.4568,0.219,0.1993,2.38],[0.5616,0.4567,0.2193,0.1993,2.35],[0.5616,0.4567,0.2193,0.1993,2.35],[0.562,0.4568,0.2189,0.1993,2.35],[0.5626,0.4568,0.2183,0.1994,2.36],[0.5634,0.4569,0.2177,0.1994,2.38],[0.564,0.4569,0.2172,0.1994,2.4],[0.5643,0.457,0.2169,0.1994,2.4],[0.5642,0.457,0.217,0.1994,2.42],[0.5638,0.457,0.2174,0.1994,2.41],[0.5633,0.4569,0.2179,0.1994,2.4],[0.5627,0.457,0.2183,0.1995,2.38],[0.5621,0.4571,0.2187,0.1996,2.34],[0.5616,0.4574,0.2188,0.1995,2.18],[0.5611,0.4578,0.2188,0.1995,1.99],[0.5605,0.4583,0.2186,0.1996,1.72],[0.5597,0.4591,0.2184,0.1995,1.34],[0.5586,0.46,0.2181,0.1995,0.86],[0.5574,0.4608,0.2178,0.1996,0.37],[0.556,0.4618,0.2176,0.1997,-0.21],[0.5546,0.4627,0.2175,0.1997,-0.83],[0.5531,0.4634,0.2173,0.1998,-1.41],[0.5517,0.4641,0.2174,0.1999,-1.89],[0.5505,0.4646,0.2174,0.1999,-2.33],[0.5496,0.465,0.2173,0.2,-2.67],[0.5489,0.4653,0.2173,0.2,-2.91],[0.5485,0.4655,0.2174,0.2001,-3.05],[0.5484,0.4656,0.2173,0.2001,-3.17],[0.5483,0.4656,0.2173,0.2001,-3.19]],"rest":"t1-rest","mask":true,"reach":[0.2943,0.2995,0.7109,0.9219]},"ask":{"file":"clips/ask.mp4","fps":24,"frames":56,"size":384,"face":"idle","tracks":[[0.5483,0.4656,0.2173,0.2001,-3.18],[0.5483,0.4657,0.2174,0.2001,-3.17],[0.5485,0.4661,0.2174,0.2,-3.09],[0.5488,0.4667,0.2176,0.1999,-2.92],[0.5493,0.4675,0.2177,0.1998,-2.71],[0.5501,0.4684,0.218,0.1996,-2.41],[0.5508,0.4694,0.2183,0.1994,-2.01],[0.5516,0.47,0.2186,0.1992,-1.58],[0.5523,0.4702,0.2189,0.1992,-1.18],[0.5528,0.4698,0.2194,0.1993,-0.69],[0.5531,0.4692,0.2198,0.1993,-0.24],[0.5534,0.4684,0.2202,0.1993,0.11],[0.5535,0.4676,0.2205,0.1993,0.38],[0.5536,0.4671,0.2208,0.1992,0.59],[0.5536,0.4667,0.2209,0.1991,0.68],[0.5536,0.4665,0.221,0.1992,0.73],[0.5536,0.4664,0.221,0.1993,0.76],[0.5536,0.4662,0.221,0.1995,0.78],[0.5536,0.4661,0.221,0.1997,0.78],[0.5536,0.4661,0.221,0.1997,0.77],[0.5536,0.4661,0.221,0.1997,0.76],[0.5536,0.4661,0.221,0.1997,0.76],[0.5536,0.4661,0.221,0.1997,0.76],[0.5536,0.4661,0.221,0.1997,0.76],[0.5536,0.4661,0.221,0.1997,0.77],[0.5536,0.4661,0.221,0.1997,0.77],[0.5536,0.4661,0.221,0.1997,0.77],[0.5536,0.4661,0.221,0.1997,0.78],[0.5536,0.4661,0.221,0.1997,0.78],[0.5536,0.4661,0.221,0.1997,0.78],[0.5536,0.4661,0.221,0.1997,0.79],[0.5536,0.4661,0.221,0.1997,0.78],[0.5536,0.4661,0.221,0.1997,0.78],[0.5536,0.4661,0.221,0.1997,0.77],[0.5536,0.4661,0.2209,0.1997,0.76],[0.5536,0.4661,0.2209,0.1997,0.75],[0.5536,0.4661,0.2209,0.1997,0.75],[0.5535,0.4661,0.2209,0.1997,0.75],[0.5535,0.4662,0.2209,0.1995,0.71],[0.5534,0.4664,0.2209,0.1994,0.68],[0.5533,0.4665,0.2209,0.1992,0.63],[0.5531,0.4666,0.2208,0.199,0.52],[0.5529,0.4668,0.2207,0.1988,0.34],[0.5526,0.4668,0.2204,0.199,0.1],[0.5522,0.4667,0.2201,0.1992,-0.23],[0.5518,0.4667,0.2197,0.1993,-0.62],[0.5513,0.4666,0.2194,0.1995,-1.02],[0.5508,0.4665,0.219,0.1997,-1.41],[0.5503,0.4664,0.2186,0.1997,-1.75],[0.5499,0.4664,0.2184,0.1996,-2.03],[0.5494,0.4663,0.2181,0.1997,-2.35],[0.549,0.4661,0.2179,0.1997,-2.61],[0.5487,0.466,0.2177,0.1998,-2.83],[0.5484,0.4659,0.2176,0.1999,-3.01],[0.5483,0.4658,0.2176,0.2,-3.15],[0.5482,0.4657,0.2176,0.2001,-3.19]],"rest":"t1-rest","mask":true,"reach":[0.2943,0.2995,0.7109,0.9219]},"thumbs-up":{"file":"clips/thumbs-up.mp4","fps":24,"frames":59,"size":384,"face":"happy","tracks":[[0.5483,0.4657,0.2179,0.2001,-3.13],[0.5483,0.4657,0.2178,0.2001,-3.13],[0.5484,0.4659,0.2178,0.2,-3.07],[0.5486,0.4664,0.2178,0.2,-2.97],[0.5492,0.4676,0.2179,0.1999,-2.77],[0.5502,0.4695,0.218,0.1998,-2.47],[0.5515,0.4722,0.2181,0.1996,-2.02],[0.5532,0.4751,0.2184,0.1996,-1.5],[0.5549,0.4778,0.2186,0.1995,-0.9],[0.5564,0.4792,0.2189,0.1993,-0.35],[0.5572,0.4787,0.219,0.199,0.04],[0.5573,0.4757,0.2191,0.1992,0.21],[0.5567,0.4713,0.219,0.1993,0.15],[0.5556,0.4661,0.2189,0.1994,-0.11],[0.5542,0.4611,0.2187,0.1996,-0.47],[0.5529,0.4572,0.2186,0.1998,-0.77],[0.5519,0.4547,0.2185,0.1997,-1.01],[0.5513,0.4534,0.2184,0.1996,-1.13],[0.5511,0.4528,0.2183,0.1995,-1.17],[0.551,0.4527,0.2183,0.1995,-1.15],[0.5515,0.4549,0.2184,0.1994,-1.04],[0.5523,0.4588,0.2185,0.1995,-0.85],[0.5534,0.4638,0.2187,0.1996,-0.65],[0.5548,0.4689,0.2189,0.1998,-0.31],[0.5563,0.4738,0.2192,0.1999,0.1],[0.5575,0.4755,0.2192,0.2001,0.47],[0.5584,0.4747,0.2193,0.2003,0.78],[0.559,0.4723,0.2192,0.2005,1.02],[0.5594,0.4691,0.219,0.2006,1.14],[0.5595,0.4662,0.2188,0.2006,1.17],[0.5597,0.4645,0.2186,0.2005,1.13],[0.5599,0.4641,0.2184,0.2004,1.11],[0.5603,0.4647,0.2182,0.2003,1.15],[0.5608,0.4663,0.2181,0.2002,1.18],[0.5613,0.4679,0.218,0.2001,1.27],[0.5619,0.4692,0.2179,0.2002,1.37],[0.5623,0.47,0.2179,0.2,1.43],[0.5626,0.4701,0.2179,0.1999,1.49],[0.5628,0.4696,0.2178,0.2,1.53],[0.5628,0.469,0.2178,0.1999,1.53],[0.5627,0.4684,0.2178,0.1999,1.52],[0.5627,0.4679,0.2178,0.2,1.52],[0.5626,0.4677,0.2178,0.2,1.48],[0.5625,0.4676,0.2178,0.1999,1.47],[0.5625,0.4676,0.2178,0.1999,1.46],[0.5625,0.4676,0.2178,0.1999,1.46],[0.5625,0.4676,0.2178,0.1999,1.46],[0.5625,0.4676,0.2178,0.1999,1.46],[0.5625,0.4676,0.2178,0.1999,1.47],[0.5625,0.4676,0.2179,0.1999,1.47],[0.5625,0.4676,0.2179,0.1999,1.48],[0.5625,0.4676,0.2178,0.1999,1.45],[0.5622,0.4677,0.2178,0.1999,1.36],[0.5616,0.4678,0.2178,0.1999,1.17],[0.5605,0.4679,0.2178,0.2,0.8],[0.5585,0.468,0.218,0.1999,0.26],[0.5557,0.468,0.2184,0.1999,-0.54],[0.5521,0.4678,0.2192,0.1999,-1.46],[0.5476,0.4675,0.2204,0.2,-2.42]],"rest":"t1-rest","mask":true,"reach":[0.2943,0.2995,0.7109,0.9219]},"notebook":{"file":"clips/notebook.mp4","fps":24,"frames":107,"size":384,"face":"idle","tracks":[[0.5478,0.4639,0.2178,0.2054,-3.5],[0.5478,0.4639,0.2179,0.2054,-3.5],[0.5479,0.464,0.2179,0.2055,-3.51],[0.5481,0.4643,0.2179,0.2055,-3.47],[0.5484,0.465,0.218,0.2055,-3.36],[0.5491,0.4659,0.2178,0.2054,-3.3],[0.5506,0.4678,0.2174,0.2051,-3.13],[0.5526,0.4703,0.2168,0.2045,-2.9],[0.555,0.4733,0.2159,0.2035,-2.67],[0.5578,0.4765,0.2148,0.2026,-2.45],[0.5605,0.4798,0.2138,0.2016,-2.13],[0.5625,0.4823,0.213,0.2006,-1.89],[0.5641,0.4844,0.2124,0.1999,-1.66],[0.5651,0.4858,0.2123,0.1994,-1.41],[0.5655,0.4869,0.2129,0.199,-1.37],[0.5655,0.4877,0.2137,0.1988,-1.19],[0.5653,0.4884,0.2148,0.1987,-0.86],[0.5647,0.4893,0.2166,0.1986,-0.73],[0.5642,0.4903,0.2183,0.1985,-0.43],[0.5636,0.4915,0.2202,0.1982,-0.05],[0.563,0.493,0.2221,0.1978,0.22],[0.5626,0.4945,0.2241,0.1973,0.51],[0.5626,0.4958,0.2253,0.1964,1.1],[0.5625,0.4969,0.2266,0.1957,1.5],[0.5627,0.4976,0.2271,0.195,1.96],[0.5629,0.498,0.2274,0.1944,2.35],[0.563,0.4982,0.2276,0.194,2.58],[0.563,0.4983,0.2277,0.1938,2.66],[0.5631,0.4983,0.2278,0.1938,2.65],[0.5634,0.4983,0.2278,0.1937,2.73],[0.5638,0.4983,0.2274,0.1936,2.76],[0.564,0.4983,0.2274,0.1936,2.76],[0.564,0.4984,0.2275,0.1937,2.7],[0.5636,0.4984,0.228,0.1938,2.68],[0.5629,0.4985,0.2285,0.194,2.59],[0.562,0.4985,0.2294,0.194,2.56],[0.5617,0.4985,0.2296,0.1941,2.57],[0.5618,0.4985,0.2294,0.1939,2.69],[0.5627,0.4984,0.2287,0.1938,2.78],[0.5637,0.4984,0.2278,0.1937,2.81],[0.5642,0.4985,0.2272,0.1938,2.76],[0.564,0.4986,0.2274,0.1939,2.71],[0.5635,0.4987,0.2278,0.1941,2.62],[0.5626,0.4987,0.2285,0.1942,2.59],[0.5617,0.4986,0.2293,0.1941,2.63],[0.5613,0.4985,0.2297,0.1939,2.72],[0.5612,0.4985,0.2298,0.1939,2.73],[0.5613,0.4986,0.2298,0.1939,2.69],[0.5616,0.4987,0.2294,0.1941,2.55],[0.562,0.4989,0.2291,0.1944,2.38],[0.5623,0.499,0.2286,0.1947,2.24],[0.5626,0.4991,0.2282,0.1949,2.14],[0.5625,0.4991,0.2282,0.1949,2.12],[0.5617,0.4991,0.2291,0.1948,2.19],[0.5608,0.4991,0.2298,0.1949,2.26],[0.5594,0.4991,0.231,0.195,2.25],[0.5579,0.4992,0.2321,0.1952,2.33],[0.5564,0.4994,0.2332,0.1955,2.27],[0.5553,0.4995,0.2338,0.1957,2.21],[0.5545,0.4995,0.2347,0.1957,2.15],[0.554,0.4995,0.235,0.1957,2.15],[0.5537,0.4995,0.2354,0.1957,2.04],[0.5537,0.4995,0.2354,0.1958,2.0],[0.5537,0.4996,0.2354,0.1959,1.94],[0.5537,0.4996,0.2355,0.1959,1.9],[0.5537,0.4996,0.2355,0.196,1.87],[0.5538,0.4997,0.2356,0.196,1.85],[0.5538,0.4997,0.2356,0.196,1.84],[0.5538,0.4997,0.2356,0.1961,1.82],[0.5538,0.4997,0.2356,0.1962,1.77],[0.5538,0.4998,0.2356,0.1963,1.71],[0.5537,0.4998,0.2356,0.1963,1.67],[0.5537,0.4998,0.2356,0.1964,1.62],[0.5537,0.4996,0.2356,0.1965,1.57],[0.5536,0.4992,0.2356,0.1967,1.49],[0.5536,0.4985,0.2355,0.197,1.41],[0.5535,0.4977,0.2353,0.1974,1.3],[0.5535,0.4966,0.235,0.1978,1.19],[0.5537,0.4954,0.234,0.1981,1.27],[0.5538,0.4943,0.233,0.1985,1.27],[0.554,0.4933,0.2319,0.1989,1.18],[0.5542,0.4924,0.2309,0.1993,1.02],[0.5544,0.4916,0.2299,0.1996,0.79],[0.5546,0.4906,0.229,0.1999,0.59],[0.5548,0.4892,0.2279,0.2002,0.39],[0.5548,0.4873,0.2266,0.2007,0.12],[0.5546,0.4848,0.2251,0.2013,-0.17],[0.5542,0.4817,0.2235,0.202,-0.49],[0.5534,0.4782,0.2223,0.2029,-1.09],[0.5524,0.4749,0.2213,0.2037,-1.69],[0.5514,0.4718,0.2204,0.2044,-2.2],[0.5504,0.4691,0.2198,0.2049,-2.63],[0.5494,0.467,0.2192,0.2052,-2.95],[0.5487,0.4655,0.2188,0.2055,-3.19],[0.5482,0.4645,0.2185,0.2056,-3.38],[0.5479,0.4639,0.2183,0.2057,-3.5],[0.5477,0.4636,0.2182,0.2058,-3.57],[0.5477,0.4635,0.2182,0.2059,-3.62],[0.5477,0.4634,0.2182,0.206,-3.66],[0.5477,0.4634,0.2182,0.206,-3.66],[0.5477,0.4634,0.2181,0.206,-3.66],[0.5477,0.4634,0.2181,0.206,-3.66],[0.5477,0.4634,0.2181,0.206,-3.66],[0.5477,0.4634,0.2181,0.206,-3.65],[0.5477,0.4634,0.2181,0.206,-3.65],[0.5477,0.4635,0.2182,0.2059,-3.61],[0.5479,0.464,0.2183,0.2058,-3.52]],"rest":"ta-rest","mask":true,"reach":[0.2578,0.2917,0.7474,0.9297]},"magnifier":{"file":"clips/magnifier.mp4","fps":24,"frames":116,"size":384,"face":"curious","tracks":[[0.5479,0.464,0.2183,0.2058,-3.52],[0.5483,0.4648,0.2186,0.2057,-3.34],[0.5489,0.4663,0.2192,0.2053,-3.06],[0.5494,0.4677,0.2197,0.205,-2.78],[0.5501,0.4699,0.2208,0.2046,-2.38],[0.5506,0.4724,0.2219,0.2041,-1.91],[0.5507,0.4748,0.2232,0.2035,-1.49],[0.5503,0.4767,0.2246,0.2032,-1.17],[0.5495,0.4782,0.2262,0.2029,-0.82],[0.5482,0.4782,0.2275,0.203,-0.65],[0.5466,0.4769,0.2286,0.2032,-0.63],[0.5449,0.4734,0.2295,0.2016,-0.74],[0.5433,0.4698,0.2301,0.2015,-0.64],[0.5419,0.4663,0.2304,0.2015,-0.28],[0.5408,0.4631,0.2305,0.2017,-0.54],[0.5399,0.4605,0.2305,0.202,-0.68],[0.5393,0.4597,0.2304,0.2043,-0.74],[0.539,0.459,0.2304,0.2049,-1.0],[0.5388,0.4585,0.2304,0.2055,-1.56],[0.5387,0.4584,0.2304,0.2056,-1.47],[0.5387,0.4584,0.2304,0.2056,-1.46],[0.5387,0.4584,0.2304,0.2056,-1.45],[0.5386,0.4583,0.2304,0.2055,-1.48],[0.5385,0.4583,0.2305,0.2055,-1.51],[0.538,0.4583,0.2309,0.2054,-1.47],[0.5369,0.4582,0.2317,0.2053,-1.33],[0.5349,0.458,0.2329,0.2052,-1.21],[0.533,0.458,0.2339,0.2051,-1.07],[0.5295,0.4578,0.2362,0.205,-0.73],[0.5258,0.4576,0.2387,0.2049,-0.44],[0.5219,0.4575,0.2411,0.2049,-0.2],[0.5189,0.4574,0.2431,0.2049,0.07],[0.5148,0.4573,0.2456,0.2049,0.38],[0.5116,0.4573,0.2467,0.205,0.51],[0.5086,0.4573,0.2471,0.2052,0.68],[0.5063,0.4574,0.2471,0.2054,0.84],[0.5034,0.4575,0.2472,0.2065,0.91],[0.5014,0.4576,0.2467,0.2077,0.89],[0.5,0.4577,0.2465,0.2089,0.84],[0.4991,0.4578,0.2463,0.2099,0.73],[0.4983,0.4578,0.2464,0.2109,0.58],[0.4981,0.4578,0.2464,0.2111,0.52],[0.4981,0.4578,0.2466,0.2111,0.51],[0.4981,0.4578,0.2467,0.211,0.5],[0.4982,0.4578,0.2467,0.211,0.47],[0.4984,0.4578,0.2468,0.211,0.47],[0.4991,0.4578,0.247,0.211,0.43],[0.4998,0.4579,0.2471,0.211,0.39],[0.5018,0.4579,0.2472,0.21,0.41],[0.5045,0.4578,0.2471,0.2088,0.42],[0.5079,0.4576,0.2465,0.2076,0.4],[0.511,0.4575,0.2461,0.2064,0.37],[0.5161,0.4572,0.2446,0.2049,0.14],[0.5209,0.457,0.2427,0.2043,-0.33],[0.5258,0.4571,0.24,0.2041,-0.8],[0.5298,0.4571,0.2376,0.204,-1.11],[0.5353,0.4572,0.2331,0.2043,-1.62],[0.5395,0.4574,0.229,0.2052,-1.94],[0.5429,0.4576,0.2241,0.2061,-1.69],[0.5453,0.4577,0.2202,0.207,-1.41],[0.5484,0.4578,0.2158,0.2078,-1.34],[0.5499,0.4579,0.2132,0.2082,-1.07],[0.5509,0.458,0.2112,0.2084,-0.84],[0.5516,0.458,0.2106,0.2085,-0.96],[0.5526,0.458,0.2097,0.2085,-1.17],[0.5533,0.4579,0.2092,0.2084,-1.43],[0.5541,0.4579,0.2086,0.2083,-1.66],[0.5548,0.4578,0.2079,0.2083,-1.9],[0.5556,0.4578,0.2072,0.2082,-2.18],[0.5563,0.4577,0.2067,0.2081,-2.39],[0.5566,0.4576,0.2063,0.2081,-2.49],[0.5567,0.4576,0.2061,0.2082,-2.57],[0.5568,0.4575,0.206,0.2082,-2.6],[0.5568,0.4574,0.2058,0.2082,-2.63],[0.5569,0.4573,0.2056,0.2083,-2.66],[0.5569,0.4572,0.2055,0.2083,-2.69],[0.557,0.4572,0.2053,0.2083,-2.72],[0.557,0.4571,0.2052,0.2084,-2.75],[0.5571,0.457,0.205,0.2084,-2.78],[0.5571,0.4569,0.2049,0.2084,-2.81],[0.5572,0.4568,0.2047,0.2084,-2.84],[0.5572,0.4568,0.2046,0.2085,-2.87],[0.5573,0.4567,0.2044,0.2085,-2.9],[0.5573,0.4566,0.2043,0.2085,-2.93],[0.5574,0.4565,0.2041,0.2086,-2.96],[0.5574,0.4565,0.2039,0.2086,-2.99],[0.5568,0.4564,0.2047,0.2087,-2.9],[0.5556,0.4564,0.2059,0.2088,-2.65],[0.5543,0.4563,0.2071,0.209,-2.39],[0.5528,0.4563,0.2082,0.2092,-2.11],[0.5514,0.4563,0.2094,0.2094,-1.81],[0.5506,0.4563,0.2097,0.2095,-1.63],[0.5504,0.4563,0.2096,0.2096,-1.58],[0.5504,0.4562,0.2097,0.2096,-1.64],[0.5506,0.4558,0.21,0.2084,-1.68],[0.5509,0.4551,0.2107,0.2066,-1.91],[0.5514,0.4542,0.2116,0.204,-2.58],[0.5518,0.4544,0.2124,0.2032,-2.91],[0.5523,0.455,0.2131,0.2024,-3.07],[0.5528,0.4565,0.2139,0.2028,-3.24],[0.5532,0.4585,0.2146,0.2038,-3.15],[0.5536,0.4613,0.2152,0.2056,-2.56],[0.5539,0.4633,0.2159,0.2053,-2.29],[0.5542,0.4651,0.2165,0.2052,-2.07],[0.5543,0.4666,0.217,0.2049,-1.84],[0.5543,0.4678,0.2175,0.2047,-1.7],[0.5541,0.4683,0.2178,0.2047,-1.69],[0.5537,0.4684,0.2179,0.2047,-1.78],[0.553,0.4681,0.2181,0.2047,-1.94],[0.5521,0.4674,0.2183,0.2049,-2.1],[0.5511,0.4665,0.2185,0.2052,-2.33],[0.5502,0.4657,0.2185,0.2053,-2.56],[0.5493,0.465,0.2186,0.2054,-2.78],[0.5486,0.4644,0.2187,0.2055,-2.97],[0.5482,0.464,0.2185,0.2056,-3.18],[0.548,0.4638,0.2184,0.2056,-3.31]],"rest":"ta-rest","mask":true,"reach":[0.263,0.2943,0.7188,0.9297]},"cards-shuffle":{"file":"clips/cards-shuffle.mp4","fps":24,"frames":119,"size":384,"face":"curious","tracks":[[0.5488,0.4655,0.2176,0.207,-3.34],[0.5493,0.466,0.2175,0.2068,-3.25],[0.5502,0.4672,0.2172,0.2066,-3.17],[0.5517,0.4691,0.2168,0.2061,-3.02],[0.5538,0.4721,0.2161,0.2052,-2.8],[0.5566,0.4758,0.2148,0.2042,-2.59],[0.5597,0.4802,0.2135,0.2028,-2.3],[0.5628,0.4848,0.2122,0.2012,-1.97],[0.5657,0.4893,0.2109,0.1994,-1.71],[0.5682,0.4932,0.2096,0.1979,-1.44],[0.5702,0.4961,0.2084,0.1964,-1.27],[0.5715,0.498,0.2078,0.1954,-1.26],[0.5719,0.4988,0.2077,0.1949,-1.3],[0.5719,0.4987,0.2079,0.1951,-1.22],[0.5714,0.4977,0.2084,0.1957,-1.11],[0.5704,0.4962,0.2096,0.1966,-0.96],[0.569,0.4943,0.2111,0.1977,-0.67],[0.5675,0.4921,0.2126,0.1989,-0.23],[0.5661,0.4899,0.214,0.2001,0.26],[0.5644,0.4878,0.2157,0.2012,0.64],[0.563,0.486,0.2171,0.2022,1.02],[0.5617,0.4845,0.2182,0.203,1.34],[0.5608,0.4834,0.219,0.2035,1.51],[0.56,0.4827,0.2199,0.2039,1.52],[0.5595,0.4822,0.2204,0.2041,1.52],[0.5594,0.482,0.2205,0.2042,1.56],[0.5596,0.482,0.2205,0.2043,1.63],[0.5602,0.482,0.2201,0.2044,1.84],[0.5611,0.4822,0.2198,0.2044,2.02],[0.5624,0.4825,0.219,0.2045,2.32],[0.5638,0.4829,0.2182,0.2046,2.5],[0.5654,0.4832,0.2173,0.2045,2.66],[0.5667,0.4834,0.2166,0.2042,2.73],[0.5679,0.4835,0.2157,0.2039,2.86],[0.5688,0.4835,0.2153,0.2035,2.9],[0.5693,0.4833,0.2149,0.203,3.03],[0.5696,0.4832,0.2148,0.2028,3.11],[0.5696,0.4833,0.2149,0.2029,3.07],[0.5692,0.4835,0.2152,0.2032,3.07],[0.5685,0.4837,0.2156,0.2036,3.17],[0.5673,0.4838,0.2165,0.2038,3.2],[0.5657,0.484,0.2177,0.2041,3.17],[0.5638,0.484,0.2191,0.2041,3.2],[0.5619,0.484,0.2203,0.204,3.16],[0.5601,0.484,0.2217,0.2039,2.95],[0.5586,0.484,0.2228,0.2039,2.75],[0.5574,0.4839,0.2237,0.2039,2.54],[0.5564,0.4839,0.2242,0.2039,2.37],[0.5552,0.4837,0.2249,0.2039,1.98],[0.5535,0.4835,0.2255,0.2038,1.54],[0.551,0.483,0.2261,0.2038,0.97],[0.5476,0.4824,0.2269,0.204,0.4],[0.5433,0.4818,0.2281,0.2047,-0.26],[0.5386,0.4812,0.2293,0.2057,-0.85],[0.5338,0.4805,0.2306,0.2065,-1.45],[0.5293,0.48,0.2319,0.2076,-1.93],[0.5257,0.4796,0.2328,0.2089,-2.36],[0.5227,0.4795,0.2335,0.2103,-2.66],[0.5205,0.4796,0.234,0.2118,-2.82],[0.5191,0.4799,0.2342,0.2133,-2.95],[0.5182,0.4801,0.2344,0.2146,-3.02],[0.5176,0.4803,0.2346,0.2153,-3.06],[0.5174,0.4803,0.2347,0.2154,-3.11],[0.5173,0.4803,0.2347,0.2154,-3.14],[0.5174,0.4804,0.2347,0.2154,-3.07],[0.5174,0.4806,0.2347,0.2154,-3.0],[0.5175,0.4808,0.2347,0.2152,-2.93],[0.5176,0.4811,0.2347,0.215,-2.88],[0.5178,0.4814,0.2347,0.2142,-2.97],[0.5179,0.4813,0.2348,0.2121,-3.39],[0.5179,0.4811,0.2349,0.2094,-4.02],[0.5176,0.4798,0.2351,0.2052,-4.88],[0.5167,0.477,0.2354,0.1996,-5.94],[0.5153,0.473,0.2355,0.1935,-7.04],[0.5133,0.4683,0.2358,0.1885,-7.87],[0.5109,0.4628,0.236,0.184,-8.56],[0.5083,0.4578,0.2362,0.1816,-9.05],[0.506,0.4539,0.2364,0.1811,-9.31],[0.504,0.451,0.2367,0.1818,-9.47],[0.5026,0.4489,0.237,0.1824,-9.55],[0.5017,0.4477,0.2371,0.1833,-9.63],[0.5014,0.4471,0.2371,0.1835,-9.69],[0.5014,0.4468,0.2371,0.1831,-9.76],[0.5016,0.4468,0.2371,0.1824,-9.79],[0.5019,0.447,0.237,0.1819,-9.84],[0.5025,0.4474,0.237,0.1813,-9.77],[0.5033,0.4482,0.237,0.1811,-9.49],[0.5045,0.4495,0.237,0.1817,-8.86],[0.5064,0.452,0.2371,0.184,-7.68],[0.5088,0.4559,0.2373,0.188,-6.15],[0.5116,0.4609,0.2377,0.1931,-4.66],[0.5147,0.4664,0.2383,0.198,-3.12],[0.5176,0.472,0.2392,0.2019,-1.61],[0.52,0.4774,0.2402,0.2041,-0.41],[0.5218,0.4819,0.2412,0.2044,0.57],[0.5229,0.4859,0.2423,0.2035,1.67],[0.5234,0.4896,0.2432,0.2025,2.59],[0.5234,0.4926,0.2439,0.2017,3.28],[0.5233,0.4949,0.2445,0.2011,3.76],[0.5232,0.4965,0.2448,0.2006,4.09],[0.5231,0.4974,0.245,0.2004,4.28],[0.5231,0.4978,0.2451,0.2004,4.34],[0.5233,0.4978,0.2452,0.2004,4.31],[0.5238,0.4974,0.2451,0.2005,4.22],[0.5248,0.4966,0.2447,0.2007,3.98],[0.5262,0.4952,0.244,0.201,3.56],[0.5283,0.4933,0.2428,0.2015,3.08],[0.5309,0.4907,0.2411,0.2021,2.44],[0.5338,0.4876,0.2388,0.2028,1.63],[0.5368,0.4841,0.236,0.2036,0.77],[0.5397,0.4805,0.2327,0.2044,-0.07],[0.5423,0.4768,0.2293,0.205,-0.92],[0.5445,0.4733,0.2259,0.2056,-1.74],[0.5462,0.4704,0.2229,0.2061,-2.38],[0.5474,0.4682,0.2206,0.2064,-2.87],[0.5482,0.4667,0.2189,0.2066,-3.2],[0.5486,0.4658,0.2179,0.2068,-3.41],[0.5487,0.4654,0.2175,0.2069,-3.48],[0.5488,0.4653,0.2173,0.207,-3.52]],"rest":"tb-rest","mask":true,"reach":[0.276,0.2891,0.7865,0.9323]},"cards-fan":{"file":"clips/cards-fan.mp4","fps":24,"frames":137,"size":384,"face":"happy","tracks":[[0.5487,0.4654,0.2173,0.207,-3.52],[0.5488,0.4656,0.2174,0.207,-3.5],[0.549,0.4663,0.2175,0.2067,-3.46],[0.5495,0.4675,0.2177,0.2066,-3.38],[0.55,0.4695,0.218,0.2062,-3.25],[0.5506,0.4724,0.2184,0.2057,-3.08],[0.5508,0.4757,0.2189,0.205,-2.89],[0.5505,0.4792,0.2197,0.2043,-2.74],[0.5494,0.4826,0.2207,0.2034,-2.7],[0.5473,0.4857,0.222,0.2027,-2.77],[0.5443,0.4879,0.2237,0.2022,-2.97],[0.5405,0.4894,0.2256,0.2019,-3.15],[0.5363,0.4902,0.2276,0.2019,-3.5],[0.5319,0.4902,0.2298,0.2021,-3.82],[0.5276,0.4895,0.2319,0.2025,-4.06],[0.5235,0.4884,0.2339,0.2031,-4.16],[0.5199,0.4868,0.2356,0.2036,-4.3],[0.5165,0.4849,0.2373,0.2043,-4.2],[0.5133,0.4826,0.2388,0.205,-4.03],[0.5105,0.4801,0.2401,0.2057,-3.79],[0.5079,0.4775,0.2412,0.2063,-3.54],[0.5055,0.4745,0.2422,0.2063,-3.34],[0.5035,0.4711,0.243,0.205,-3.04],[0.5019,0.4677,0.2436,0.2027,-2.72],[0.5006,0.4646,0.2439,0.2,-2.45],[0.4996,0.4619,0.2442,0.1972,-2.22],[0.499,0.4599,0.2443,0.195,-1.94],[0.4986,0.4588,0.2443,0.1939,-1.82],[0.4983,0.4584,0.2443,0.1936,-1.74],[0.4982,0.4582,0.2443,0.1935,-1.7],[0.4981,0.4582,0.2442,0.1935,-1.65],[0.498,0.4579,0.2442,0.1931,-1.58],[0.4979,0.4575,0.2443,0.1924,-1.55],[0.4978,0.4569,0.2443,0.1915,-1.61],[0.4976,0.4562,0.2443,0.1905,-1.76],[0.4974,0.4556,0.2443,0.1895,-1.98],[0.4972,0.4551,0.2443,0.1889,-2.26],[0.4971,0.4548,0.2443,0.1887,-2.51],[0.4969,0.4546,0.2443,0.1887,-2.68],[0.4968,0.4546,0.2443,0.1887,-2.75],[0.4968,0.4546,0.2443,0.1887,-2.72],[0.4967,0.4545,0.2443,0.1887,-2.57],[0.4967,0.4545,0.2443,0.1887,-2.29],[0.4967,0.4546,0.2443,0.1889,-1.92],[0.4967,0.4547,0.2443,0.1891,-1.52],[0.4967,0.4549,0.2443,0.1894,-1.16],[0.4968,0.4552,0.2443,0.1896,-0.91],[0.4969,0.4555,0.2443,0.1895,-0.77],[0.497,0.456,0.2443,0.1889,-0.67],[0.4973,0.4563,0.2444,0.1878,-0.6],[0.4976,0.4564,0.2444,0.1859,-0.51],[0.4979,0.4565,0.2445,0.1842,-0.4],[0.4982,0.4564,0.2446,0.1826,-0.29],[0.4985,0.4561,0.2446,0.1815,-0.22],[0.4987,0.4559,0.2446,0.1809,-0.15],[0.4988,0.4558,0.2446,0.181,-0.11],[0.4989,0.4557,0.2446,0.1811,-0.07],[0.4989,0.4557,0.2446,0.1812,-0.02],[0.499,0.4556,0.2446,0.1813,0.02],[0.4991,0.4555,0.2446,0.1814,0.06],[0.4992,0.4555,0.2446,0.1815,0.1],[0.4993,0.4554,0.2446,0.1816,0.15],[0.4994,0.4554,0.2446,0.1817,0.19],[0.4995,0.4553,0.2446,0.1818,0.23],[0.4996,0.4552,0.2446,0.1819,0.28],[0.4997,0.4552,0.2446,0.182,0.32],[0.4998,0.4551,0.2446,0.1821,0.36],[0.4998,0.455,0.2446,0.1822,0.4],[0.4999,0.455,0.2446,0.1823,0.45],[0.5,0.4549,0.2446,0.1824,0.49],[0.5001,0.4549,0.2446,0.1825,0.53],[0.5002,0.4548,0.2446,0.1826,0.58],[0.5003,0.4547,0.2446,0.1827,0.62],[0.5004,0.4547,0.2446,0.1828,0.66],[0.5005,0.4546,0.2446,0.1829,0.7],[0.5006,0.4545,0.2446,0.183,0.75],[0.5007,0.4545,0.2447,0.1831,0.79],[0.5007,0.4544,0.2447,0.1832,0.83],[0.5008,0.4544,0.2447,0.1833,0.88],[0.5009,0.4543,0.2447,0.1834,0.92],[0.501,0.4542,0.2447,0.1835,0.96],[0.5011,0.4542,0.2447,0.1836,1.01],[0.5012,0.4541,0.2447,0.1837,1.05],[0.5013,0.454,0.2447,0.1838,1.09],[0.5014,0.454,0.2447,0.1839,1.13],[0.5015,0.4539,0.2447,0.184,1.18],[0.5015,0.4538,0.2447,0.1841,1.22],[0.5016,0.4538,0.2447,0.1842,1.26],[0.5017,0.4537,0.2447,0.1843,1.31],[0.5018,0.4537,0.2447,0.1844,1.35],[0.5019,0.4536,0.2447,0.1845,1.39],[0.502,0.4535,0.2447,0.1846,1.43],[0.5021,0.4535,0.2447,0.1847,1.48],[0.5022,0.4534,0.2447,0.1848,1.52],[0.5023,0.4533,0.2447,0.1849,1.56],[0.5024,0.4533,0.2447,0.185,1.61],[0.5024,0.4532,0.2447,0.1851,1.65],[0.5025,0.4532,0.2447,0.1852,1.69],[0.5026,0.4531,0.2448,0.1853,1.73],[0.5027,0.453,0.2448,0.1854,1.78],[0.5028,0.453,0.2448,0.1855,1.82],[0.5029,0.4529,0.2448,0.1856,1.86],[0.503,0.4528,0.2448,0.1857,1.91],[0.5031,0.4528,0.2448,0.1858,1.95],[0.503,0.4543,0.2448,0.1888,1.89],[0.5025,0.4571,0.2449,0.1929,1.78],[0.5016,0.4604,0.245,0.1973,1.58],[0.5001,0.4644,0.2452,0.2018,1.4],[0.498,0.4691,0.2453,0.206,1.23],[0.4955,0.473,0.2453,0.2071,1.17],[0.4928,0.4764,0.2451,0.2069,1.2],[0.4901,0.4798,0.2447,0.2062,1.35],[0.4877,0.4829,0.2442,0.2054,1.53],[0.4857,0.4855,0.2436,0.2047,1.71],[0.4842,0.4875,0.2431,0.204,1.89],[0.4834,0.489,0.2428,0.2036,2.07],[0.4831,0.49,0.2426,0.2032,2.23],[0.4832,0.4908,0.2425,0.203,2.35],[0.4838,0.4915,0.2427,0.2028,2.49],[0.485,0.492,0.243,0.2027,2.63],[0.4868,0.4922,0.2434,0.2026,2.68],[0.4894,0.4922,0.2439,0.2025,2.64],[0.4931,0.4918,0.2445,0.2027,2.5],[0.4979,0.491,0.2452,0.2028,2.38],[0.5034,0.4897,0.2452,0.2032,1.97],[0.5094,0.4879,0.2447,0.2037,1.47],[0.5157,0.4857,0.2435,0.2041,0.9],[0.5219,0.4831,0.2414,0.2045,0.27],[0.5275,0.4803,0.2381,0.2052,-0.53],[0.5328,0.4773,0.2345,0.2057,-1.18],[0.5374,0.4743,0.2306,0.2061,-1.79],[0.5414,0.4715,0.2267,0.2066,-2.33],[0.5443,0.4691,0.2234,0.2069,-2.73],[0.5464,0.4673,0.2208,0.207,-2.99],[0.5476,0.4662,0.2191,0.207,-3.16],[0.5483,0.4656,0.2182,0.2071,-3.22],[0.5485,0.4653,0.2178,0.2071,-3.22]],"rest":"tb-rest","mask":true,"reach":[0.2682,0.2917,0.7578,0.9323]},"hop":{"file":"clips/hop.mp4","fps":24,"frames":40,"size":384,"face":"happy","tracks":[[0.5489,0.4667,0.2178,0.2067,-3.27],[0.5499,0.4699,0.218,0.2066,-3.11],[0.5516,0.4754,0.2182,0.206,-2.87],[0.5538,0.4831,0.2185,0.2049,-2.57],[0.5563,0.4925,0.2189,0.2037,-2.17],[0.5585,0.5025,0.2195,0.2014,-1.68],[0.5599,0.5111,0.2203,0.1987,-1.5],[0.56,0.5169,0.2215,0.1963,-1.43],[0.5587,0.5176,0.2225,0.1941,-1.36],[0.5544,0.5053,0.2231,0.1938,-1.44],[0.548,0.4848,0.2236,0.1953,-1.77],[0.54,0.459,0.2237,0.198,-2.21],[0.5311,0.4315,0.2238,0.2004,-2.87],[0.5219,0.4055,0.224,0.2028,-3.76],[0.5151,0.3902,0.2246,0.2036,-4.66],[0.51,0.3812,0.2252,0.2034,-5.45],[0.5068,0.3769,0.2257,0.2024,-6.0],[0.5051,0.3751,0.2259,0.2018,-6.29],[0.5046,0.3746,0.2259,0.2013,-6.43],[0.5046,0.3748,0.2258,0.2011,-6.53],[0.5047,0.3756,0.2258,0.201,-6.56],[0.505,0.3777,0.2258,0.2006,-6.54],[0.5057,0.3838,0.2256,0.2005,-6.58],[0.5071,0.3949,0.2254,0.2004,-6.59],[0.5105,0.4089,0.2251,0.2004,-6.43],[0.5159,0.4252,0.2248,0.2014,-6.09],[0.5226,0.4428,0.2243,0.2029,-5.66],[0.5302,0.4582,0.2236,0.2042,-4.95],[0.538,0.4696,0.2227,0.2054,-4.13],[0.5444,0.4785,0.2219,0.2062,-3.34],[0.5491,0.4843,0.2208,0.2057,-2.7],[0.5522,0.4865,0.2199,0.2052,-2.22],[0.5536,0.4857,0.2193,0.2049,-2.03],[0.5538,0.4826,0.2188,0.2051,-2.08],[0.5531,0.4783,0.2183,0.2055,-2.32],[0.5519,0.4739,0.2181,0.2062,-2.64],[0.5507,0.4703,0.2178,0.2067,-2.95],[0.5498,0.4677,0.2176,0.207,-3.2],[0.5492,0.4662,0.2175,0.2071,-3.35],[0.5489,0.4655,0.2174,0.2072,-3.42]],"rest":"tb-rest","mask":true,"reach":[0.2604,0.224,0.737,0.9323]},"story-hourglass":{"file":"clips/story-hourglass.mp4","fps":24,"frames":124,"size":384,"face":"idle","rest":"story-hourglass-rest","mask":true,"tracks":[[0.4878,0.2786,0.3125,0.2452,-0.49],[0.4878,0.2787,0.3125,0.2452,-0.49],[0.4878,0.2787,0.3125,0.2452,-0.49],[0.4878,0.2788,0.3125,0.2452,-0.48],[0.4878,0.2788,0.3125,0.2452,-0.48],[0.4878,0.2788,0.3125,0.2452,-0.48],[0.4878,0.2788,0.3125,0.2452,-0.47],[0.4878,0.2788,0.3125,0.2452,-0.47],[0.4878,0.2788,0.3125,0.2452,-0.45],[0.4878,0.2791,0.3125,0.2452,-0.42],[0.4877,0.2799,0.3125,0.2452,-0.37],[0.4877,0.2807,0.3125,0.2452,-0.3],[0.4877,0.2814,0.3125,0.2452,-0.2],[0.4878,0.2814,0.3125,0.2452,-0.07],[0.4879,0.2802,0.3125,0.2452,0.08],[0.4881,0.2776,0.3125,0.2452,0.25],[0.4883,0.2739,0.3125,0.2452,0.44],[0.4884,0.2694,0.3125,0.2452,0.64],[0.4886,0.2648,0.3125,0.2452,0.83],[0.4889,0.2602,0.3125,0.2452,1.02],[0.4892,0.2558,0.3125,0.2452,1.18],[0.4895,0.2522,0.3125,0.2452,1.3],[0.4896,0.249,0.3125,0.2452,1.44],[0.4893,0.2463,0.3125,0.2452,1.76],[0.4883,0.2448,0.3125,0.2452,2.09],[0.4876,0.244,0.3125,0.2452,2.17],[0.4869,0.2433,0.3125,0.2452,1.86],[0.484,0.2449,0.3125,0.2452,1.13],[0.4788,0.2493,0.3125,0.2452,-0.16],[0.4726,0.2554,0.3125,0.2452,-1.89],[0.4658,0.263,0.3125,0.2452,-3.94],[0.4588,0.2721,0.3125,0.2452,-5.9],[0.4542,0.2805,0.3125,0.2452,-7.34],[0.4535,0.2889,0.3125,0.2452,-8.06],[0.4558,0.2969,0.3125,0.2452,-7.97],[0.4604,0.3049,0.3125,0.2452,-6.9],[0.4671,0.3116,0.3125,0.2452,-5.06],[0.4753,0.3167,0.3125,0.2452,-2.88],[0.4838,0.3187,0.3125,0.2452,-0.63],[0.4921,0.3184,0.3125,0.2452,1.55],[0.4998,0.3163,0.3125,0.2452,3.62],[0.5066,0.3134,0.3125,0.2452,5.48],[0.5126,0.3107,0.3125,0.2452,7.07],[0.5187,0.3085,0.3125,0.2452,8.37],[0.524,0.3066,0.3125,0.2452,9.48],[0.5288,0.3051,0.3125,0.2452,10.38],[0.5333,0.3039,0.3125,0.2452,11.07],[0.5376,0.3029,0.3125,0.2452,11.53],[0.5404,0.3021,0.3125,0.2452,11.81],[0.5429,0.3014,0.3125,0.2452,11.95],[0.5448,0.3008,0.3125,0.2452,12.0],[0.5459,0.3004,0.3125,0.2452,12.0],[0.5464,0.3001,0.3125,0.2452,12.0],[0.5469,0.2998,0.3125,0.2452,12.0],[0.5471,0.2995,0.3125,0.2452,12.0],[0.5471,0.2993,0.3125,0.2452,12.0],[0.5471,0.299,0.3125,0.2452,12.0],[0.5471,0.2988,0.3125,0.2452,12.0],[0.547,0.2985,0.3125,0.2452,12.0],[0.5469,0.2982,0.3125,0.2452,12.0],[0.5469,0.298,0.3125,0.2452,12.0],[0.5468,0.2978,0.3125,0.2452,12.0],[0.5468,0.2977,0.3125,0.2452,12.0],[0.5467,0.2976,0.3125,0.2452,12.0],[0.5467,0.2975,0.3125,0.2452,12.0],[0.5466,0.2974,0.3125,0.2452,12.0],[0.5466,0.2973,0.3125,0.2452,12.0],[0.5465,0.2972,0.3125,0.2452,12.0],[0.5465,0.2971,0.3125,0.2452,12.0],[0.5465,0.297,0.3125,0.2452,12.0],[0.5465,0.297,0.3125,0.2452,12.0],[0.5465,0.2969,0.3125,0.2452,12.0],[0.5465,0.2969,0.3125,0.2452,12.0],[0.5465,0.2968,0.3125,0.2452,12.0],[0.5465,0.2967,0.3125,0.2452,12.0],[0.5465,0.2966,0.3125,0.2452,12.0],[0.5465,0.2965,0.3125,0.2452,12.0],[0.5464,0.2964,0.3125,0.2452,12.0],[0.5464,0.2964,0.3125,0.2452,12.0],[0.5464,0.2963,0.3125,0.2452,12.0],[0.5463,0.2963,0.3125,0.2452,12.0],[0.5463,0.2963,0.3125,0.2452,12.0],[0.5458,0.2966,0.3125,0.2452,12.0],[0.5445,0.297,0.3125,0.2452,11.96],[0.5423,0.2976,0.3125,0.2452,11.85],[0.5393,0.2979,0.3125,0.2452,11.6],[0.5351,0.2977,0.3125,0.2452,11.16],[0.53,0.2967,0.3125,0.2452,10.48],[0.5245,0.295,0.3125,0.2452,9.56],[0.5186,0.2927,0.3125,0.2452,8.4],[0.5123,0.2901,0.3125,0.2452,7.09],[0.5064,0.2877,0.3125,0.2452,5.73],[0.5015,0.2856,0.3125,0.2452,4.38],[0.4979,0.2839,0.3125,0.2452,3.16],[0.4953,0.2829,0.3125,0.2452,2.13],[0.4932,0.2822,0.3125,0.2452,1.31],[0.4917,0.2817,0.3125,0.2452,0.7],[0.4906,0.2813,0.3125,0.2452,0.26],[0.4894,0.2809,0.3125,0.2452,-0.04],[0.4885,0.2802,0.3125,0.2452,-0.22],[0.488,0.2796,0.3125,0.2452,-0.34],[0.4879,0.2792,0.3125,0.2452,-0.42],[0.4878,0.2788,0.3125,0.2452,-0.46],[0.4878,0.2785,0.3125,0.2452,-0.47],[0.4878,0.2784,0.3125,0.2452,-0.48],[0.4878,0.2783,0.3125,0.2452,-0.49],[0.4878,0.2783,0.3125,0.2452,-0.49],[0.4878,0.2783,0.3125,0.2452,-0.49],[0.4878,0.2783,0.3125,0.2452,-0.5],[0.4878,0.2783,0.3125,0.2452,-0.49],[0.4878,0.2783,0.3125,0.2452,-0.49],[0.4878,0.2783,0.3125,0.2452,-0.49],[0.4878,0.2783,0.3125,0.2452,-0.48],[0.4879,0.2783,0.3125,0.2452,-0.48],[0.4879,0.2784,0.3125,0.2452,-0.48],[0.4879,0.2784,0.3125,0.2452,-0.48],[0.4879,0.2784,0.3125,0.2452,-0.48],[0.4879,0.2784,0.3125,0.2452,-0.49],[0.4879,0.2785,0.3125,0.2452,-0.49],[0.4879,0.2785,0.3125,0.2452,-0.49],[0.4879,0.2785,0.3125,0.2452,-0.5],[0.4879,0.2786,0.3125,0.2452,-0.5],[0.4879,0.2786,0.3125,0.2452,-0.5],[0.4879,0.2786,0.3125,0.2452,-0.5]],"reach":[0.1634,0.03,0.8366,0.92],"script":[[0,"idle",0,1],[0.35,"happy",0,0],[0.8,"blink",0,0],[0.84,"idle",0,0]]},"story-business":{"file":"clips/story-business.mp4","fps":24,"frames":124,"size":384,"face":"idle","rest":"story-business-rest","mask":true,"tracks":[[0.4789,0.293,0.3099,0.244,-0.19],[0.4788,0.293,0.3099,0.244,-0.2],[0.4788,0.293,0.3099,0.244,-0.2],[0.4788,0.293,0.3099,0.244,-0.22],[0.4787,0.293,0.3099,0.244,-0.23],[0.4787,0.293,0.3099,0.244,-0.26],[0.4786,0.293,0.3099,0.244,-0.3],[0.4783,0.293,0.3099,0.244,-0.35],[0.4778,0.293,0.3099,0.244,-0.43],[0.4772,0.293,0.3099,0.244,-0.52],[0.4764,0.2931,0.3099,0.244,-0.61],[0.4759,0.2933,0.3099,0.244,-0.68],[0.4763,0.2938,0.3099,0.244,-0.72],[0.4781,0.2945,0.3099,0.244,-0.72],[0.4812,0.2955,0.3099,0.244,-0.66],[0.4852,0.2966,0.3099,0.244,-0.56],[0.4893,0.2978,0.3099,0.244,-0.44],[0.4932,0.2989,0.3099,0.244,-0.31],[0.4961,0.2997,0.3099,0.244,-0.17],[0.4981,0.3002,0.3099,0.244,-0.05],[0.4994,0.3005,0.3099,0.244,0.05],[0.5005,0.3006,0.3099,0.244,0.14],[0.5012,0.3006,0.3099,0.244,0.22],[0.5017,0.3006,0.3099,0.244,0.29],[0.5019,0.3006,0.3099,0.244,0.36],[0.502,0.3006,0.3099,0.244,0.41],[0.502,0.3006,0.3099,0.244,0.44],[0.502,0.3006,0.3099,0.244,0.47],[0.502,0.3006,0.3099,0.244,0.48],[0.502,0.3006,0.3099,0.244,0.48],[0.502,0.3006,0.3099,0.244,0.5],[0.5021,0.3006,0.3099,0.244,0.53],[0.5022,0.3007,0.3099,0.244,0.58],[0.5026,0.301,0.3099,0.244,0.66],[0.5032,0.3014,0.3099,0.244,0.77],[0.5044,0.3021,0.3099,0.244,0.91],[0.506,0.3031,0.3099,0.244,1.08],[0.5081,0.3043,0.3099,0.244,1.28],[0.5108,0.3054,0.3099,0.244,1.5],[0.5137,0.3066,0.3099,0.244,1.71],[0.5162,0.3075,0.3099,0.244,1.9],[0.5183,0.3082,0.3099,0.244,2.04],[0.5196,0.3085,0.3099,0.244,2.11],[0.5197,0.3084,0.3099,0.244,2.09],[0.5183,0.3078,0.3099,0.244,1.98],[0.5161,0.3067,0.3099,0.244,1.79],[0.5134,0.3052,0.3099,0.244,1.53],[0.5104,0.3036,0.3099,0.244,1.24],[0.5077,0.302,0.3099,0.244,0.95],[0.5059,0.3006,0.3099,0.244,0.7],[0.5048,0.2996,0.3099,0.244,0.5],[0.5042,0.2992,0.3099,0.244,0.39],[0.5043,0.2992,0.3099,0.244,0.32],[0.5049,0.2998,0.3099,0.244,0.31],[0.5056,0.3007,0.3099,0.244,0.34],[0.5063,0.3018,0.3099,0.244,0.39],[0.5068,0.3028,0.3099,0.244,0.44],[0.5071,0.3036,0.3099,0.244,0.47],[0.5067,0.3039,0.3099,0.244,0.47],[0.5062,0.3034,0.3099,0.244,0.44],[0.5055,0.3021,0.3099,0.244,0.37],[0.5044,0.3003,0.3099,0.244,0.27],[0.5032,0.2983,0.3099,0.244,0.15],[0.5021,0.2968,0.3099,0.244,0.03],[0.5013,0.2969,0.3099,0.244,-0.09],[0.501,0.2998,0.3099,0.244,-0.19],[0.5011,0.3056,0.3099,0.244,-0.26],[0.5009,0.3143,0.3099,0.244,-0.32],[0.5003,0.3251,0.3099,0.244,-0.35],[0.4994,0.3363,0.3099,0.244,-0.37],[0.4984,0.3464,0.3099,0.244,-0.37],[0.4976,0.3549,0.3099,0.244,-0.32],[0.4983,0.3609,0.3099,0.244,-0.2],[0.5009,0.3637,0.3099,0.244,0.03],[0.5051,0.3636,0.3099,0.244,0.43],[0.5107,0.3605,0.3099,0.244,1.02],[0.5173,0.3542,0.3099,0.244,1.77],[0.5242,0.3452,0.3099,0.244,2.67],[0.5307,0.3342,0.3099,0.244,3.62],[0.5363,0.3226,0.3099,0.244,4.55],[0.5404,0.3117,0.3099,0.244,5.37],[0.543,0.303,0.3099,0.244,6.0],[0.5438,0.2971,0.3099,0.244,6.39],[0.5428,0.2943,0.3099,0.244,6.52],[0.54,0.2944,0.3099,0.244,6.41],[0.5357,0.2968,0.3099,0.244,6.06],[0.5298,0.3009,0.3099,0.244,5.52],[0.5223,0.306,0.3099,0.244,4.83],[0.5136,0.3115,0.3099,0.244,4.03],[0.5039,0.3165,0.3099,0.244,3.16],[0.4938,0.3197,0.3099,0.244,2.25],[0.4839,0.3208,0.3099,0.244,1.35],[0.4748,0.3198,0.3099,0.244,0.48],[0.4668,0.317,0.3099,0.244,-0.31],[0.4604,0.3129,0.3099,0.244,-1.0],[0.4559,0.3086,0.3099,0.244,-1.58],[0.453,0.3042,0.3099,0.244,-1.99],[0.4519,0.2998,0.3099,0.244,-2.25],[0.4522,0.2959,0.3099,0.244,-2.37],[0.4536,0.2928,0.3099,0.244,-2.35],[0.4558,0.2906,0.3099,0.244,-2.22],[0.4586,0.2893,0.3099,0.244,-2.02],[0.4618,0.2888,0.3099,0.244,-1.77],[0.4652,0.289,0.3099,0.244,-1.52],[0.4686,0.2897,0.3099,0.244,-1.28],[0.4717,0.2906,0.3099,0.244,-1.06],[0.4742,0.2914,0.3099,0.244,-0.86],[0.476,0.2921,0.3099,0.244,-0.7],[0.4771,0.2925,0.3099,0.244,-0.56],[0.4777,0.2927,0.3099,0.244,-0.44],[0.478,0.2927,0.3099,0.244,-0.34],[0.4782,0.2928,0.3099,0.244,-0.28],[0.4783,0.2928,0.3099,0.244,-0.24],[0.4783,0.2928,0.3099,0.244,-0.23],[0.4784,0.2928,0.3099,0.244,-0.24],[0.4784,0.2928,0.3099,0.244,-0.25],[0.4784,0.2928,0.3099,0.244,-0.27],[0.4784,0.2929,0.3099,0.244,-0.29],[0.4784,0.2929,0.3099,0.244,-0.3],[0.4784,0.2929,0.3099,0.244,-0.32],[0.4784,0.2929,0.3099,0.244,-0.32],[0.4784,0.2929,0.3099,0.244,-0.32],[0.4784,0.2929,0.3099,0.244,-0.32],[0.4784,0.2929,0.3099,0.244,-0.32]],"reach":[0.1642,0.03,0.8358,0.92],"script":[[0,"idle",0,1],[0.5,"happy",0,0],[0.85,"blink",0,0],[0.89,"idle",0,0]]},"story-celebrate":{"file":"clips/story-celebrate.mp4","fps":24,"frames":124,"size":384,"face":"happy","rest":"story-celebrate-rest","mask":true,"tracks":[[0.4861,0.4855,0.1979,0.1589,-1.01],[0.4861,0.4855,0.1979,0.1589,-1.01],[0.4861,0.4855,0.1979,0.1589,-1.01],[0.4861,0.4855,0.1979,0.1589,-1.01],[0.4861,0.4855,0.1979,0.1589,-1.01],[0.4861,0.4855,0.1979,0.1589,-1.0],[0.4861,0.4855,0.1979,0.1589,-0.99],[0.4861,0.4855,0.1979,0.1589,-0.98],[0.4861,0.4855,0.1979,0.1589,-0.96],[0.4861,0.4855,0.1979,0.1589,-0.94],[0.4861,0.4855,0.1979,0.1589,-0.92],[0.4862,0.4855,0.1979,0.1589,-0.91],[0.4862,0.4855,0.1979,0.1589,-0.91],[0.4862,0.4855,0.1979,0.1589,-0.91],[0.4862,0.4855,0.1979,0.1589,-0.93],[0.4862,0.4857,0.1979,0.1589,-0.95],[0.4862,0.4863,0.1979,0.1589,-0.98],[0.4861,0.4873,0.1979,0.1589,-1.01],[0.4861,0.4889,0.1979,0.1589,-1.03],[0.486,0.491,0.1979,0.1589,-1.06],[0.486,0.4935,0.1979,0.1589,-1.08],[0.4859,0.4964,0.1979,0.1589,-1.09],[0.4859,0.4994,0.1979,0.1589,-1.1],[0.486,0.5027,0.1979,0.1589,-1.09],[0.4859,0.506,0.1979,0.1589,-1.08],[0.4858,0.5095,0.1979,0.1589,-1.07],[0.4857,0.5132,0.1979,0.1589,-1.07],[0.4857,0.5169,0.1979,0.1589,-1.06],[0.4856,0.5207,0.1979,0.1589,-1.04],[0.4855,0.5245,0.1979,0.1589,-1.0],[0.4854,0.5283,0.1979,0.1589,-0.95],[0.4854,0.532,0.1979,0.1589,-0.88],[0.4853,0.5355,0.1979,0.1589,-0.8],[0.4852,0.5389,0.1979,0.1589,-0.73],[0.4852,0.5424,0.1979,0.1589,-0.66],[0.4852,0.5458,0.1979,0.1589,-0.61],[0.4852,0.5486,0.1979,0.1589,-0.58],[0.4852,0.5515,0.1979,0.1589,-0.57],[0.4852,0.5544,0.1979,0.1589,-0.57],[0.4852,0.5569,0.1979,0.1589,-0.59],[0.4852,0.5586,0.1979,0.1589,-0.56],[0.4852,0.5587,0.1979,0.1589,-0.51],[0.4851,0.5518,0.1979,0.1589,-0.43],[0.4853,0.532,0.1979,0.1589,-0.31],[0.4854,0.5028,0.1979,0.1589,-0.16],[0.4854,0.4673,0.1979,0.1589,-0.02],[0.4857,0.428,0.1979,0.1589,0.16],[0.4861,0.3911,0.1979,0.1589,0.31],[0.4862,0.3637,0.1979,0.1589,0.45],[0.4864,0.3427,0.1979,0.1589,0.58],[0.4867,0.3262,0.1979,0.1589,0.66],[0.4867,0.3141,0.1979,0.1589,0.72],[0.4868,0.3052,0.1979,0.1589,0.76],[0.4868,0.2987,0.1979,0.1589,0.78],[0.4868,0.2942,0.1979,0.1589,0.76],[0.4867,0.2916,0.1979,0.1589,0.72],[0.4866,0.2895,0.1979,0.1589,0.66],[0.4865,0.2878,0.1979,0.1589,0.58],[0.4864,0.2865,0.1979,0.1589,0.47],[0.4861,0.2855,0.1979,0.1589,0.35],[0.4858,0.2847,0.1979,0.1589,0.22],[0.4853,0.2842,0.1979,0.1589,0.06],[0.4848,0.2839,0.1979,0.1589,-0.13],[0.4843,0.2838,0.1979,0.1589,-0.32],[0.4838,0.2839,0.1979,0.1589,-0.54],[0.4834,0.2842,0.1979,0.1589,-0.76],[0.4829,0.2847,0.1979,0.1589,-0.99],[0.4823,0.2855,0.1979,0.1589,-1.23],[0.4817,0.2866,0.1979,0.1589,-1.47],[0.4811,0.2879,0.1979,0.1589,-1.7],[0.4805,0.2896,0.1979,0.1589,-1.93],[0.48,0.2916,0.1979,0.1589,-2.15],[0.4795,0.2937,0.1979,0.1589,-2.37],[0.479,0.296,0.1979,0.1589,-2.59],[0.4784,0.2991,0.1979,0.1589,-2.8],[0.4779,0.3024,0.1979,0.1589,-3.0],[0.4773,0.3062,0.1979,0.1589,-3.19],[0.4769,0.3106,0.1979,0.1589,-3.27],[0.4764,0.3159,0.1979,0.1589,-3.25],[0.4762,0.3231,0.1979,0.1589,-3.15],[0.4761,0.3379,0.1979,0.1589,-2.96],[0.4767,0.3615,0.1979,0.1589,-2.67],[0.4775,0.3898,0.1979,0.1589,-2.38],[0.4787,0.4211,0.1979,0.1589,-2.04],[0.4801,0.4528,0.1979,0.1589,-1.73],[0.4816,0.4795,0.1979,0.1589,-1.42],[0.4827,0.5,0.1979,0.1589,-1.11],[0.4836,0.5172,0.1979,0.1589,-0.8],[0.4842,0.5313,0.1979,0.1589,-0.55],[0.4846,0.5413,0.1979,0.1589,-0.3],[0.485,0.5462,0.1979,0.1589,-0.12],[0.4851,0.5453,0.1979,0.1589,-0.01],[0.4852,0.54,0.1979,0.1589,-0.01],[0.4853,0.5318,0.1979,0.1589,-0.07],[0.4853,0.523,0.1979,0.1589,-0.19],[0.4854,0.5153,0.1979,0.1589,-0.32],[0.4855,0.5093,0.1979,0.1589,-0.43],[0.4856,0.5046,0.1979,0.1589,-0.54],[0.4857,0.5007,0.1979,0.1589,-0.62],[0.4858,0.4977,0.1979,0.1589,-0.67],[0.4859,0.495,0.1979,0.1589,-0.71],[0.486,0.4926,0.1979,0.1589,-0.75],[0.4861,0.4904,0.1979,0.1589,-0.78],[0.4861,0.4888,0.1979,0.1589,-0.8],[0.4861,0.4874,0.1979,0.1589,-0.82],[0.4861,0.4864,0.1979,0.1589,-0.82],[0.4861,0.4859,0.1979,0.1589,-0.82],[0.4861,0.4856,0.1979,0.1589,-0.82],[0.4862,0.4855,0.1979,0.1589,-0.82],[0.4862,0.4855,0.1979,0.1589,-0.82],[0.4862,0.4855,0.1979,0.1589,-0.82],[0.4862,0.4855,0.1979,0.1589,-0.83],[0.4862,0.4855,0.1979,0.1589,-0.84],[0.4862,0.4855,0.1979,0.1589,-0.84],[0.4862,0.4855,0.1979,0.1589,-0.85],[0.4862,0.4855,0.1979,0.1589,-0.86],[0.4862,0.4855,0.1979,0.1589,-0.86],[0.4862,0.4855,0.1979,0.1589,-0.87],[0.4862,0.4855,0.1979,0.1589,-0.87],[0.4862,0.4855,0.1979,0.1589,-0.88],[0.4862,0.4855,0.1979,0.1589,-0.88],[0.4862,0.4855,0.1979,0.1589,-0.89],[0.4862,0.4855,0.1979,0.1589,-0.89],[0.4862,0.4855,0.1979,0.1589,-0.89]],"reach":[0.185,0.03,0.815,0.92],"script":[[0,"happy",0,0],[0.7,"blink",0,0],[0.74,"happy",0,0]]},"story-comedy":{"file":"clips/story-comedy.mp4","fps":24,"frames":124,"size":384,"face":"happy","rest":"story-comedy-rest","mask":true,"tracks":[[0.4859,0.3027,0.3018,0.2407,-0.35],[0.4859,0.3026,0.3018,0.2407,-0.36],[0.486,0.3026,0.3018,0.2407,-0.36],[0.4859,0.3026,0.3018,0.2407,-0.37],[0.486,0.3027,0.3018,0.2407,-0.39],[0.486,0.303,0.3018,0.2407,-0.39],[0.4861,0.3077,0.3018,0.2407,-0.41],[0.4861,0.3144,0.3018,0.2407,-0.42],[0.4861,0.3237,0.3018,0.2407,-0.44],[0.4861,0.3333,0.3018,0.2407,-0.45],[0.4861,0.3429,0.3018,0.2407,-0.46],[0.4859,0.348,0.3018,0.2407,-0.47],[0.4859,0.3512,0.3018,0.2407,-0.49],[0.4859,0.3516,0.3018,0.2407,-0.51],[0.4858,0.3517,0.3018,0.2407,-0.53],[0.4858,0.3517,0.3018,0.2407,-0.54],[0.4857,0.3517,0.3018,0.2407,-0.54],[0.4857,0.3517,0.3018,0.2407,-0.55],[0.4857,0.3518,0.3018,0.2407,-0.53],[0.4858,0.3538,0.3018,0.2407,-0.44],[0.4856,0.3556,0.3018,0.2407,-0.23],[0.4857,0.343,0.3018,0.2407,0.12],[0.4878,0.3222,0.3018,0.2407,0.68],[0.4914,0.3012,0.3018,0.2407,1.39],[0.4968,0.2825,0.3018,0.2407,2.34],[0.505,0.2683,0.3018,0.2407,3.48],[0.5151,0.2711,0.3018,0.2407,4.73],[0.5255,0.2782,0.3018,0.2407,6.02],[0.5357,0.2813,0.3018,0.2407,7.28],[0.5448,0.2772,0.3018,0.2407,8.32],[0.5515,0.2704,0.3018,0.2407,9.17],[0.5554,0.2645,0.3018,0.2407,9.76],[0.5568,0.268,0.3018,0.2407,10.16],[0.5567,0.2793,0.3018,0.2407,10.39],[0.5554,0.2933,0.3018,0.2407,10.52],[0.554,0.3022,0.3018,0.2407,10.58],[0.5535,0.3035,0.3018,0.2407,10.62],[0.5534,0.2956,0.3018,0.2407,10.66],[0.5534,0.2832,0.3018,0.2407,10.71],[0.5539,0.2727,0.3018,0.2407,10.71],[0.5547,0.269,0.3018,0.2407,10.68],[0.5553,0.2722,0.3018,0.2407,10.6],[0.556,0.2817,0.3018,0.2407,10.48],[0.5565,0.2917,0.3018,0.2407,10.41],[0.5564,0.2983,0.3018,0.2407,10.38],[0.5555,0.3019,0.3018,0.2407,10.4],[0.5544,0.2997,0.3018,0.2407,10.43],[0.5537,0.2924,0.3018,0.2407,10.52],[0.5534,0.2839,0.3018,0.2407,10.58],[0.5536,0.279,0.3018,0.2407,10.68],[0.5546,0.2778,0.3018,0.2407,10.79],[0.5554,0.2795,0.3018,0.2407,10.9],[0.5557,0.2817,0.3018,0.2407,10.98],[0.5553,0.2854,0.3018,0.2407,11.07],[0.555,0.2867,0.3018,0.2407,11.14],[0.5553,0.2864,0.3018,0.2407,11.2],[0.5561,0.2864,0.3018,0.2407,11.27],[0.5569,0.287,0.3018,0.2407,11.32],[0.5576,0.2844,0.3018,0.2407,11.38],[0.558,0.2825,0.3018,0.2407,11.45],[0.5575,0.2808,0.3018,0.2407,11.53],[0.5569,0.282,0.3018,0.2407,11.6],[0.556,0.2854,0.3018,0.2407,11.6],[0.5551,0.2905,0.3018,0.2407,11.29],[0.5525,0.2961,0.3018,0.2407,10.57],[0.5468,0.3018,0.3018,0.2407,9.37],[0.5361,0.3072,0.3018,0.2407,7.64],[0.5215,0.313,0.3018,0.2407,5.42],[0.5049,0.3201,0.3018,0.2407,2.88],[0.4883,0.3253,0.3018,0.2407,0.2],[0.4734,0.3263,0.3018,0.2407,-2.33],[0.4629,0.3234,0.3018,0.2407,-4.54],[0.4563,0.3184,0.3018,0.2407,-6.27],[0.4524,0.3135,0.3018,0.2407,-7.48],[0.4508,0.3101,0.3018,0.2407,-8.16],[0.4511,0.3089,0.3018,0.2407,-8.51],[0.452,0.3095,0.3018,0.2407,-8.61],[0.453,0.3119,0.3018,0.2407,-8.59],[0.4536,0.3151,0.3018,0.2407,-8.54],[0.4536,0.318,0.3018,0.2407,-8.53],[0.453,0.3195,0.3018,0.2407,-8.57],[0.4521,0.3195,0.3018,0.2407,-8.67],[0.451,0.3178,0.3018,0.2407,-8.8],[0.45,0.3146,0.3018,0.2407,-8.93],[0.4495,0.311,0.3018,0.2407,-9.02],[0.4496,0.308,0.3018,0.2407,-9.05],[0.4502,0.3062,0.3018,0.2407,-9.02],[0.4512,0.3062,0.3018,0.2407,-8.94],[0.4524,0.3078,0.3018,0.2407,-8.82],[0.4534,0.3107,0.3018,0.2407,-8.71],[0.4542,0.3142,0.3018,0.2407,-8.62],[0.4545,0.3175,0.3018,0.2407,-8.57],[0.4546,0.3198,0.3018,0.2407,-8.57],[0.4545,0.321,0.3018,0.2407,-8.57],[0.4543,0.3211,0.3018,0.2407,-8.57],[0.4542,0.3207,0.3018,0.2407,-8.54],[0.4542,0.3201,0.3018,0.2407,-8.38],[0.4548,0.3194,0.3018,0.2407,-8.02],[0.4563,0.3186,0.3018,0.2407,-7.46],[0.4592,0.3175,0.3018,0.2407,-6.71],[0.4629,0.3159,0.3018,0.2407,-5.78],[0.4676,0.314,0.3018,0.2407,-4.75],[0.4726,0.3116,0.3018,0.2407,-3.71],[0.4772,0.309,0.3018,0.2407,-2.76],[0.4808,0.3068,0.3018,0.2407,-1.95],[0.4834,0.3049,0.3018,0.2407,-1.31],[0.485,0.3034,0.3018,0.2407,-0.86],[0.4857,0.3026,0.3018,0.2407,-0.58],[0.4859,0.3023,0.3018,0.2407,-0.41],[0.4859,0.3023,0.3018,0.2407,-0.31],[0.4859,0.3022,0.3018,0.2407,-0.24],[0.4859,0.3021,0.3018,0.2407,-0.21],[0.4859,0.3021,0.3018,0.2407,-0.19],[0.4859,0.302,0.3018,0.2407,-0.19],[0.4859,0.302,0.3018,0.2407,-0.18],[0.4859,0.302,0.3018,0.2407,-0.18],[0.4859,0.302,0.3018,0.2407,-0.18],[0.4859,0.3021,0.3018,0.2407,-0.19],[0.4859,0.3021,0.3018,0.2407,-0.19],[0.4859,0.3021,0.3018,0.2407,-0.2],[0.4859,0.3022,0.3018,0.2407,-0.21],[0.486,0.3022,0.3018,0.2407,-0.22],[0.4859,0.3022,0.3018,0.2407,-0.22],[0.4859,0.3022,0.3018,0.2407,-0.22]],"reach":[0.1647,0.03,0.8353,0.92],"script":[[0,"happy",0,0],[0.85,"blink",0,0],[0.89,"happy",0,0]]},"story-daytime":{"file":"clips/story-daytime.mp4","fps":24,"frames":124,"size":384,"face":"happy","rest":"story-daytime-rest","mask":true,"tracks":[[0.5051,0.3264,0.2881,0.2289,-0.83],[0.505,0.3264,0.2881,0.2289,-0.81],[0.505,0.3265,0.2881,0.2289,-0.75],[0.5048,0.3265,0.2881,0.2289,-0.55],[0.504,0.3268,0.2881,0.2289,-0.23],[0.5023,0.3273,0.2881,0.2289,0.22],[0.5003,0.3278,0.2881,0.2289,0.81],[0.497,0.3286,0.2881,0.2289,1.5],[0.4933,0.3295,0.2881,0.2289,2.2],[0.4902,0.3303,0.2881,0.2289,2.9],[0.4871,0.3308,0.2881,0.2289,3.53],[0.4836,0.331,0.2881,0.2289,4.04],[0.4812,0.3305,0.2881,0.2289,4.42],[0.4787,0.3292,0.2881,0.2289,4.67],[0.4759,0.3272,0.2881,0.2289,4.79],[0.4741,0.3249,0.2881,0.2289,4.81],[0.4729,0.3222,0.2881,0.2289,4.77],[0.472,0.3198,0.2881,0.2289,4.63],[0.4714,0.3172,0.2881,0.2289,4.45],[0.4711,0.3152,0.2881,0.2289,4.2],[0.4709,0.3136,0.2881,0.2289,3.92],[0.4707,0.3121,0.2881,0.2289,3.63],[0.4705,0.3107,0.2881,0.2289,3.35],[0.4705,0.31,0.2881,0.2289,3.08],[0.4705,0.3092,0.2881,0.2289,2.83],[0.4706,0.3083,0.2881,0.2289,2.63],[0.4706,0.3077,0.2881,0.2289,2.46],[0.4707,0.3073,0.2881,0.2289,2.34],[0.4708,0.307,0.2881,0.2289,2.25],[0.4709,0.3067,0.2881,0.2289,2.18],[0.4709,0.3065,0.2881,0.2289,2.14],[0.4709,0.3065,0.2881,0.2289,2.11],[0.4709,0.3063,0.2881,0.2289,2.08],[0.4709,0.3063,0.2881,0.2289,2.04],[0.4709,0.3062,0.2881,0.2289,2.0],[0.4708,0.3062,0.2881,0.2289,1.97],[0.4708,0.3061,0.2881,0.2289,1.96],[0.4708,0.3062,0.2881,0.2289,1.95],[0.4709,0.3062,0.2881,0.2289,1.97],[0.4709,0.3063,0.2881,0.2289,2.0],[0.471,0.3063,0.2881,0.2289,2.04],[0.471,0.3064,0.2881,0.2289,2.08],[0.471,0.3065,0.2881,0.2289,2.12],[0.471,0.3066,0.2881,0.2289,2.17],[0.471,0.3067,0.2881,0.2289,2.23],[0.471,0.3069,0.2881,0.2289,2.31],[0.471,0.3071,0.2881,0.2289,2.44],[0.471,0.3075,0.2881,0.2289,2.64],[0.471,0.308,0.2881,0.2289,2.9],[0.4713,0.3088,0.2881,0.2289,3.16],[0.4719,0.3105,0.2881,0.2289,3.42],[0.4728,0.3126,0.2881,0.2289,3.61],[0.474,0.315,0.2881,0.2289,3.66],[0.4755,0.3176,0.2881,0.2289,3.51],[0.4774,0.3203,0.2881,0.2289,3.14],[0.4794,0.3222,0.2881,0.2289,2.61],[0.4817,0.3239,0.2881,0.2289,1.98],[0.4848,0.3252,0.2881,0.2289,1.29],[0.489,0.326,0.2881,0.2289,0.64],[0.4933,0.3265,0.2881,0.2289,0.1],[0.4973,0.3267,0.2881,0.2289,-0.3],[0.5008,0.3267,0.2881,0.2289,-0.57],[0.5034,0.3266,0.2881,0.2289,-0.73],[0.5046,0.3265,0.2881,0.2289,-0.8],[0.505,0.3264,0.2881,0.2289,-0.82],[0.5051,0.3263,0.2881,0.2289,-0.86],[0.5052,0.3262,0.2881,0.2289,-0.96],[0.5042,0.3267,0.2881,0.2289,-1.17],[0.5014,0.3278,0.2881,0.2289,-1.52],[0.4942,0.3299,0.2881,0.2289,-2.05],[0.4824,0.332,0.2881,0.2289,-2.79],[0.4677,0.3332,0.2881,0.2289,-3.72],[0.4524,0.3336,0.2881,0.2289,-4.82],[0.4365,0.333,0.2881,0.2289,-6.02],[0.4225,0.3312,0.2881,0.2289,-7.25],[0.4122,0.3295,0.2881,0.2289,-8.41],[0.4043,0.3285,0.2881,0.2289,-9.45],[0.398,0.3279,0.2881,0.2289,-10.3],[0.3945,0.3276,0.2881,0.2289,-10.91],[0.3946,0.3276,0.2881,0.2289,-11.29],[0.3973,0.3277,0.2881,0.2289,-11.38],[0.403,0.3279,0.2881,0.2289,-11.15],[0.4125,0.3286,0.2881,0.2289,-10.58],[0.4249,0.3299,0.2881,0.2289,-9.6],[0.4437,0.3315,0.2881,0.2289,-8.23],[0.4653,0.3328,0.2881,0.2289,-6.47],[0.4914,0.3335,0.2881,0.2289,-4.38],[0.5172,0.3333,0.2881,0.2289,-2.07],[0.5439,0.3323,0.2881,0.2289,0.29],[0.5652,0.331,0.2881,0.2289,2.55],[0.5834,0.3299,0.2881,0.2289,4.54],[0.5954,0.3294,0.2881,0.2289,6.19],[0.6037,0.3292,0.2881,0.2289,7.4],[0.6077,0.3294,0.2881,0.2289,8.22],[0.6091,0.3297,0.2881,0.2289,8.67],[0.6079,0.33,0.2881,0.2289,8.82],[0.6047,0.3304,0.2881,0.2289,8.67],[0.5998,0.3309,0.2881,0.2289,8.3],[0.5919,0.3318,0.2881,0.2289,7.65],[0.582,0.3328,0.2881,0.2289,6.79],[0.5698,0.3333,0.2881,0.2289,5.76],[0.5545,0.3327,0.2881,0.2289,4.6],[0.5393,0.3316,0.2881,0.2289,3.4],[0.5268,0.3299,0.2881,0.2289,2.3],[0.5166,0.3281,0.2881,0.2289,1.32],[0.5094,0.3266,0.2881,0.2289,0.58],[0.5069,0.3262,0.2881,0.2289,0.06],[0.5058,0.3261,0.2881,0.2289,-0.28],[0.5053,0.3262,0.2881,0.2289,-0.46],[0.5051,0.3262,0.2881,0.2289,-0.52],[0.5051,0.3261,0.2881,0.2289,-0.55],[0.5051,0.326,0.2881,0.2289,-0.57],[0.5051,0.326,0.2881,0.2289,-0.58],[0.5051,0.326,0.2881,0.2289,-0.59],[0.5051,0.326,0.2881,0.2289,-0.6],[0.5051,0.3261,0.2881,0.2289,-0.62],[0.5051,0.3262,0.2881,0.2289,-0.63],[0.5051,0.3262,0.2881,0.2289,-0.64],[0.5051,0.3262,0.2881,0.2289,-0.65],[0.5051,0.3262,0.2881,0.2289,-0.66],[0.5051,0.3262,0.2881,0.2289,-0.66],[0.5051,0.3262,0.2881,0.2289,-0.67],[0.5051,0.3261,0.2881,0.2289,-0.67],[0.5051,0.3261,0.2881,0.2289,-0.67]],"reach":[0.1211,0.03,0.8789,0.92],"script":[[0,"happy",0,0],[0.45,"blink",0,0],[0.5,"idle",1,0],[0.75,"happy",0,0]]},"story-detective":{"file":"clips/story-detective.mp4","fps":24,"frames":124,"size":384,"face":"idle","rest":"story-detective-rest","mask":true,"tracks":[[0.4842,0.3239,0.2709,0.2191,-0.26],[0.4842,0.3238,0.2709,0.2191,-0.26],[0.4834,0.3238,0.2709,0.2191,-0.27],[0.4812,0.3238,0.2709,0.2191,-0.28],[0.4788,0.3237,0.2709,0.2191,-0.28],[0.4765,0.3237,0.2709,0.2191,-0.27],[0.4731,0.3236,0.2709,0.2191,-0.25],[0.4698,0.3235,0.2709,0.2191,-0.22],[0.4677,0.3235,0.2709,0.2191,-0.2],[0.4649,0.3235,0.2709,0.2191,-0.2],[0.4621,0.3235,0.2709,0.2191,-0.26],[0.4588,0.3237,0.2709,0.2191,-0.37],[0.4559,0.324,0.2709,0.2191,-0.54],[0.4527,0.3244,0.2709,0.2191,-0.75],[0.4503,0.3249,0.2709,0.2191,-1.0],[0.4478,0.3254,0.2709,0.2191,-1.24],[0.4478,0.3266,0.2709,0.2191,-1.47],[0.4479,0.3277,0.2709,0.2191,-1.65],[0.4503,0.3295,0.2709,0.2191,-1.76],[0.4527,0.3313,0.2709,0.2191,-1.75],[0.4575,0.3337,0.2709,0.2191,-1.65],[0.4614,0.3352,0.2709,0.2191,-1.45],[0.47,0.337,0.2709,0.2191,-1.23],[0.477,0.338,0.2709,0.2191,-0.62],[0.4883,0.3391,0.2709,0.2191,0.32],[0.4972,0.3395,0.2709,0.2191,1.58],[0.5101,0.3393,0.2709,0.2191,3.11],[0.5182,0.3388,0.2709,0.2191,4.83],[0.5283,0.3376,0.2709,0.2191,6.37],[0.5347,0.3361,0.2709,0.2191,7.77],[0.5362,0.3344,0.2709,0.2191,8.79],[0.5338,0.3332,0.2709,0.2191,9.46],[0.5316,0.3319,0.2709,0.2191,9.91],[0.5308,0.3314,0.2709,0.2191,10.15],[0.5296,0.331,0.2709,0.2191,10.25],[0.5364,0.331,0.2709,0.2191,10.48],[0.543,0.3311,0.2709,0.2191,10.84],[0.5526,0.3316,0.2709,0.2191,11.2],[0.5586,0.3322,0.2709,0.2191,11.58],[0.5674,0.3331,0.2709,0.2191,11.84],[0.5732,0.334,0.2709,0.2191,11.98],[0.5808,0.3352,0.2709,0.2191,11.99],[0.5854,0.336,0.2709,0.2191,12.0],[0.5907,0.3369,0.2709,0.2191,12.0],[0.5933,0.3375,0.2709,0.2191,12.0],[0.5962,0.3381,0.2709,0.2191,12.0],[0.5975,0.3383,0.2709,0.2191,12.0],[0.599,0.3386,0.2709,0.2191,12.0],[0.5998,0.3388,0.2709,0.2191,12.0],[0.6008,0.3389,0.2709,0.2191,12.0],[0.6015,0.3391,0.2709,0.2191,12.0],[0.6021,0.3393,0.2709,0.2191,12.0],[0.6028,0.3394,0.2709,0.2191,12.0],[0.6034,0.3396,0.2709,0.2191,12.0],[0.6041,0.3397,0.2709,0.2191,12.0],[0.6047,0.3399,0.2709,0.2191,12.0],[0.6056,0.34,0.2709,0.2191,12.0],[0.6063,0.3402,0.2709,0.2191,12.0],[0.6073,0.3403,0.2709,0.2191,12.0],[0.608,0.3405,0.2709,0.2191,12.0],[0.6093,0.3409,0.2709,0.2191,12.0],[0.6102,0.3412,0.2709,0.2191,12.0],[0.6117,0.3419,0.2709,0.2191,12.0],[0.6129,0.3425,0.2709,0.2191,12.0],[0.6149,0.3434,0.2709,0.2191,12.0],[0.6162,0.344,0.2709,0.2191,12.0],[0.6179,0.3448,0.2709,0.2191,12.0],[0.619,0.3454,0.2709,0.2191,12.0],[0.6202,0.3459,0.2709,0.2191,12.0],[0.6208,0.3462,0.2709,0.2191,12.0],[0.6213,0.3465,0.2709,0.2191,12.0],[0.6216,0.3467,0.2709,0.2191,12.0],[0.6218,0.3469,0.2709,0.2191,12.0],[0.622,0.3471,0.2709,0.2191,12.0],[0.6218,0.3473,0.2709,0.2191,12.0],[0.621,0.3472,0.2709,0.2191,12.0],[0.6181,0.3464,0.2709,0.2191,12.0],[0.6152,0.3455,0.2709,0.2191,12.0],[0.6101,0.3437,0.2709,0.2191,12.0],[0.6034,0.3415,0.2709,0.2191,12.0],[0.6008,0.3402,0.2709,0.2191,12.0],[0.6003,0.3395,0.2709,0.2191,11.6],[0.595,0.3396,0.2709,0.2191,11.01],[0.592,0.3405,0.2709,0.2191,10.22],[0.5837,0.3425,0.2709,0.2191,9.21],[0.5725,0.3437,0.2709,0.2191,7.94],[0.5572,0.3447,0.2709,0.2191,6.78],[0.5442,0.3451,0.2709,0.2191,5.5],[0.5297,0.345,0.2709,0.2191,4.46],[0.5223,0.3443,0.2709,0.2191,3.5],[0.5116,0.3422,0.2709,0.2191,2.64],[0.5051,0.3404,0.2709,0.2191,1.93],[0.499,0.337,0.2709,0.2191,1.46],[0.4945,0.334,0.2709,0.2191,0.86],[0.4896,0.3302,0.2709,0.2191,0.39],[0.4874,0.3278,0.2709,0.2191,0.06],[0.4851,0.3255,0.2709,0.2191,-0.12],[0.4847,0.3246,0.2709,0.2191,-0.23],[0.4843,0.3237,0.2709,0.2191,-0.26],[0.4843,0.3237,0.2709,0.2191,-0.27],[0.4843,0.3237,0.2709,0.2191,-0.26],[0.4843,0.3237,0.2709,0.2191,-0.26],[0.4842,0.3237,0.2709,0.2191,-0.26],[0.4842,0.3237,0.2709,0.2191,-0.25],[0.4842,0.3237,0.2709,0.2191,-0.25],[0.4842,0.3237,0.2709,0.2191,-0.23],[0.4842,0.3237,0.2709,0.2191,-0.21],[0.4842,0.3237,0.2709,0.2191,-0.19],[0.4842,0.3237,0.2709,0.2191,-0.17],[0.4842,0.3237,0.2709,0.2191,-0.15],[0.4842,0.3237,0.2709,0.2191,-0.13],[0.4842,0.3237,0.2709,0.2191,-0.11],[0.4842,0.3237,0.2709,0.2191,-0.11],[0.4842,0.3237,0.2709,0.2191,-0.1],[0.4842,0.3237,0.2709,0.2191,-0.09],[0.4842,0.3237,0.2709,0.2191,-0.08],[0.4842,0.3238,0.2709,0.2191,-0.07],[0.4842,0.3238,0.2709,0.2191,-0.06],[0.4842,0.3238,0.2709,0.2191,-0.06],[0.4842,0.3238,0.2709,0.2191,-0.05],[0.4842,0.3238,0.2709,0.2191,-0.05],[0.4842,0.3238,0.2709,0.2191,-0.05],[0.4842,0.3238,0.2709,0.2191,-0.05],[0.4842,0.3238,0.2709,0.2191,-0.05]],"reach":[0.0995,0.03,0.9005,0.92],"script":[[0,"idle",-1,0],[0.8,"blink",-1,0],[0.84,"idle",-1,0]],"lens":[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,[0.3399,0.3458,0.0567,0.7,0],[0.3582,0.3438,0.0567,0.85,0],[0.3825,0.3413,0.0569,1.0,0],[0.3973,0.3399,0.0571,1.0,0],[0.4163,0.3382,0.0572,1.0,0],[0.4272,0.3373,0.0574,1.0,0],[0.4381,0.3363,0.0577,1.0,0],[0.4499,0.3362,0.0578,1.0,0],[0.4611,0.3363,0.058,1.0,0],[0.4734,0.3374,0.058,1.0,0],[0.4858,0.3384,0.0581,1.0,0],[0.5031,0.3398,0.0579,1.0,0],[0.5136,0.341,0.0577,1.0,0],[0.5281,0.3429,0.0575,1.0,0],[0.5373,0.3442,0.0574,1.0,0],[0.5491,0.3459,0.0573,1.0,0],[0.5559,0.3471,0.0573,1.0,0],[0.5636,0.3485,0.0574,1.0,0],[0.5672,0.349,0.0574,1.0,0],[0.5711,0.3497,0.0574,1.0,0],[0.5727,0.3499,0.0574,1.0,0],[0.5745,0.3502,0.0574,1.0,0],[0.5754,0.3503,0.0574,1.0,0],[0.5764,0.3505,0.0574,1.0,0],[0.5772,0.3505,0.0574,1.0,0],[0.5779,0.3506,0.0574,1.0,0],[0.5788,0.3507,0.0574,1.0,0],[0.5796,0.3508,0.0574,1.0,0],[0.5803,0.3509,0.0574,1.0,1],[0.581,0.351,0.0574,1.0,1],[0.5819,0.3511,0.0574,1.0,1],[0.5826,0.3513,0.0574,1.0,0],[0.5835,0.3515,0.0574,1.0,0],[0.5843,0.3518,0.0574,1.0,0],[0.5856,0.3523,0.0574,1.0,0],[0.5866,0.3527,0.0574,1.0,0],[0.5882,0.3535,0.0573,1.0,0],[0.5896,0.3542,0.0573,1.0,0],[0.5917,0.3552,0.0573,1.0,0],[0.5931,0.356,0.0573,1.0,0],[0.5949,0.357,0.0573,1.0,0],[0.596,0.3576,0.0573,1.0,0],[0.5972,0.3582,0.0573,1.0,0],[0.5979,0.3587,0.0574,1.0,0],[0.5985,0.3591,0.0574,1.0,0],[0.5988,0.3594,0.0574,1.0,0],[0.599,0.3598,0.0573,1.0,0],[0.5991,0.3601,0.0573,1.0,0],[0.5988,0.3602,0.0574,1.0,0],[0.5975,0.36,0.0573,1.0,0],[0.5927,0.358,0.0572,1.0,0],[0.5879,0.3559,0.0572,1.0,0],[0.5783,0.3521,0.0571,1.0,0],[0.5646,0.3468,0.0571,1.0,0],[0.5456,0.3403,0.0569,1.0,0],[0.5301,0.3355,0.0567,1.0,0],[0.5014,0.3292,0.0567,1.0,0],[0.4775,0.3245,0.0566,1.0,0],[0.4441,0.322,0.0566,0.85,0],[0.4171,0.321,0.0567,0.7,0],null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null],"blinks":[21,22,90,91]},"story-fiction":{"file":"clips/story-fiction.mp4","fps":24,"frames":124,"size":384,"face":"idle","rest":"story-fiction-rest","mask":true,"tracks":[[0.5027,0.3497,0.2922,0.2282,-0.39],[0.5027,0.3498,0.2922,0.2282,-0.4],[0.5028,0.3499,0.2922,0.2282,-0.4],[0.5028,0.3501,0.2922,0.2282,-0.41],[0.5028,0.3505,0.2922,0.2282,-0.39],[0.5029,0.3512,0.2922,0.2282,-0.38],[0.503,0.3525,0.2922,0.2282,-0.34],[0.5032,0.3546,0.2922,0.2282,-0.29],[0.5036,0.358,0.2922,0.2282,-0.22],[0.5042,0.3627,0.2922,0.2282,-0.15],[0.505,0.3686,0.2922,0.2282,-0.06],[0.506,0.3749,0.2922,0.2282,0.03],[0.5074,0.3815,0.2922,0.2282,0.13],[0.5092,0.3877,0.2922,0.2282,0.22],[0.5112,0.3931,0.2922,0.2282,0.31],[0.5135,0.3974,0.2922,0.2282,0.4],[0.5159,0.4011,0.2922,0.2282,0.5],[0.5182,0.4039,0.2922,0.2282,0.58],[0.5201,0.4058,0.2922,0.2282,0.67],[0.522,0.4073,0.2922,0.2282,0.75],[0.5235,0.4083,0.2922,0.2282,0.82],[0.5248,0.409,0.2922,0.2282,0.89],[0.526,0.4094,0.2922,0.2282,0.95],[0.527,0.4095,0.2922,0.2282,1.01],[0.528,0.4095,0.2922,0.2282,1.07],[0.5291,0.4093,0.2922,0.2282,1.12],[0.5304,0.409,0.2922,0.2282,1.16],[0.5317,0.4086,0.2922,0.2282,1.18],[0.533,0.4081,0.2922,0.2282,1.18],[0.5341,0.4074,0.2922,0.2282,1.16],[0.5347,0.4065,0.2922,0.2282,1.11],[0.5347,0.4056,0.2922,0.2282,1.03],[0.534,0.4046,0.2922,0.2282,0.9],[0.5325,0.4034,0.2922,0.2282,0.72],[0.5303,0.402,0.2922,0.2282,0.45],[0.5273,0.4004,0.2922,0.2282,0.1],[0.5235,0.3984,0.2922,0.2282,-0.39],[0.519,0.3962,0.2922,0.2282,-1.03],[0.5139,0.3938,0.2922,0.2282,-1.78],[0.5074,0.391,0.2922,0.2282,-2.64],[0.5007,0.389,0.2922,0.2282,-3.56],[0.4942,0.3878,0.2922,0.2282,-4.45],[0.4885,0.3876,0.2922,0.2282,-5.29],[0.4836,0.3884,0.2922,0.2282,-6.0],[0.4807,0.3902,0.2922,0.2282,-6.59],[0.4787,0.3923,0.2922,0.2282,-7.05],[0.4775,0.3943,0.2922,0.2282,-7.38],[0.4767,0.3959,0.2922,0.2282,-7.61],[0.4762,0.3971,0.2922,0.2282,-7.79],[0.4759,0.3979,0.2922,0.2282,-7.93],[0.4759,0.3984,0.2922,0.2282,-8.03],[0.4758,0.3988,0.2922,0.2282,-8.11],[0.4758,0.399,0.2922,0.2282,-8.17],[0.4758,0.3992,0.2922,0.2282,-8.21],[0.4757,0.3994,0.2922,0.2282,-8.22],[0.4756,0.3995,0.2922,0.2282,-8.2],[0.4755,0.3996,0.2922,0.2282,-8.13],[0.4752,0.3997,0.2922,0.2282,-8.0],[0.4745,0.3999,0.2922,0.2282,-7.81],[0.4734,0.4002,0.2922,0.2282,-7.54],[0.4722,0.4004,0.2922,0.2282,-7.24],[0.4709,0.4008,0.2922,0.2282,-6.91],[0.4695,0.4011,0.2922,0.2282,-6.59],[0.4686,0.4014,0.2922,0.2282,-6.28],[0.4681,0.4017,0.2922,0.2282,-6.0],[0.4678,0.4019,0.2922,0.2282,-5.78],[0.4678,0.4021,0.2922,0.2282,-5.61],[0.4682,0.4023,0.2922,0.2282,-5.45],[0.4689,0.4024,0.2922,0.2282,-5.31],[0.4699,0.4024,0.2922,0.2282,-5.19],[0.4714,0.4022,0.2922,0.2282,-5.05],[0.4732,0.402,0.2922,0.2282,-4.9],[0.4754,0.4016,0.2922,0.2282,-4.71],[0.478,0.4011,0.2922,0.2282,-4.49],[0.4812,0.4004,0.2922,0.2282,-4.25],[0.4844,0.3994,0.2922,0.2282,-3.98],[0.4875,0.3983,0.2922,0.2282,-3.68],[0.4905,0.3969,0.2922,0.2282,-3.39],[0.4931,0.3954,0.2922,0.2282,-3.09],[0.4947,0.3938,0.2922,0.2282,-2.81],[0.4961,0.392,0.2922,0.2282,-2.55],[0.4973,0.3903,0.2922,0.2282,-2.31],[0.4982,0.3882,0.2922,0.2282,-2.09],[0.4988,0.3863,0.2922,0.2282,-1.88],[0.4995,0.3844,0.2922,0.2282,-1.69],[0.4998,0.3825,0.2922,0.2282,-1.51],[0.5001,0.3801,0.2922,0.2282,-1.36],[0.5003,0.3779,0.2922,0.2282,-1.23],[0.5007,0.3756,0.2922,0.2282,-1.11],[0.501,0.3729,0.2922,0.2282,-1.02],[0.5014,0.3701,0.2922,0.2282,-0.94],[0.5017,0.3677,0.2922,0.2282,-0.87],[0.5019,0.3652,0.2922,0.2282,-0.81],[0.502,0.3627,0.2922,0.2282,-0.77],[0.5021,0.3605,0.2922,0.2282,-0.72],[0.5023,0.3581,0.2922,0.2282,-0.68],[0.5024,0.3559,0.2922,0.2282,-0.63],[0.5025,0.3541,0.2922,0.2282,-0.59],[0.5025,0.3527,0.2922,0.2282,-0.55],[0.5026,0.3516,0.2922,0.2282,-0.52],[0.5026,0.3511,0.2922,0.2282,-0.48],[0.5026,0.3508,0.2922,0.2282,-0.46],[0.5026,0.3506,0.2922,0.2282,-0.44],[0.5026,0.3505,0.2922,0.2282,-0.43],[0.5026,0.3504,0.2922,0.2282,-0.44],[0.5026,0.3504,0.2922,0.2282,-0.45],[0.5026,0.3504,0.2922,0.2282,-0.46],[0.5027,0.3503,0.2922,0.2282,-0.48],[0.5027,0.3503,0.2922,0.2282,-0.48],[0.5026,0.3503,0.2922,0.2282,-0.48],[0.5026,0.3503,0.2922,0.2282,-0.48],[0.5026,0.3503,0.2922,0.2282,-0.47],[0.5026,0.3503,0.2922,0.2282,-0.46],[0.5026,0.3503,0.2922,0.2282,-0.45],[0.5026,0.3503,0.2922,0.2282,-0.44],[0.5026,0.3503,0.2922,0.2282,-0.43],[0.5026,0.3503,0.2922,0.2282,-0.43],[0.5026,0.3503,0.2922,0.2282,-0.43],[0.5026,0.3503,0.2922,0.2282,-0.42],[0.5026,0.3503,0.2922,0.2282,-0.42],[0.5026,0.3503,0.2922,0.2282,-0.42],[0.5026,0.3503,0.2922,0.2282,-0.42],[0.5026,0.3503,0.2922,0.2282,-0.43],[0.5026,0.3503,0.2922,0.2282,-0.43]],"reach":[0.1998,0.03,0.8002,0.92],"script":[[0,"idle",0,1],[0.55,"happy",0,0],[0.85,"blink",0,0],[0.89,"idle",0,0]]},"story-health":{"file":"clips/story-health.mp4","fps":24,"frames":124,"size":384,"face":"happy","rest":"story-health-rest","mask":true,"tracks":[[0.5022,0.3764,0.2677,0.2093,-0.33],[0.5022,0.3764,0.2677,0.2093,-0.34],[0.5022,0.3764,0.2677,0.2093,-0.34],[0.5022,0.3764,0.2677,0.2093,-0.37],[0.5022,0.3764,0.2677,0.2093,-0.43],[0.502,0.3764,0.2677,0.2093,-0.56],[0.5012,0.3764,0.2677,0.2093,-0.84],[0.4989,0.3764,0.2677,0.2093,-1.29],[0.4948,0.3763,0.2677,0.2093,-1.96],[0.4886,0.3764,0.2677,0.2093,-2.87],[0.4802,0.3767,0.2677,0.2093,-4.03],[0.4697,0.3773,0.2677,0.2093,-5.34],[0.4582,0.3782,0.2677,0.2093,-6.73],[0.4463,0.3796,0.2677,0.2093,-8.08],[0.4343,0.3814,0.2677,0.2093,-9.3],[0.4231,0.3834,0.2677,0.2093,-10.29],[0.4129,0.3858,0.2677,0.2093,-11.04],[0.4036,0.3881,0.2677,0.2093,-11.53],[0.3951,0.3906,0.2677,0.2093,-11.82],[0.3875,0.393,0.2677,0.2093,-11.96],[0.3806,0.3952,0.2677,0.2093,-12.0],[0.3748,0.3971,0.2677,0.2093,-12.0],[0.3698,0.3989,0.2677,0.2093,-12.0],[0.3656,0.4004,0.2677,0.2093,-12.0],[0.3625,0.4016,0.2677,0.2093,-12.0],[0.3598,0.4027,0.2677,0.2093,-12.0],[0.3576,0.4035,0.2677,0.2093,-12.0],[0.356,0.4041,0.2677,0.2093,-12.0],[0.3549,0.4045,0.2677,0.2093,-12.0],[0.3542,0.4048,0.2677,0.2093,-12.0],[0.3541,0.4049,0.2677,0.2093,-12.0],[0.3545,0.4048,0.2677,0.2093,-12.0],[0.3556,0.4044,0.2677,0.2093,-12.0],[0.3573,0.4038,0.2677,0.2093,-12.0],[0.3598,0.4029,0.2677,0.2093,-12.0],[0.3634,0.4016,0.2677,0.2093,-12.0],[0.368,0.3999,0.2677,0.2093,-12.0],[0.3737,0.398,0.2677,0.2093,-12.0],[0.3812,0.3955,0.2677,0.2093,-11.93],[0.3921,0.3924,0.2677,0.2093,-11.7],[0.4044,0.3892,0.2677,0.2093,-11.26],[0.4184,0.386,0.2677,0.2093,-10.54],[0.4333,0.383,0.2677,0.2093,-9.45],[0.4488,0.3803,0.2677,0.2093,-8.01],[0.4629,0.3786,0.2677,0.2093,-6.22],[0.4773,0.3773,0.2677,0.2093,-4.17],[0.4915,0.3765,0.2677,0.2093,-1.91],[0.506,0.3761,0.2677,0.2093,0.44],[0.5203,0.3763,0.2677,0.2093,2.77],[0.5344,0.377,0.2677,0.2093,4.93],[0.5503,0.3786,0.2677,0.2093,6.85],[0.5656,0.3807,0.2677,0.2093,8.49],[0.5801,0.3832,0.2677,0.2093,9.8],[0.5937,0.386,0.2677,0.2093,10.75],[0.6056,0.3888,0.2677,0.2093,11.38],[0.6138,0.391,0.2677,0.2093,11.76],[0.6206,0.393,0.2677,0.2093,11.94],[0.6256,0.3947,0.2677,0.2093,12.0],[0.6293,0.3959,0.2677,0.2093,12.0],[0.6324,0.397,0.2677,0.2093,12.0],[0.635,0.3979,0.2677,0.2093,12.0],[0.6371,0.3988,0.2677,0.2093,12.0],[0.6389,0.3995,0.2677,0.2093,12.0],[0.6405,0.4001,0.2677,0.2093,12.0],[0.6417,0.4006,0.2677,0.2093,12.0],[0.6425,0.4009,0.2677,0.2093,12.0],[0.643,0.4011,0.2677,0.2093,12.0],[0.6432,0.4012,0.2677,0.2093,12.0],[0.643,0.4012,0.2677,0.2093,12.0],[0.6421,0.4009,0.2677,0.2093,12.0],[0.6404,0.4003,0.2677,0.2093,12.0],[0.637,0.3991,0.2677,0.2093,12.0],[0.6315,0.3973,0.2677,0.2093,12.0],[0.6237,0.395,0.2677,0.2093,11.97],[0.6138,0.3921,0.2677,0.2093,11.85],[0.6019,0.389,0.2677,0.2093,11.62],[0.5894,0.386,0.2677,0.2093,11.19],[0.5766,0.3833,0.2677,0.2093,10.5],[0.5637,0.381,0.2677,0.2093,9.56],[0.5511,0.3792,0.2677,0.2093,8.34],[0.5393,0.3778,0.2677,0.2093,6.92],[0.5284,0.3768,0.2677,0.2093,5.45],[0.5187,0.3764,0.2677,0.2093,4.02],[0.5114,0.3762,0.2677,0.2093,2.77],[0.5065,0.3751,0.2677,0.2093,1.8],[0.5035,0.3733,0.2677,0.2093,1.13],[0.502,0.3705,0.2677,0.2093,0.76],[0.502,0.3671,0.2677,0.2093,0.62],[0.5019,0.3634,0.2677,0.2093,0.63],[0.5019,0.3604,0.2677,0.2093,0.72],[0.5019,0.3582,0.2677,0.2093,0.83],[0.5019,0.357,0.2677,0.2093,0.9],[0.5019,0.3564,0.2677,0.2093,0.92],[0.5019,0.3564,0.2677,0.2093,0.92],[0.502,0.3569,0.2677,0.2093,0.89],[0.502,0.3576,0.2677,0.2093,0.85],[0.502,0.3585,0.2677,0.2093,0.78],[0.5021,0.3596,0.2677,0.2093,0.72],[0.5021,0.3607,0.2677,0.2093,0.65],[0.5021,0.3619,0.2677,0.2093,0.58],[0.5021,0.3632,0.2677,0.2093,0.51],[0.5021,0.3647,0.2677,0.2093,0.46],[0.5021,0.366,0.2677,0.2093,0.41],[0.5021,0.3673,0.2677,0.2093,0.36],[0.5022,0.3684,0.2677,0.2093,0.3],[0.5022,0.3695,0.2677,0.2093,0.25],[0.5022,0.3707,0.2677,0.2093,0.19],[0.5022,0.3718,0.2677,0.2093,0.12],[0.5022,0.3726,0.2677,0.2093,0.04],[0.5023,0.3736,0.2677,0.2093,-0.03],[0.5023,0.3744,0.2677,0.2093,-0.1],[0.5023,0.3749,0.2677,0.2093,-0.16],[0.5023,0.3754,0.2677,0.2093,-0.22],[0.5023,0.3757,0.2677,0.2093,-0.26],[0.5023,0.376,0.2677,0.2093,-0.3],[0.5023,0.3761,0.2677,0.2093,-0.32],[0.5022,0.3762,0.2677,0.2093,-0.33],[0.5023,0.3762,0.2677,0.2093,-0.34],[0.5022,0.3763,0.2677,0.2093,-0.33],[0.5023,0.3763,0.2677,0.2093,-0.32],[0.5023,0.3763,0.2677,0.2093,-0.32],[0.5023,0.3763,0.2677,0.2093,-0.31],[0.5022,0.3763,0.2677,0.2093,-0.3],[0.5022,0.3763,0.2677,0.2093,-0.3]],"reach":[0.03,0.157,0.97,0.92],"script":[[0,"happy",0,0],[0.3,"blink",0,0],[0.6,"happy",0,0]]},"story-history":{"file":"clips/story-history.mp4","fps":24,"frames":124,"size":384,"face":"idle","rest":"story-history-rest","mask":true,"tracks":[[0.4989,0.3094,0.3009,0.2367,-0.51],[0.4989,0.3094,0.3009,0.2367,-0.52],[0.4989,0.3094,0.3009,0.2367,-0.53],[0.4989,0.3094,0.3009,0.2367,-0.54],[0.4989,0.3094,0.3009,0.2367,-0.55],[0.4989,0.3094,0.3009,0.2367,-0.56],[0.4988,0.3095,0.3009,0.2367,-0.56],[0.4988,0.3095,0.3009,0.2367,-0.56],[0.4988,0.3096,0.3009,0.2367,-0.55],[0.4989,0.3097,0.3009,0.2367,-0.52],[0.4989,0.3098,0.3009,0.2367,-0.48],[0.4989,0.3101,0.3009,0.2367,-0.45],[0.4988,0.3105,0.3009,0.2367,-0.42],[0.4988,0.3111,0.3009,0.2367,-0.38],[0.4987,0.3119,0.3009,0.2367,-0.36],[0.4987,0.3131,0.3009,0.2367,-0.34],[0.4986,0.3145,0.3009,0.2367,-0.32],[0.4986,0.3158,0.3009,0.2367,-0.32],[0.4986,0.3176,0.3009,0.2367,-0.31],[0.4986,0.3193,0.3009,0.2367,-0.32],[0.4986,0.3213,0.3009,0.2367,-0.33],[0.4986,0.3231,0.3009,0.2367,-0.35],[0.4985,0.3253,0.3009,0.2367,-0.38],[0.4985,0.3273,0.3009,0.2367,-0.42],[0.4985,0.3295,0.3009,0.2367,-0.47],[0.4985,0.3315,0.3009,0.2367,-0.54],[0.4985,0.3334,0.3009,0.2367,-0.59],[0.4984,0.3352,0.3009,0.2367,-0.64],[0.4984,0.3367,0.3009,0.2367,-0.67],[0.4984,0.3381,0.3009,0.2367,-0.7],[0.4984,0.3391,0.3009,0.2367,-0.71],[0.4984,0.3399,0.3009,0.2367,-0.72],[0.4983,0.3404,0.3009,0.2367,-0.71],[0.4983,0.3407,0.3009,0.2367,-0.72],[0.4983,0.3408,0.3009,0.2367,-0.72],[0.4983,0.3409,0.3009,0.2367,-0.73],[0.4983,0.3409,0.3009,0.2367,-0.74],[0.4984,0.3409,0.3009,0.2367,-0.76],[0.4984,0.3409,0.3009,0.2367,-0.78],[0.4984,0.3409,0.3009,0.2367,-0.79],[0.4984,0.3409,0.3009,0.2367,-0.81],[0.4984,0.3409,0.3009,0.2367,-0.84],[0.4985,0.3411,0.3009,0.2367,-0.88],[0.4986,0.3415,0.3009,0.2367,-0.92],[0.4991,0.3423,0.3009,0.2367,-0.97],[0.5,0.3436,0.3009,0.2367,-1.04],[0.5016,0.3453,0.3009,0.2367,-1.14],[0.504,0.3476,0.3009,0.2367,-1.28],[0.5072,0.35,0.3009,0.2367,-1.44],[0.5107,0.3529,0.3009,0.2367,-1.67],[0.5145,0.3558,0.3009,0.2367,-1.95],[0.5182,0.3588,0.3009,0.2367,-2.26],[0.5214,0.3619,0.3009,0.2367,-2.61],[0.5241,0.3651,0.3009,0.2367,-2.95],[0.5262,0.3673,0.3009,0.2367,-3.26],[0.5276,0.3697,0.3009,0.2367,-3.55],[0.5282,0.3732,0.3009,0.2367,-3.77],[0.5284,0.3749,0.3009,0.2367,-3.89],[0.5284,0.3746,0.3009,0.2367,-3.96],[0.5284,0.3768,0.3009,0.2367,-4.0],[0.5283,0.3797,0.3009,0.2367,-4.01],[0.5283,0.3795,0.3009,0.2367,-3.99],[0.5283,0.3785,0.3009,0.2367,-3.96],[0.5282,0.3801,0.3009,0.2367,-3.93],[0.5282,0.3816,0.3009,0.2367,-3.9],[0.5282,0.38,0.3009,0.2367,-3.85],[0.5282,0.3779,0.3009,0.2367,-3.78],[0.5282,0.3782,0.3009,0.2367,-3.72],[0.5282,0.3791,0.3009,0.2367,-3.67],[0.5282,0.3769,0.3009,0.2367,-3.61],[0.5282,0.3749,0.3009,0.2367,-3.58],[0.5282,0.3769,0.3009,0.2367,-3.59],[0.5281,0.3782,0.3009,0.2367,-3.62],[0.5282,0.376,0.3009,0.2367,-3.64],[0.5283,0.3758,0.3009,0.2367,-3.66],[0.5283,0.3777,0.3009,0.2367,-3.67],[0.5284,0.3771,0.3009,0.2367,-3.67],[0.5286,0.376,0.3009,0.2367,-3.63],[0.5286,0.3757,0.3009,0.2367,-3.57],[0.5288,0.3742,0.3009,0.2367,-3.48],[0.529,0.3708,0.3009,0.2367,-3.33],[0.5288,0.3673,0.3009,0.2367,-3.12],[0.5279,0.3636,0.3009,0.2367,-2.86],[0.5262,0.3594,0.3009,0.2367,-2.54],[0.5238,0.3547,0.3009,0.2367,-2.18],[0.5204,0.3493,0.3009,0.2367,-1.79],[0.5164,0.3437,0.3009,0.2367,-1.42],[0.5122,0.3379,0.3009,0.2367,-1.09],[0.5082,0.3324,0.3009,0.2367,-0.82],[0.5047,0.3273,0.3009,0.2367,-0.62],[0.502,0.3231,0.3009,0.2367,-0.48],[0.5002,0.3197,0.3009,0.2367,-0.41],[0.4991,0.3169,0.3009,0.2367,-0.38],[0.4987,0.3147,0.3009,0.2367,-0.37],[0.4986,0.3132,0.3009,0.2367,-0.38],[0.4985,0.3121,0.3009,0.2367,-0.41],[0.4985,0.3113,0.3009,0.2367,-0.43],[0.4986,0.3108,0.3009,0.2367,-0.46],[0.4986,0.3104,0.3009,0.2367,-0.48],[0.4986,0.3101,0.3009,0.2367,-0.5],[0.4986,0.31,0.3009,0.2367,-0.53],[0.4987,0.3098,0.3009,0.2367,-0.54],[0.4987,0.3097,0.3009,0.2367,-0.55],[0.4987,0.3097,0.3009,0.2367,-0.54],[0.4987,0.3096,0.3009,0.2367,-0.52],[0.4987,0.3095,0.3009,0.2367,-0.51],[0.4987,0.3095,0.3009,0.2367,-0.48],[0.4987,0.3095,0.3009,0.2367,-0.47],[0.4987,0.3095,0.3009,0.2367,-0.45],[0.4986,0.3094,0.3009,0.2367,-0.44],[0.4986,0.3094,0.3009,0.2367,-0.42],[0.4986,0.3094,0.3009,0.2367,-0.42],[0.4986,0.3094,0.3009,0.2367,-0.41],[0.4986,0.3094,0.3009,0.2367,-0.41],[0.4986,0.3094,0.3009,0.2367,-0.42],[0.4986,0.3094,0.3009,0.2367,-0.42],[0.4987,0.3094,0.3009,0.2367,-0.42],[0.4987,0.3094,0.3009,0.2367,-0.42],[0.4987,0.3095,0.3009,0.2367,-0.42],[0.4987,0.3094,0.3009,0.2367,-0.42],[0.4987,0.3094,0.3009,0.2367,-0.42],[0.4987,0.3094,0.3009,0.2367,-0.41],[0.4987,0.3094,0.3009,0.2367,-0.41],[0.4987,0.3094,0.3009,0.2367,-0.41]],"reach":[0.1788,0.03,0.8212,0.92],"script":[[0,"idle",0,1],[0.6,"happy",0,0],[0.85,"blink",0,0],[0.89,"idle",0,0]]},"story-morning":{"file":"clips/story-morning.mp4","fps":24,"frames":124,"size":384,"face":"idle","rest":"story-morning-rest","mask":true,"tracks":[[0.4984,0.276,0.3172,0.2498,-0.3],[0.4984,0.276,0.3172,0.2498,-0.3],[0.4984,0.2761,0.3172,0.2498,-0.29],[0.4984,0.2761,0.3172,0.2498,-0.29],[0.4983,0.2761,0.3172,0.2498,-0.29],[0.4983,0.2761,0.3172,0.2498,-0.28],[0.4983,0.2762,0.3172,0.2498,-0.28],[0.4983,0.2762,0.3172,0.2498,-0.28],[0.4983,0.2762,0.3172,0.2498,-0.28],[0.4983,0.2761,0.3172,0.2498,-0.28],[0.4983,0.2761,0.3172,0.2498,-0.3],[0.4983,0.276,0.3172,0.2498,-0.31],[0.4983,0.2759,0.3172,0.2498,-0.32],[0.4983,0.2758,0.3172,0.2498,-0.31],[0.4984,0.2759,0.3172,0.2498,-0.29],[0.4983,0.2761,0.3172,0.2498,-0.25],[0.4983,0.2765,0.3172,0.2498,-0.2],[0.4983,0.2776,0.3172,0.2498,-0.13],[0.4983,0.279,0.3172,0.2498,-0.07],[0.4984,0.281,0.3172,0.2498,-0.01],[0.4984,0.2838,0.3172,0.2498,0.03],[0.4984,0.2872,0.3172,0.2498,0.05],[0.4985,0.2908,0.3172,0.2498,0.04],[0.4985,0.2954,0.3172,0.2498,0.01],[0.4985,0.3003,0.3172,0.2498,-0.02],[0.4985,0.3051,0.3172,0.2498,-0.06],[0.4985,0.3098,0.3172,0.2498,-0.11],[0.4984,0.3141,0.3172,0.2498,-0.17],[0.4983,0.3175,0.3172,0.2498,-0.23],[0.4981,0.3201,0.3172,0.2498,-0.31],[0.498,0.3219,0.3172,0.2498,-0.39],[0.4979,0.323,0.3172,0.2498,-0.49],[0.4978,0.3235,0.3172,0.2498,-0.59],[0.4977,0.3237,0.3172,0.2498,-0.7],[0.4976,0.3235,0.3172,0.2498,-0.79],[0.4975,0.323,0.3172,0.2498,-0.88],[0.4974,0.3221,0.3172,0.2498,-0.96],[0.4973,0.3209,0.3172,0.2498,-1.03],[0.4973,0.3192,0.3172,0.2498,-1.09],[0.4972,0.3173,0.3172,0.2498,-1.14],[0.4971,0.3151,0.3172,0.2498,-1.15],[0.4969,0.313,0.3172,0.2498,-1.14],[0.4968,0.3107,0.3172,0.2498,-1.07],[0.4968,0.3086,0.3172,0.2498,-0.95],[0.497,0.3065,0.3172,0.2498,-0.79],[0.4975,0.3045,0.3172,0.2498,-0.6],[0.4981,0.3027,0.3172,0.2498,-0.39],[0.4988,0.301,0.3172,0.2498,-0.21],[0.4994,0.2995,0.3172,0.2498,-0.11],[0.4997,0.2983,0.3172,0.2498,-0.1],[0.4996,0.2974,0.3172,0.2498,-0.15],[0.4992,0.2967,0.3172,0.2498,-0.28],[0.4987,0.2958,0.3172,0.2498,-0.43],[0.4983,0.2948,0.3172,0.2498,-0.58],[0.4981,0.2933,0.3172,0.2498,-0.69],[0.4984,0.2919,0.3172,0.2498,-0.76],[0.4988,0.2906,0.3172,0.2498,-0.8],[0.499,0.29,0.3172,0.2498,-0.84],[0.4992,0.2904,0.3172,0.2498,-0.87],[0.499,0.2922,0.3172,0.2498,-0.93],[0.4986,0.2948,0.3172,0.2498,-1.04],[0.4981,0.2982,0.3172,0.2498,-1.19],[0.498,0.302,0.3172,0.2498,-1.37],[0.4976,0.3057,0.3172,0.2498,-1.55],[0.4973,0.3092,0.3172,0.2498,-1.69],[0.4971,0.3126,0.3172,0.2498,-1.8],[0.497,0.3157,0.3172,0.2498,-1.89],[0.497,0.3185,0.3172,0.2498,-1.93],[0.497,0.3205,0.3172,0.2498,-1.92],[0.4971,0.3218,0.3172,0.2498,-1.91],[0.4973,0.322,0.3172,0.2498,-1.87],[0.4974,0.3212,0.3172,0.2498,-1.8],[0.4974,0.319,0.3172,0.2498,-1.69],[0.4976,0.3165,0.3172,0.2498,-1.55],[0.4978,0.3127,0.3172,0.2498,-1.37],[0.498,0.3088,0.3172,0.2498,-1.16],[0.4983,0.3051,0.3172,0.2498,-0.91],[0.4985,0.3014,0.3172,0.2498,-0.67],[0.4985,0.2973,0.3172,0.2498,-0.46],[0.4986,0.2941,0.3172,0.2498,-0.3],[0.4986,0.291,0.3172,0.2498,-0.19],[0.4986,0.2882,0.3172,0.2498,-0.13],[0.4985,0.2856,0.3172,0.2498,-0.11],[0.4985,0.2838,0.3172,0.2498,-0.11],[0.4985,0.282,0.3172,0.2498,-0.11],[0.4985,0.2804,0.3172,0.2498,-0.12],[0.4985,0.2791,0.3172,0.2498,-0.12],[0.4984,0.2781,0.3172,0.2498,-0.1],[0.4984,0.277,0.3172,0.2498,-0.1],[0.4984,0.2762,0.3172,0.2498,-0.09],[0.4984,0.2756,0.3172,0.2498,-0.08],[0.4984,0.275,0.3172,0.2498,-0.08],[0.4985,0.2745,0.3172,0.2498,-0.09],[0.4985,0.2743,0.3172,0.2498,-0.09],[0.4985,0.2742,0.3172,0.2498,-0.1],[0.4985,0.2742,0.3172,0.2498,-0.12],[0.4985,0.2743,0.3172,0.2498,-0.14],[0.4985,0.2744,0.3172,0.2498,-0.16],[0.4985,0.2746,0.3172,0.2498,-0.17],[0.4985,0.2749,0.3172,0.2498,-0.19],[0.4984,0.2752,0.3172,0.2498,-0.21],[0.4984,0.2754,0.3172,0.2498,-0.22],[0.4984,0.2757,0.3172,0.2498,-0.23],[0.4984,0.2758,0.3172,0.2498,-0.23],[0.4984,0.2758,0.3172,0.2498,-0.24],[0.4984,0.2759,0.3172,0.2498,-0.25],[0.4984,0.2759,0.3172,0.2498,-0.27],[0.4984,0.276,0.3172,0.2498,-0.28],[0.4983,0.276,0.3172,0.2498,-0.29],[0.4983,0.2761,0.3172,0.2498,-0.29],[0.4983,0.276,0.3172,0.2498,-0.28],[0.4983,0.2759,0.3172,0.2498,-0.26],[0.4984,0.2758,0.3172,0.2498,-0.24],[0.4984,0.2756,0.3172,0.2498,-0.22],[0.4984,0.2755,0.3172,0.2498,-0.2],[0.4984,0.2753,0.3172,0.2498,-0.19],[0.4984,0.2752,0.3172,0.2498,-0.19],[0.4984,0.2751,0.3172,0.2498,-0.19],[0.4984,0.275,0.3172,0.2498,-0.2],[0.4984,0.2748,0.3172,0.2498,-0.21],[0.4984,0.2747,0.3172,0.2498,-0.21],[0.4984,0.2747,0.3172,0.2498,-0.22],[0.4984,0.2746,0.3172,0.2498,-0.22],[0.4984,0.2745,0.3172,0.2498,-0.22]],"reach":[0.1974,0.03,0.8026,0.92],"script":[[0,"idle",0,0],[0.25,"blink",0,0],[0.55,"happy",0,0]]},"story-news":{"file":"clips/story-news.mp4","fps":24,"frames":124,"size":384,"face":"idle","rest":"story-news-rest","mask":true,"tracks":[[0.503,0.3217,0.2842,0.2162,-0.52],[0.503,0.3216,0.2842,0.2162,-0.53],[0.503,0.3216,0.2842,0.2162,-0.53],[0.5029,0.3215,0.2842,0.2162,-0.55],[0.5029,0.3215,0.2842,0.2162,-0.57],[0.5029,0.3215,0.2842,0.2162,-0.58],[0.5029,0.3215,0.2842,0.2162,-0.6],[0.5029,0.3215,0.2842,0.2162,-0.62],[0.5029,0.3215,0.2842,0.2162,-0.64],[0.5029,0.3215,0.2842,0.2162,-0.65],[0.5029,0.3215,0.2842,0.2162,-0.66],[0.5029,0.3215,0.2842,0.2162,-0.66],[0.5029,0.3215,0.2842,0.2162,-0.65],[0.5029,0.3215,0.2842,0.2162,-0.65],[0.5029,0.3215,0.2842,0.2162,-0.65],[0.5029,0.3215,0.2842,0.2162,-0.64],[0.5029,0.3215,0.2842,0.2162,-0.61],[0.5029,0.3216,0.2842,0.2162,-0.59],[0.5029,0.3216,0.2842,0.2162,-0.55],[0.503,0.3217,0.2842,0.2162,-0.51],[0.503,0.3217,0.2842,0.2162,-0.47],[0.503,0.3216,0.2842,0.2162,-0.43],[0.5031,0.3216,0.2842,0.2162,-0.4],[0.5031,0.3215,0.2842,0.2162,-0.39],[0.5031,0.3214,0.2842,0.2162,-0.39],[0.5031,0.3214,0.2842,0.2162,-0.39],[0.5031,0.3214,0.2842,0.2162,-0.4],[0.5031,0.3214,0.2842,0.2162,-0.41],[0.5031,0.3215,0.2842,0.2162,-0.42],[0.5031,0.3215,0.2842,0.2162,-0.42],[0.5031,0.3216,0.2842,0.2162,-0.42],[0.5031,0.3217,0.2842,0.2162,-0.43],[0.5031,0.3219,0.2842,0.2162,-0.45],[0.5031,0.3223,0.2842,0.2162,-0.48],[0.5031,0.3227,0.2842,0.2162,-0.54],[0.5031,0.3232,0.2842,0.2162,-0.65],[0.5031,0.324,0.2842,0.2162,-0.81],[0.5031,0.3251,0.2842,0.2162,-1.05],[0.5029,0.3263,0.2842,0.2162,-1.38],[0.5024,0.3278,0.2842,0.2162,-1.76],[0.5013,0.3297,0.2842,0.2162,-2.16],[0.4997,0.3316,0.2842,0.2162,-2.62],[0.4979,0.3333,0.2842,0.2162,-3.13],[0.4962,0.3344,0.2842,0.2162,-3.66],[0.4929,0.3342,0.2842,0.2162,-4.23],[0.4892,0.3333,0.2842,0.2162,-4.83],[0.4854,0.3319,0.2842,0.2162,-5.43],[0.4815,0.3303,0.2842,0.2162,-6.03],[0.4775,0.329,0.2842,0.2162,-6.56],[0.4753,0.3286,0.2842,0.2162,-7.01],[0.4741,0.3284,0.2842,0.2162,-7.39],[0.4735,0.3283,0.2842,0.2162,-7.64],[0.4733,0.3283,0.2842,0.2162,-7.77],[0.4733,0.3283,0.2842,0.2162,-7.83],[0.4734,0.3283,0.2842,0.2162,-7.86],[0.4734,0.3283,0.2842,0.2162,-7.87],[0.4734,0.3283,0.2842,0.2162,-7.86],[0.4734,0.3283,0.2842,0.2162,-7.85],[0.4734,0.3283,0.2842,0.2162,-7.84],[0.4734,0.3283,0.2842,0.2162,-7.83],[0.4734,0.3283,0.2842,0.2162,-7.82],[0.4734,0.3283,0.2842,0.2162,-7.81],[0.4735,0.3284,0.2842,0.2162,-7.81],[0.4735,0.3283,0.2842,0.2162,-7.81],[0.4734,0.3283,0.2842,0.2162,-7.8],[0.4734,0.3283,0.2842,0.2162,-7.8],[0.4734,0.3283,0.2842,0.2162,-7.8],[0.4734,0.3283,0.2842,0.2162,-7.8],[0.4734,0.3283,0.2842,0.2162,-7.81],[0.4734,0.3283,0.2842,0.2162,-7.8],[0.4734,0.3283,0.2842,0.2162,-7.8],[0.4734,0.3283,0.2842,0.2162,-7.8],[0.4734,0.3282,0.2842,0.2162,-7.8],[0.4734,0.3282,0.2842,0.2162,-7.8],[0.4734,0.3282,0.2842,0.2162,-7.78],[0.4734,0.3282,0.2842,0.2162,-7.77],[0.4738,0.3284,0.2842,0.2162,-7.71],[0.4748,0.3292,0.2842,0.2162,-7.57],[0.4765,0.3301,0.2842,0.2162,-7.34],[0.4793,0.3313,0.2842,0.2162,-7.0],[0.4825,0.3321,0.2842,0.2162,-6.52],[0.4864,0.3323,0.2842,0.2162,-5.89],[0.4901,0.3315,0.2842,0.2162,-5.15],[0.4933,0.3301,0.2842,0.2162,-4.34],[0.4961,0.3281,0.2842,0.2162,-3.54],[0.4984,0.3262,0.2842,0.2162,-2.76],[0.5,0.3247,0.2842,0.2162,-2.08],[0.5012,0.3236,0.2842,0.2162,-1.54],[0.5021,0.3228,0.2842,0.2162,-1.14],[0.5025,0.3223,0.2842,0.2162,-0.84],[0.5027,0.3221,0.2842,0.2162,-0.63],[0.5028,0.3219,0.2842,0.2162,-0.48],[0.5029,0.3218,0.2842,0.2162,-0.32],[0.503,0.3216,0.2842,0.2162,-0.18],[0.5031,0.3214,0.2842,0.2162,-0.04],[0.5034,0.3211,0.2842,0.2162,0.06],[0.5035,0.3211,0.2842,0.2162,0.12],[0.5035,0.3211,0.2842,0.2162,0.09],[0.5035,0.3212,0.2842,0.2162,0.02],[0.5034,0.3213,0.2842,0.2162,-0.12],[0.5031,0.3217,0.2842,0.2162,-0.27],[0.5031,0.3218,0.2842,0.2162,-0.42],[0.503,0.3218,0.2842,0.2162,-0.54],[0.503,0.3218,0.2842,0.2162,-0.64],[0.5029,0.3217,0.2842,0.2162,-0.67],[0.5029,0.3216,0.2842,0.2162,-0.69],[0.503,0.3216,0.2842,0.2162,-0.71],[0.503,0.3216,0.2842,0.2162,-0.72],[0.503,0.3216,0.2842,0.2162,-0.73],[0.503,0.3216,0.2842,0.2162,-0.74],[0.503,0.3216,0.2842,0.2162,-0.75],[0.503,0.3216,0.2842,0.2162,-0.75],[0.503,0.3216,0.2842,0.2162,-0.76],[0.503,0.3216,0.2842,0.2162,-0.76],[0.503,0.3216,0.2842,0.2162,-0.76],[0.503,0.3216,0.2842,0.2162,-0.75],[0.503,0.3216,0.2842,0.2162,-0.75],[0.503,0.3216,0.2842,0.2162,-0.75],[0.503,0.3216,0.2842,0.2162,-0.75],[0.503,0.3216,0.2842,0.2162,-0.75],[0.503,0.3216,0.2842,0.2162,-0.75],[0.503,0.3216,0.2842,0.2162,-0.75],[0.503,0.3216,0.2842,0.2162,-0.75],[0.503,0.3216,0.2842,0.2162,-0.75]],"reach":[0.1966,0.03,0.8034,0.92],"script":[[0,"idle",0,0],[0.25,"idle",0,1],[0.7,"happy",0,0],[0.85,"blink",0,0],[0.89,"idle",0,0]]},"story-night":{"file":"clips/story-night.mp4","fps":24,"frames":124,"size":384,"face":"idle","rest":"story-night-rest","mask":true,"tracks":[[0.4374,0.336,0.2636,0.2077,0.65],[0.4374,0.336,0.2636,0.2077,0.65],[0.4374,0.336,0.2636,0.2077,0.65],[0.4374,0.3361,0.2636,0.2077,0.64],[0.4376,0.3368,0.2636,0.2077,0.63],[0.4377,0.3389,0.2636,0.2077,0.62],[0.4378,0.3428,0.2636,0.2077,0.6],[0.438,0.3484,0.2636,0.2077,0.61],[0.4383,0.3555,0.2636,0.2077,0.65],[0.4386,0.3631,0.2636,0.2077,0.76],[0.439,0.3696,0.2636,0.2077,1.0],[0.4395,0.3738,0.2636,0.2077,1.38],[0.4399,0.3747,0.2636,0.2077,1.91],[0.4401,0.3722,0.2636,0.2077,2.59],[0.4401,0.3664,0.2636,0.2077,3.37],[0.4399,0.3581,0.2636,0.2077,4.21],[0.4396,0.3489,0.2636,0.2077,5.06],[0.4392,0.3392,0.2636,0.2077,5.9],[0.4389,0.3302,0.2636,0.2077,6.68],[0.4389,0.3227,0.2636,0.2077,7.41],[0.4391,0.3165,0.2636,0.2077,8.05],[0.4394,0.3114,0.2636,0.2077,8.62],[0.4399,0.3081,0.2636,0.2077,9.11],[0.4404,0.306,0.2636,0.2077,9.52],[0.4407,0.3048,0.2636,0.2077,9.84],[0.4409,0.3041,0.2636,0.2077,10.09],[0.441,0.3039,0.2636,0.2077,10.26],[0.4412,0.3037,0.2636,0.2077,10.38],[0.4413,0.3036,0.2636,0.2077,10.46],[0.4415,0.3034,0.2636,0.2077,10.55],[0.4416,0.3032,0.2636,0.2077,10.63],[0.4418,0.303,0.2636,0.2077,10.71],[0.4419,0.3028,0.2636,0.2077,10.79],[0.442,0.3027,0.2636,0.2077,10.87],[0.4422,0.3025,0.2636,0.2077,10.95],[0.4423,0.3023,0.2636,0.2077,11.03],[0.4425,0.3021,0.2636,0.2077,11.11],[0.4426,0.3019,0.2636,0.2077,11.19],[0.4427,0.3018,0.2636,0.2077,11.27],[0.4429,0.3016,0.2636,0.2077,11.35],[0.443,0.3014,0.2636,0.2077,11.43],[0.4432,0.3012,0.2636,0.2077,11.52],[0.4433,0.301,0.2636,0.2077,11.6],[0.4435,0.3009,0.2636,0.2077,11.67],[0.4436,0.3007,0.2636,0.2077,11.7],[0.4437,0.3005,0.2636,0.2077,11.69],[0.4437,0.3012,0.2636,0.2077,11.65],[0.4438,0.3031,0.2636,0.2077,11.58],[0.4437,0.3072,0.2636,0.2077,11.48],[0.4434,0.3134,0.2636,0.2077,11.37],[0.4417,0.3236,0.2636,0.2077,11.25],[0.4389,0.3342,0.2636,0.2077,11.09],[0.4343,0.3442,0.2636,0.2077,10.87],[0.4281,0.3529,0.2636,0.2077,10.55],[0.4197,0.3597,0.2636,0.2077,10.08],[0.4105,0.3626,0.2636,0.2077,9.47],[0.4001,0.3642,0.2636,0.2077,8.68],[0.3892,0.3649,0.2636,0.2077,7.74],[0.3782,0.3647,0.2636,0.2077,6.67],[0.3679,0.3639,0.2636,0.2077,5.54],[0.3585,0.3629,0.2636,0.2077,4.37],[0.3508,0.3618,0.2636,0.2077,3.24],[0.3452,0.3604,0.2636,0.2077,2.18],[0.3417,0.3589,0.2636,0.2077,1.25],[0.3408,0.3571,0.2636,0.2077,0.51],[0.3425,0.3551,0.2636,0.2077,0.04],[0.3472,0.3525,0.2636,0.2077,-0.14],[0.3547,0.3497,0.2636,0.2077,0.07],[0.3651,0.3467,0.2636,0.2077,0.68],[0.3785,0.3435,0.2636,0.2077,1.73],[0.3948,0.3406,0.2636,0.2077,3.1],[0.4133,0.3383,0.2636,0.2077,4.7],[0.4335,0.3368,0.2636,0.2077,6.37],[0.4548,0.3363,0.2636,0.2077,7.98],[0.4758,0.3368,0.2636,0.2077,9.37],[0.4968,0.3385,0.2636,0.2077,10.47],[0.5153,0.3406,0.2636,0.2077,11.22],[0.5309,0.343,0.2636,0.2077,11.69],[0.5434,0.3454,0.2636,0.2077,11.92],[0.5527,0.3476,0.2636,0.2077,12.0],[0.5577,0.349,0.2636,0.2077,12.0],[0.5603,0.35,0.2636,0.2077,12.0],[0.5603,0.3505,0.2636,0.2077,12.0],[0.5579,0.3505,0.2636,0.2077,12.0],[0.5536,0.3501,0.2636,0.2077,12.0],[0.5477,0.3493,0.2636,0.2077,12.0],[0.5403,0.3485,0.2636,0.2077,12.0],[0.5316,0.3476,0.2636,0.2077,12.0],[0.522,0.3468,0.2636,0.2077,12.0],[0.5114,0.3461,0.2636,0.2077,11.98],[0.5002,0.3456,0.2636,0.2077,11.87],[0.487,0.3455,0.2636,0.2077,11.58],[0.4743,0.3458,0.2636,0.2077,11.05],[0.4623,0.3459,0.2636,0.2077,10.23],[0.4523,0.3458,0.2636,0.2077,9.11],[0.4442,0.3453,0.2636,0.2077,7.75],[0.4401,0.344,0.2636,0.2077,6.25],[0.4378,0.3423,0.2636,0.2077,4.76],[0.437,0.3406,0.2636,0.2077,3.4],[0.4368,0.3389,0.2636,0.2077,2.27],[0.4369,0.3377,0.2636,0.2077,1.44],[0.4371,0.3369,0.2636,0.2077,0.91],[0.4372,0.3363,0.2636,0.2077,0.61],[0.4373,0.3361,0.2636,0.2077,0.48],[0.4372,0.3361,0.2636,0.2077,0.43],[0.4372,0.3361,0.2636,0.2077,0.42],[0.4372,0.3361,0.2636,0.2077,0.43],[0.4372,0.3361,0.2636,0.2077,0.43],[0.4372,0.3361,0.2636,0.2077,0.43],[0.4372,0.3361,0.2636,0.2077,0.44],[0.4372,0.3361,0.2636,0.2077,0.44],[0.4372,0.3361,0.2636,0.2077,0.45],[0.4372,0.3361,0.2636,0.2077,0.46],[0.4372,0.3361,0.2636,0.2077,0.47],[0.4372,0.3361,0.2636,0.2077,0.49],[0.4372,0.3361,0.2636,0.2077,0.5],[0.4372,0.3361,0.2636,0.2077,0.52],[0.4372,0.3361,0.2636,0.2077,0.53],[0.4372,0.3361,0.2636,0.2077,0.53],[0.4372,0.3361,0.2636,0.2077,0.54],[0.4372,0.3361,0.2636,0.2077,0.54],[0.4372,0.3361,0.2636,0.2077,0.53],[0.4372,0.3361,0.2636,0.2077,0.53],[0.4372,0.3361,0.2636,0.2077,0.53]],"reach":[0.089,0.03,0.911,0.92],"script":[[0,"blink",0,0],[0.3,"idle",0,0],[0.38,"blink",0,0],[0.7,"happy",0,0]]},"story-science":{"file":"clips/story-science.mp4","fps":24,"frames":124,"size":384,"face":"idle","rest":"story-science-rest","mask":true,"tracks":[[0.4999,0.3099,0.2962,0.2362,-0.56],[0.4999,0.3099,0.2962,0.2362,-0.56],[0.4999,0.3099,0.2962,0.2362,-0.56],[0.4999,0.3099,0.2962,0.2362,-0.57],[0.4999,0.3099,0.2962,0.2362,-0.55],[0.4999,0.3099,0.2962,0.2362,-0.53],[0.4999,0.3099,0.2962,0.2362,-0.5],[0.4999,0.31,0.2962,0.2362,-0.45],[0.4993,0.3103,0.2962,0.2362,-0.4],[0.4985,0.3109,0.2962,0.2362,-0.34],[0.4965,0.3123,0.2962,0.2362,-0.25],[0.4935,0.3138,0.2962,0.2362,-0.15],[0.4904,0.3154,0.2962,0.2362,-0.01],[0.4864,0.3172,0.2962,0.2362,0.18],[0.4828,0.3187,0.2962,0.2362,0.4],[0.4794,0.3197,0.2962,0.2362,0.64],[0.4766,0.3204,0.2962,0.2362,0.91],[0.4738,0.3211,0.2962,0.2362,1.16],[0.4721,0.3211,0.2962,0.2362,1.39],[0.4704,0.3211,0.2962,0.2362,1.61],[0.4699,0.3207,0.2962,0.2362,1.86],[0.4703,0.3202,0.2962,0.2362,2.13],[0.4713,0.3196,0.2962,0.2362,2.55],[0.4736,0.3189,0.2962,0.2362,3.22],[0.4761,0.3183,0.2962,0.2362,4.13],[0.4807,0.3179,0.2962,0.2362,5.25],[0.4873,0.3182,0.2962,0.2362,6.55],[0.4933,0.3186,0.2962,0.2362,7.87],[0.5001,0.3197,0.2962,0.2362,9.13],[0.5066,0.3209,0.2962,0.2362,10.22],[0.5115,0.3222,0.2962,0.2362,11.02],[0.5144,0.3233,0.2962,0.2362,11.56],[0.5178,0.3246,0.2962,0.2362,11.89],[0.5194,0.3253,0.2962,0.2362,12.0],[0.5214,0.326,0.2962,0.2362,12.0],[0.5226,0.3265,0.2962,0.2362,12.0],[0.5236,0.3268,0.2962,0.2362,12.0],[0.5243,0.3269,0.2962,0.2362,12.0],[0.525,0.327,0.2962,0.2362,12.0],[0.5258,0.3271,0.2962,0.2362,12.0],[0.5266,0.3272,0.2962,0.2362,12.0],[0.5276,0.3274,0.2962,0.2362,12.0],[0.5284,0.3276,0.2962,0.2362,12.0],[0.5292,0.3277,0.2962,0.2362,12.0],[0.5297,0.3278,0.2962,0.2362,12.0],[0.5302,0.328,0.2962,0.2362,12.0],[0.5304,0.328,0.2962,0.2362,12.0],[0.5306,0.328,0.2962,0.2362,12.0],[0.5307,0.328,0.2962,0.2362,12.0],[0.5309,0.328,0.2962,0.2362,12.0],[0.531,0.328,0.2962,0.2362,12.0],[0.531,0.328,0.2962,0.2362,12.0],[0.5311,0.3281,0.2962,0.2362,12.0],[0.5311,0.3281,0.2962,0.2362,12.0],[0.5311,0.3281,0.2962,0.2362,12.0],[0.5311,0.3281,0.2962,0.2362,12.0],[0.5311,0.3281,0.2962,0.2362,12.0],[0.5311,0.3281,0.2962,0.2362,12.0],[0.531,0.3281,0.2962,0.2362,12.0],[0.531,0.328,0.2962,0.2362,12.0],[0.531,0.328,0.2962,0.2362,12.0],[0.531,0.328,0.2962,0.2362,12.0],[0.531,0.328,0.2962,0.2362,12.0],[0.531,0.328,0.2962,0.2362,12.0],[0.531,0.328,0.2962,0.2362,12.0],[0.531,0.328,0.2962,0.2362,12.0],[0.531,0.3281,0.2962,0.2362,12.0],[0.531,0.3281,0.2962,0.2362,12.0],[0.5307,0.328,0.2962,0.2362,12.0],[0.5299,0.3277,0.2962,0.2362,12.0],[0.5284,0.3271,0.2962,0.2362,12.0],[0.5261,0.326,0.2962,0.2362,12.0],[0.5228,0.3246,0.2962,0.2362,11.94],[0.5187,0.3227,0.2962,0.2362,11.75],[0.5141,0.3207,0.2962,0.2362,11.37],[0.5093,0.3185,0.2962,0.2362,10.77],[0.5046,0.3163,0.2962,0.2362,9.9],[0.5,0.3143,0.2962,0.2362,8.81],[0.4954,0.3124,0.2962,0.2362,7.55],[0.4912,0.3107,0.2962,0.2362,6.22],[0.4877,0.3095,0.2962,0.2362,4.94],[0.4846,0.3085,0.2962,0.2362,3.8],[0.4823,0.308,0.2962,0.2362,2.84],[0.4811,0.3077,0.2962,0.2362,2.12],[0.4804,0.3077,0.2962,0.2362,1.59],[0.48,0.3077,0.2962,0.2362,1.21],[0.4802,0.3078,0.2962,0.2362,0.93],[0.4806,0.3079,0.2962,0.2362,0.71],[0.4816,0.3081,0.2962,0.2362,0.51],[0.4833,0.3083,0.2962,0.2362,0.35],[0.4858,0.3085,0.2962,0.2362,0.19],[0.4883,0.3088,0.2962,0.2362,0.06],[0.4915,0.3091,0.2962,0.2362,-0.06],[0.4944,0.3093,0.2962,0.2362,-0.16],[0.4967,0.3094,0.2962,0.2362,-0.24],[0.4982,0.3096,0.2962,0.2362,-0.3],[0.4995,0.3097,0.2962,0.2362,-0.36],[0.4998,0.3098,0.2962,0.2362,-0.4],[0.4999,0.3098,0.2962,0.2362,-0.43],[0.4999,0.3098,0.2962,0.2362,-0.45],[0.4998,0.3098,0.2962,0.2362,-0.47],[0.4999,0.3099,0.2962,0.2362,-0.48],[0.4999,0.3099,0.2962,0.2362,-0.48],[0.4999,0.3099,0.2962,0.2362,-0.49],[0.4999,0.3098,0.2962,0.2362,-0.5],[0.4999,0.3098,0.2962,0.2362,-0.5],[0.4999,0.3098,0.2962,0.2362,-0.51],[0.4999,0.3098,0.2962,0.2362,-0.51],[0.4999,0.3099,0.2962,0.2362,-0.52],[0.4999,0.3099,0.2962,0.2362,-0.52],[0.4999,0.3099,0.2962,0.2362,-0.52],[0.4999,0.3099,0.2962,0.2362,-0.52],[0.4999,0.3099,0.2962,0.2362,-0.53],[0.4999,0.3099,0.2962,0.2362,-0.53],[0.4999,0.3099,0.2962,0.2362,-0.54],[0.4999,0.3099,0.2962,0.2362,-0.54],[0.4999,0.3099,0.2962,0.2362,-0.55],[0.4999,0.3099,0.2962,0.2362,-0.55],[0.4999,0.3099,0.2962,0.2362,-0.55],[0.4999,0.3099,0.2962,0.2362,-0.55],[0.4999,0.3099,0.2962,0.2362,-0.55],[0.4999,0.3099,0.2962,0.2362,-0.55],[0.4999,0.3099,0.2962,0.2362,-0.55],[0.4999,0.3099,0.2962,0.2362,-0.55]],"reach":[0.1678,0.03,0.8322,0.92],"script":[[0,"idle",0,0],[0.25,"idle",-1,-1],[0.65,"happy",0,0],[0.85,"blink",0,0],[0.89,"idle",0,0]]},"story-tech":{"file":"clips/story-tech.mp4","fps":24,"frames":124,"size":384,"face":"idle","rest":"story-tech-rest","mask":true,"tracks":[[0.5459,0.3504,0.2718,0.2144,-0.65],[0.5459,0.3503,0.2718,0.2144,-0.63],[0.5456,0.3505,0.2718,0.2144,-0.59],[0.544,0.3509,0.2718,0.2144,-0.3],[0.5403,0.3521,0.2718,0.2144,0.35],[0.5339,0.354,0.2718,0.2144,1.37],[0.5215,0.3577,0.2718,0.2144,2.73],[0.5073,0.3615,0.2718,0.2144,4.43],[0.4932,0.3653,0.2718,0.2144,6.23],[0.4807,0.3685,0.2718,0.2144,7.99],[0.4704,0.3709,0.2718,0.2144,9.5],[0.4658,0.3717,0.2718,0.2144,10.65],[0.4631,0.3722,0.2718,0.2144,11.44],[0.4612,0.3727,0.2718,0.2144,11.89],[0.4594,0.3736,0.2718,0.2144,12.0],[0.4583,0.3744,0.2718,0.2144,12.0],[0.4576,0.375,0.2718,0.2144,12.0],[0.4577,0.3752,0.2718,0.2144,12.0],[0.4597,0.3745,0.2718,0.2144,12.0],[0.4623,0.3733,0.2718,0.2144,12.0],[0.4645,0.3724,0.2718,0.2144,12.0],[0.4659,0.3716,0.2718,0.2144,12.0],[0.4659,0.3715,0.2718,0.2144,12.0],[0.464,0.3724,0.2718,0.2144,12.0],[0.4618,0.3733,0.2718,0.2144,12.0],[0.4599,0.374,0.2718,0.2144,12.0],[0.4587,0.3744,0.2718,0.2144,12.0],[0.4587,0.3743,0.2718,0.2144,12.0],[0.4594,0.3738,0.2718,0.2144,12.0],[0.4605,0.3732,0.2718,0.2144,12.0],[0.4613,0.3728,0.2718,0.2144,12.0],[0.4616,0.3727,0.2718,0.2144,12.0],[0.4613,0.3728,0.2718,0.2144,12.0],[0.4606,0.3731,0.2718,0.2144,12.0],[0.46,0.3735,0.2718,0.2144,12.0],[0.46,0.3735,0.2718,0.2144,12.0],[0.4605,0.3733,0.2718,0.2144,12.0],[0.4616,0.3729,0.2718,0.2144,12.0],[0.4629,0.3725,0.2718,0.2144,12.0],[0.4636,0.3721,0.2718,0.2144,12.0],[0.4635,0.3721,0.2718,0.2144,12.0],[0.4627,0.3724,0.2718,0.2144,12.0],[0.4611,0.373,0.2718,0.2144,12.0],[0.4594,0.3735,0.2718,0.2144,12.0],[0.4585,0.374,0.2718,0.2144,12.0],[0.4583,0.3741,0.2718,0.2144,12.0],[0.4589,0.3738,0.2718,0.2144,12.0],[0.46,0.3733,0.2718,0.2144,12.0],[0.4605,0.3732,0.2718,0.2144,12.0],[0.4595,0.3736,0.2718,0.2144,12.0],[0.4554,0.376,0.2718,0.2144,12.0],[0.4503,0.38,0.2718,0.2144,12.0],[0.445,0.3855,0.2718,0.2144,12.0],[0.4406,0.3914,0.2718,0.2144,12.0],[0.4387,0.3956,0.2718,0.2144,12.0],[0.4407,0.3962,0.2718,0.2144,12.0],[0.4446,0.3934,0.2718,0.2144,12.0],[0.4498,0.3876,0.2718,0.2144,12.0],[0.4584,0.3796,0.2718,0.2144,12.0],[0.4666,0.3726,0.2718,0.2144,12.0],[0.4744,0.3675,0.2718,0.2144,12.0],[0.4813,0.3645,0.2718,0.2144,12.0],[0.4871,0.3632,0.2718,0.2144,12.0],[0.4892,0.3633,0.2718,0.2144,12.0],[0.4903,0.3635,0.2718,0.2144,12.0],[0.4909,0.3636,0.2718,0.2144,12.0],[0.4911,0.3636,0.2718,0.2144,12.0],[0.4911,0.3636,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.4909,0.3634,0.2718,0.2144,12.0],[0.4909,0.3634,0.2718,0.2144,12.0],[0.4909,0.3634,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.4898,0.3636,0.2718,0.2144,12.0],[0.4872,0.364,0.2718,0.2144,11.94],[0.482,0.3673,0.2718,0.2144,11.75],[0.4769,0.3746,0.2718,0.2144,11.33],[0.4764,0.3882,0.2718,0.2144,10.59],[0.4803,0.403,0.2718,0.2144,9.5],[0.4887,0.4135,0.2718,0.2144,8.11],[0.5019,0.4165,0.2718,0.2144,6.48],[0.517,0.4107,0.2718,0.2144,4.79],[0.528,0.3959,0.2718,0.2144,3.18],[0.5364,0.3783,0.2718,0.2144,1.8],[0.5416,0.3649,0.2718,0.2144,0.74],[0.5444,0.3561,0.2718,0.2144,0.04],[0.5455,0.352,0.2718,0.2144,-0.36],[0.5458,0.3506,0.2718,0.2144,-0.52],[0.5458,0.3506,0.2718,0.2144,-0.56],[0.5458,0.3506,0.2718,0.2144,-0.56],[0.5458,0.3506,0.2718,0.2144,-0.55],[0.5458,0.3506,0.2718,0.2144,-0.54],[0.5457,0.3505,0.2718,0.2144,-0.54],[0.5457,0.3505,0.2718,0.2144,-0.55],[0.5458,0.3505,0.2718,0.2144,-0.55],[0.5458,0.3505,0.2718,0.2144,-0.56],[0.5458,0.3505,0.2718,0.2144,-0.56],[0.5458,0.3505,0.2718,0.2144,-0.56],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57]],"reach":[0.1894,0.03,0.8106,0.92],"script":[[0,"idle",0,1],[0.4,"happy",0,0],[0.8,"blink",0,0],[0.84,"idle",0,0]]},"story-trophy":{"file":"clips/story-trophy.mp4","fps":24,"frames":124,"size":384,"face":"happy","rest":"story-trophy-rest","mask":true,"tracks":[[0.543,0.3385,0.2824,0.2181,-0.62],[0.543,0.3385,0.2824,0.2181,-0.63],[0.543,0.3385,0.2824,0.2181,-0.64],[0.543,0.3385,0.2824,0.2181,-0.66],[0.543,0.3385,0.2824,0.2181,-0.68],[0.543,0.3385,0.2824,0.2181,-0.69],[0.543,0.3385,0.2824,0.2181,-0.7],[0.543,0.3385,0.2824,0.2181,-0.7],[0.543,0.3385,0.2824,0.2181,-0.7],[0.543,0.3385,0.2824,0.2181,-0.69],[0.543,0.3385,0.2824,0.2181,-0.69],[0.543,0.3385,0.2824,0.2181,-0.69],[0.543,0.3385,0.2824,0.2181,-0.7],[0.543,0.3385,0.2824,0.2181,-0.71],[0.543,0.3385,0.2824,0.2181,-0.73],[0.5431,0.3385,0.2824,0.2181,-0.74],[0.5431,0.3385,0.2824,0.2181,-0.76],[0.5431,0.3385,0.2824,0.2181,-0.79],[0.5431,0.3385,0.2824,0.2181,-0.84],[0.5431,0.3385,0.2824,0.2181,-0.9],[0.543,0.3398,0.2824,0.2181,-0.98],[0.5431,0.3432,0.2824,0.2181,-1.06],[0.5432,0.348,0.2824,0.2181,-0.75],[0.543,0.3534,0.2824,0.2181,-0.5],[0.542,0.3595,0.2824,0.2181,-0.32],[0.5425,0.3614,0.2824,0.2181,-0.19],[0.5398,0.3589,0.2824,0.2181,-0.14],[0.5362,0.3494,0.2824,0.2181,-0.55],[0.5318,0.3349,0.2824,0.2181,-0.96],[0.5278,0.3181,0.2824,0.2181,-1.78],[0.5222,0.304,0.2824,0.2181,-2.55],[0.5198,0.2933,0.2824,0.2181,-3.23],[0.5183,0.2894,0.2824,0.2181,-3.84],[0.5179,0.2907,0.2824,0.2181,-4.35],[0.518,0.2937,0.2824,0.2181,-4.39],[0.5182,0.2962,0.2824,0.2181,-4.43],[0.5183,0.2972,0.2824,0.2181,-4.45],[0.5182,0.2969,0.2824,0.2181,-4.46],[0.518,0.2957,0.2824,0.2181,-4.49],[0.5179,0.2943,0.2824,0.2181,-4.51],[0.5178,0.2937,0.2824,0.2181,-4.53],[0.5178,0.2935,0.2824,0.2181,-4.56],[0.5177,0.2934,0.2824,0.2181,-4.59],[0.5177,0.2934,0.2824,0.2181,-4.61],[0.5177,0.2934,0.2824,0.2181,-4.64],[0.5177,0.2934,0.2824,0.2181,-4.63],[0.5177,0.2935,0.2824,0.2181,-4.6],[0.5177,0.2935,0.2824,0.2181,-4.54],[0.5183,0.285,0.2824,0.2181,-4.44],[0.5189,0.272,0.2824,0.2181,-4.31],[0.5193,0.256,0.2824,0.2181,-4.19],[0.5197,0.2462,0.2824,0.2181,-4.06],[0.5198,0.2506,0.2824,0.2181,-3.97],[0.5194,0.2677,0.2824,0.2181,-3.94],[0.5191,0.2887,0.2824,0.2181,-3.94],[0.5189,0.3113,0.2824,0.2181,-3.99],[0.5188,0.3255,0.2824,0.2181,-4.07],[0.5188,0.324,0.2824,0.2181,-4.16],[0.5185,0.3164,0.2824,0.2181,-4.23],[0.5181,0.3087,0.2824,0.2181,-4.3],[0.5176,0.3021,0.2824,0.2181,-4.35],[0.5171,0.2974,0.2824,0.2181,-4.39],[0.5168,0.2945,0.2824,0.2181,-4.44],[0.5168,0.2936,0.2824,0.2181,-4.47],[0.5168,0.2934,0.2824,0.2181,-4.43],[0.5171,0.2938,0.2824,0.2181,-4.29],[0.5177,0.2993,0.2824,0.2181,-4.04],[0.5184,0.3079,0.2824,0.2181,-3.68],[0.5197,0.3182,0.2824,0.2181,-3.22],[0.5216,0.3293,0.2824,0.2181,-2.73],[0.5241,0.3399,0.2824,0.2181,-2.22],[0.527,0.3453,0.2824,0.2181,-1.78],[0.5304,0.347,0.2824,0.2181,-1.41],[0.5338,0.3465,0.2824,0.2181,-1.13],[0.5371,0.3445,0.2824,0.2181,-0.93],[0.5396,0.3425,0.2824,0.2181,-0.84],[0.5414,0.3406,0.2824,0.2181,-0.76],[0.5425,0.3393,0.2824,0.2181,-0.7],[0.5431,0.3385,0.2824,0.2181,-0.66],[0.543,0.3385,0.2824,0.2181,-0.63],[0.543,0.3385,0.2824,0.2181,-0.61],[0.543,0.3385,0.2824,0.2181,-0.6],[0.5429,0.3385,0.2824,0.2181,-0.59],[0.5429,0.3385,0.2824,0.2181,-0.58],[0.5429,0.3385,0.2824,0.2181,-0.57],[0.5429,0.3384,0.2824,0.2181,-0.57],[0.5429,0.3384,0.2824,0.2181,-0.56],[0.5429,0.3384,0.2824,0.2181,-0.57],[0.543,0.3384,0.2824,0.2181,-0.57],[0.543,0.3384,0.2824,0.2181,-0.58],[0.543,0.3384,0.2824,0.2181,-0.59],[0.543,0.3384,0.2824,0.2181,-0.6],[0.543,0.3384,0.2824,0.2181,-0.61],[0.543,0.3384,0.2824,0.2181,-0.62],[0.543,0.3384,0.2824,0.2181,-0.63],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.65],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.65],[0.543,0.3384,0.2824,0.2181,-0.65],[0.543,0.3384,0.2824,0.2181,-0.66],[0.543,0.3384,0.2824,0.2181,-0.66],[0.543,0.3384,0.2824,0.2181,-0.67],[0.543,0.3384,0.2824,0.2181,-0.68],[0.543,0.3384,0.2824,0.2181,-0.68],[0.543,0.3384,0.2824,0.2181,-0.69],[0.543,0.3384,0.2824,0.2181,-0.69],[0.543,0.3384,0.2824,0.2181,-0.68],[0.543,0.3384,0.2824,0.2181,-0.68],[0.543,0.3384,0.2824,0.2181,-0.67],[0.543,0.3384,0.2824,0.2181,-0.66],[0.543,0.3384,0.2824,0.2181,-0.65],[0.543,0.3384,0.2824,0.2181,-0.65],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.64]],"reach":[0.1216,0.03,0.8784,0.92],"script":[[0,"happy",0,0],[0.5,"blink",0,0],[0.54,"happy",0,0]]}}} \ No newline at end of file +{"schema":"echo-character","version":1,"note":"Echo stills and motion clips with a blank screen (the face is drawn in code). frames: stills, with the screen box as fractions of the frame, and reach (the still's extent). clips: alpha-packed H.264 (colour above, alpha below), one keyframe, with the screen tracked per frame as [centre x, centre y, width, height, tilt degrees], and reach (everything the clip covers); optionally a face script ([share of the clip, face, gaze x, gaze y] from that point on), frames with a blink, and lens frames ([centre x, centre y, glass radius, scale, blink]: one big eye shows through a magnifier, through the glass band). sequences: steps (a still with face and hold in ms, 0 = rest forever, or a clip) played once, then an optional loop.","frames":{"front-rest":{"screen":[0.3516,0.2344,0.6484,0.4688],"reach":[0.2539,0.1523,0.7461,0.8848]},"t1-rest":{"screen":[0.4387,0.3649,0.6566,0.565],"reach":[0.293,0.3008,0.7051,0.9199]},"ta-rest":{"screen":[0.4387,0.3622,0.6566,0.5676],"reach":[0.293,0.2969,0.707,0.9316]},"tb-rest":{"screen":[0.4387,0.3614,0.6566,0.5685],"reach":[0.2949,0.2949,0.709,0.9316]},"account-idle":{"screen":[0.3542,0.2396,0.6458,0.4688],"reach":[0.2552,0.1536,0.7448,0.8828]},"account-cloud":{"screen":[0.4896,0.2188,0.7812,0.4479],"reach":[0.388,0.138,0.8828,0.8672]},"account-cloud-hug":{"screen":[0.3542,0.2188,0.6458,0.4479],"reach":[0.2526,0.1354,0.7448,0.8672]},"account-comedy":{"screen":[0.3646,0.2396,0.6458,0.4583],"reach":[0.2552,0.1484,0.75,0.8802]},"account-truecrime":{"screen":[0.3646,0.2083,0.6562,0.4375],"reach":[0.1745,0.0443,0.7682,0.901]},"account-science":{"screen":[0.375,0.2292,0.6667,0.4479],"reach":[0.237,0.0911,0.7656,0.8698]},"account-history":{"screen":[0.3854,0.2396,0.6771,0.4688],"reach":[0.2839,0.1042,0.7786,0.8828]},"account-business":{"screen":[0.2604,0.2292,0.5417,0.4479],"reach":[0.1536,0.1354,0.6589,0.8672]},"account-wellness":{"screen":[0.375,0.2292,0.6562,0.4479],"reach":[0.1823,0.1224,0.8464,0.8698]},"account-fiction":{"screen":[0.4479,0.25,0.7292,0.4792],"reach":[0.3411,0.0755,0.8359,0.875]},"story-hourglass-rest":{"screen":[0.3315,0.156,0.644,0.4013],"reach":[0.1914,0.041,0.791,0.9199]},"story-business-rest":{"screen":[0.3239,0.171,0.6338,0.415],"reach":[0.1895,0.0605,0.7832,0.9199]},"story-celebrate-rest":{"screen":[0.3872,0.406,0.5851,0.565],"reach":[0.2188,0.252,0.7656,0.9199]},"story-comedy-rest":{"screen":[0.335,0.1823,0.6368,0.423],"reach":[0.2012,0.0742,0.7734,0.9199]},"story-daytime-rest":{"screen":[0.361,0.2119,0.6491,0.4408],"reach":[0.2305,0.041,0.7852,0.918]},"story-detective-rest":{"screen":[0.3488,0.2143,0.6197,0.4334],"reach":[0.1348,0.0352,0.75,0.9199]},"story-fiction-rest":{"screen":[0.3566,0.2356,0.6488,0.4637],"reach":[0.2285,0.0293,0.7812,0.9199]},"story-health-rest":{"screen":[0.3684,0.2718,0.636,0.4811],"reach":[0.1641,0.1602,0.8398,0.9199]},"story-history-rest":{"screen":[0.3485,0.1911,0.6494,0.4278],"reach":[0.2168,0.0312,0.7832,0.9199]},"story-morning-rest":{"screen":[0.3398,0.1511,0.657,0.4009],"reach":[0.2031,0.0371,0.7988,0.9199]},"story-news-rest":{"screen":[0.3609,0.2136,0.6451,0.4297],"reach":[0.2344,0.0312,0.7715,0.9199]},"story-night-rest":{"screen":[0.3056,0.2322,0.5692,0.4399],"reach":[0.1895,0.0312,0.7441,0.916]},"story-science-rest":{"screen":[0.3518,0.1918,0.648,0.428],"reach":[0.1836,0.0312,0.7812,0.918]},"story-tech-rest":{"screen":[0.4099,0.2432,0.6818,0.4576],"reach":[0.1895,0.0586,0.8086,0.9121]},"story-trophy-rest":{"screen":[0.4018,0.2294,0.6842,0.4475],"reach":[0.2734,0.127,0.8379,0.9199]}},"sequences":{"welcome":[{"frame":"front-rest","face":"idle","ms":0}],"onboarding-welcome":{"steps":[{"clip":"wave"},{"frame":"t1-rest","face":"idle","ms":0}]},"survey-ask":{"steps":[{"clip":"ask"},{"frame":"t1-rest","face":"idle","ms":0}]},"survey-notes":{"steps":[{"clip":"notebook"},{"frame":"ta-rest","face":"idle","ms":0}]},"survey-think":{"steps":[{"clip":"think-chin"},{"frame":"t1-rest","face":"curious","ms":0}]},"survey-idea":{"steps":[{"clip":"magnifier"},{"frame":"ta-rest","face":"curious","ms":0}]},"feedback-sent":{"steps":[{"clip":"magnifier"},{"frame":"ta-rest","face":"happy","ms":0}]},"survey-thanks-calm":{"steps":[{"frame":"t1-rest","face":"idle","ms":0}]},"survey-thanks":{"steps":[{"clip":"thumbs-up"},{"frame":"t1-rest","face":"happy","ms":0}]},"survey-celebrate":{"steps":[{"clip":"story-celebrate"},{"frame":"story-celebrate-rest","face":"happy","ms":0}]},"onboarding-question":{"steps":[{"clip":"ask"},{"frame":"t1-rest","face":"idle","ms":0}]},"onboarding-thinking":{"steps":[{"clip":"thumbs-up"}],"loop":[{"frame":"t1-rest","face":"idle","ms":1400},{"clip":"notebook"},{"frame":"t1-rest","face":"idle","ms":1400},{"clip":"think-chin"}]},"onboarding-preparing":{"steps":[{"frame":"t1-rest","face":"idle","ms":400}],"loop":[{"clip":"cards-shuffle"},{"frame":"t1-rest","face":"idle","ms":1200},{"clip":"magnifier"},{"frame":"t1-rest","face":"idle","ms":1200}]},"onboarding-presenting":{"steps":[{"clip":"hop"},{"frame":"t1-rest","face":"idle","ms":0}]},"onboarding-ready":{"steps":[{"clip":"cards-fan"},{"frame":"t1-rest","face":"idle","ms":0}]},"onboarding-rest":{"steps":[{"frame":"t1-rest","face":"idle","ms":0}]},"account-cloud":{"steps":[{"frame":"account-cloud","face":"idle","ms":0}]},"account-syncing":{"steps":[{"frame":"account-cloud-hug","face":"idle","ms":0}]},"account-outfit-idle":{"steps":[{"frame":"account-idle","face":"idle","ms":0}]},"account-outfit-comedy":{"steps":[{"frame":"account-comedy","face":"idle","ms":0}]},"account-outfit-truecrime":{"steps":[{"frame":"account-truecrime","face":"idle","ms":0}]},"account-outfit-science":{"steps":[{"frame":"account-science","face":"idle","ms":0}]},"account-outfit-history":{"steps":[{"frame":"account-history","face":"idle","ms":0}]},"account-outfit-business":{"steps":[{"frame":"account-business","face":"idle","ms":0}]},"account-outfit-wellness":{"steps":[{"frame":"account-wellness","face":"idle","ms":0}]},"account-outfit-fiction":{"steps":[{"frame":"account-fiction","face":"idle","ms":0}]},"import-pick":{"steps":[{"clip":"wave"},{"frame":"t1-rest","face":"idle","ms":0}]},"import-working":{"steps":[{"frame":"t1-rest","face":"idle","ms":300}],"loop":[{"clip":"cards-shuffle"},{"frame":"t1-rest","face":"idle","ms":900}]},"import-done":{"steps":[{"clip":"hop"},{"frame":"t1-rest","face":"idle","ms":0}]},"import-failed":{"steps":[{"frame":"t1-rest","face":"curious","ms":0}]},"story-hello":{"steps":[{"clip":"wave"},{"frame":"t1-rest","face":"idle","ms":0}]},"story-hop":{"steps":[{"clip":"hop"},{"frame":"tb-rest","face":"idle","ms":0}]},"story-business":{"steps":[{"clip":"story-business"},{"frame":"story-business-rest","face":"idle","ms":0}]},"story-celebrate":{"steps":[{"clip":"story-celebrate"},{"frame":"story-celebrate-rest","face":"idle","ms":0}]},"story-comedy":{"steps":[{"clip":"story-comedy"},{"frame":"story-comedy-rest","face":"idle","ms":0}]},"story-daytime":{"steps":[{"clip":"story-daytime"},{"frame":"story-daytime-rest","face":"idle","ms":0}]},"story-detective":{"steps":[{"clip":"story-detective"},{"frame":"story-detective-rest","face":"idle","ms":0}]},"story-fiction":{"steps":[{"clip":"story-fiction"},{"frame":"story-fiction-rest","face":"idle","ms":0}]},"story-health":{"steps":[{"clip":"story-health"},{"frame":"story-health-rest","face":"idle","ms":0}]},"story-history":{"steps":[{"clip":"story-history"},{"frame":"story-history-rest","face":"idle","ms":0}]},"story-hourglass":{"steps":[{"clip":"story-hourglass"},{"frame":"story-hourglass-rest","face":"idle","ms":0}]},"story-morning":{"steps":[{"clip":"story-morning"},{"frame":"story-morning-rest","face":"idle","ms":0}]},"story-news":{"steps":[{"clip":"story-news"},{"frame":"story-news-rest","face":"idle","ms":0}]},"story-night":{"steps":[{"clip":"story-night"},{"frame":"story-night-rest","face":"idle","ms":0}]},"story-science":{"steps":[{"clip":"story-science"},{"frame":"story-science-rest","face":"idle","ms":0}]},"story-tech":{"steps":[{"clip":"story-tech"},{"frame":"story-tech-rest","face":"idle","ms":0}]},"story-trophy":{"steps":[{"clip":"story-trophy"},{"frame":"story-trophy-rest","face":"idle","ms":0}]},"story-entry":{"steps":[{"frame":"front-rest","face":"happy","ms":0}]},"story-hello-still":{"steps":[{"frame":"t1-rest","face":"idle","ms":0}]},"story-hourglass-still":{"steps":[{"frame":"story-hourglass-rest","face":"idle","ms":0}]},"story-business-still":{"steps":[{"frame":"story-business-rest","face":"idle","ms":0}]},"story-celebrate-still":{"steps":[{"frame":"story-celebrate-rest","face":"idle","ms":0}]},"story-comedy-still":{"steps":[{"frame":"story-comedy-rest","face":"idle","ms":0}]},"story-daytime-still":{"steps":[{"frame":"story-daytime-rest","face":"idle","ms":0}]},"story-detective-still":{"steps":[{"frame":"story-detective-rest","face":"idle","ms":0}]},"story-fiction-still":{"steps":[{"frame":"story-fiction-rest","face":"idle","ms":0}]},"story-health-still":{"steps":[{"frame":"story-health-rest","face":"idle","ms":0}]},"story-history-still":{"steps":[{"frame":"story-history-rest","face":"idle","ms":0}]},"story-morning-still":{"steps":[{"frame":"story-morning-rest","face":"idle","ms":0}]},"story-news-still":{"steps":[{"frame":"story-news-rest","face":"idle","ms":0}]},"story-night-still":{"steps":[{"frame":"story-night-rest","face":"idle","ms":0}]},"story-science-still":{"steps":[{"frame":"story-science-rest","face":"idle","ms":0}]},"story-tech-still":{"steps":[{"frame":"story-tech-rest","face":"idle","ms":0}]},"story-trophy-still":{"steps":[{"frame":"story-trophy-rest","face":"idle","ms":0}]}},"clips":{"think-chin":{"file":"clips/think-chin.mp4","fps":24,"frames":107,"size":384,"face":"curious","tracks":[[0.5481,0.4664,0.2174,0.2001,-3.16],[0.5479,0.467,0.2175,0.2,-3.12],[0.5477,0.4676,0.2175,0.2,-3.0],[0.5471,0.4687,0.2175,0.1999,-2.86],[0.5462,0.4696,0.2175,0.1999,-2.68],[0.5451,0.4701,0.2174,0.1999,-2.48],[0.5437,0.47,0.2174,0.1997,-2.19],[0.5423,0.4692,0.2172,0.1997,-1.99],[0.5409,0.4681,0.2171,0.1997,-1.74],[0.5399,0.4669,0.2171,0.1995,-1.51],[0.5391,0.4659,0.217,0.1996,-1.4],[0.5387,0.4652,0.217,0.1995,-1.36],[0.5388,0.4651,0.217,0.1995,-1.37],[0.5393,0.4653,0.217,0.1995,-1.52],[0.5403,0.4659,0.2172,0.1997,-1.71],[0.5417,0.4669,0.2174,0.1996,-1.96],[0.5435,0.4681,0.2175,0.1998,-2.34],[0.5454,0.4693,0.2177,0.1999,-2.72],[0.5476,0.4703,0.2179,0.1999,-3.05],[0.5496,0.4706,0.2183,0.2,-3.31],[0.5516,0.4703,0.2186,0.2,-3.49],[0.5533,0.4694,0.219,0.2001,-3.57],[0.555,0.4682,0.2192,0.2,-3.65],[0.5563,0.467,0.2194,0.2001,-3.71],[0.5573,0.466,0.2194,0.2002,-3.77],[0.558,0.4653,0.2194,0.2003,-3.82],[0.5584,0.4651,0.2192,0.2002,-3.82],[0.5583,0.4652,0.2193,0.2003,-3.78],[0.558,0.4658,0.2191,0.2001,-3.74],[0.5574,0.4667,0.219,0.2001,-3.62],[0.5566,0.468,0.2188,0.2,-3.51],[0.5558,0.4697,0.2185,0.1999,-3.41],[0.555,0.4716,0.2182,0.1997,-3.23],[0.5542,0.4733,0.2181,0.1997,-3.05],[0.5536,0.4748,0.2178,0.1995,-3.0],[0.5528,0.4757,0.2177,0.1994,-2.96],[0.5517,0.4759,0.2177,0.1994,-2.96],[0.5502,0.4752,0.2179,0.1996,-3.07],[0.5483,0.4737,0.2182,0.1997,-3.24],[0.546,0.4716,0.2186,0.1998,-3.46],[0.5434,0.4691,0.2191,0.2001,-3.68],[0.5408,0.4665,0.2198,0.2004,-3.95],[0.5385,0.464,0.2207,0.2006,-4.21],[0.5366,0.462,0.2214,0.2008,-4.43],[0.5351,0.4605,0.2219,0.201,-4.64],[0.5341,0.4594,0.222,0.2011,-4.83],[0.5334,0.4587,0.222,0.2011,-4.95],[0.533,0.4584,0.2218,0.2011,-5.04],[0.5327,0.4582,0.2216,0.201,-5.08],[0.5326,0.4581,0.2215,0.201,-5.08],[0.5327,0.4583,0.2215,0.2011,-5.11],[0.5328,0.4588,0.2215,0.201,-5.07],[0.5331,0.4597,0.2215,0.2011,-5.06],[0.5334,0.4608,0.2215,0.2011,-5.06],[0.534,0.4621,0.2213,0.2011,-5.05],[0.5345,0.4633,0.2211,0.201,-5.06],[0.5349,0.4642,0.2209,0.2011,-5.1],[0.5351,0.4646,0.2207,0.201,-5.1],[0.535,0.4645,0.2207,0.2011,-5.09],[0.5347,0.4638,0.2209,0.2011,-5.07],[0.5342,0.4626,0.2211,0.2011,-5.1],[0.5337,0.4613,0.2212,0.2011,-5.13],[0.5331,0.46,0.2215,0.2011,-5.11],[0.5327,0.4589,0.2216,0.201,-5.12],[0.5326,0.4583,0.2215,0.201,-5.17],[0.5326,0.4582,0.2216,0.201,-5.12],[0.5327,0.4587,0.2217,0.2011,-5.11],[0.533,0.4596,0.2215,0.2012,-5.13],[0.5334,0.4607,0.2213,0.2012,-5.16],[0.5337,0.462,0.2214,0.2012,-5.1],[0.534,0.463,0.2215,0.2011,-5.05],[0.5342,0.4636,0.2215,0.201,-5.01],[0.5342,0.4637,0.2215,0.2011,-5.02],[0.5341,0.4633,0.2215,0.201,-4.99],[0.5339,0.4623,0.2214,0.2012,-5.07],[0.5335,0.4611,0.2213,0.2012,-5.14],[0.5331,0.46,0.2215,0.2012,-5.15],[0.5328,0.4591,0.2216,0.2011,-5.13],[0.5326,0.4584,0.2217,0.2011,-5.13],[0.5326,0.4583,0.2217,0.2011,-5.13],[0.5327,0.4585,0.2216,0.2012,-5.15],[0.533,0.4592,0.2215,0.2012,-5.14],[0.5334,0.4601,0.2214,0.2013,-5.1],[0.5338,0.4612,0.2215,0.2013,-5.09],[0.5341,0.4622,0.2215,0.2013,-5.05],[0.5343,0.4629,0.2217,0.2012,-4.99],[0.5344,0.463,0.2217,0.2012,-4.98],[0.5343,0.4627,0.2217,0.2013,-5.06],[0.5341,0.4619,0.2216,0.2013,-5.1],[0.5338,0.4609,0.2215,0.2013,-5.16],[0.5336,0.4599,0.2214,0.2013,-5.21],[0.5335,0.459,0.2212,0.2013,-5.26],[0.5335,0.4585,0.2211,0.2012,-5.21],[0.5338,0.4582,0.221,0.2011,-5.15],[0.5344,0.4584,0.2209,0.2011,-5.04],[0.5354,0.4588,0.2206,0.201,-4.88],[0.5366,0.4595,0.2204,0.201,-4.66],[0.5381,0.4603,0.2201,0.2008,-4.39],[0.5397,0.4613,0.2197,0.2007,-4.13],[0.5413,0.4623,0.2194,0.2005,-3.8],[0.5429,0.4632,0.2192,0.2004,-3.49],[0.5444,0.4641,0.219,0.2002,-3.22],[0.5457,0.4647,0.2185,0.2002,-3.14],[0.5467,0.4652,0.2183,0.2001,-3.03],[0.5475,0.4655,0.2179,0.2001,-3.02],[0.548,0.4656,0.2177,0.2002,-3.06],[0.5483,0.4657,0.2175,0.2002,-3.12]],"rest":"t1-rest","mask":true,"reach":[0.2917,0.2969,0.7109,0.9219]},"wave":{"file":"clips/wave.mp4","fps":24,"frames":61,"size":384,"face":"happy","tracks":[[0.5483,0.4658,0.2174,0.2001,-3.16],[0.5484,0.4661,0.2174,0.2,-3.11],[0.5488,0.4665,0.2173,0.2,-3.03],[0.5494,0.4671,0.2173,0.1998,-2.84],[0.5504,0.4678,0.2175,0.1997,-2.51],[0.5516,0.4683,0.2176,0.1995,-2.09],[0.553,0.4685,0.2177,0.1995,-1.61],[0.5544,0.4681,0.218,0.1995,-1.01],[0.5558,0.4671,0.2182,0.1996,-0.39],[0.5571,0.4657,0.2183,0.1996,0.2],[0.5583,0.4639,0.2184,0.1997,0.79],[0.5595,0.4621,0.2186,0.1995,1.18],[0.5607,0.4604,0.2184,0.1995,1.63],[0.5617,0.459,0.2182,0.1995,1.98],[0.5626,0.458,0.2181,0.1995,2.17],[0.5632,0.4574,0.2179,0.1993,2.26],[0.5636,0.4571,0.2177,0.1994,2.44],[0.5636,0.4569,0.2178,0.1993,2.45],[0.5632,0.4569,0.2182,0.1993,2.43],[0.5628,0.4568,0.2186,0.1993,2.47],[0.5622,0.4568,0.219,0.1993,2.51],[0.5619,0.4568,0.2194,0.1993,2.51],[0.5618,0.4568,0.2194,0.1993,2.51],[0.5621,0.4568,0.2191,0.1993,2.51],[0.5628,0.4568,0.2184,0.1993,2.51],[0.5637,0.4569,0.2176,0.1993,2.5],[0.5645,0.4569,0.2168,0.1994,2.49],[0.5649,0.457,0.2164,0.1994,2.49],[0.5649,0.457,0.2164,0.1994,2.48],[0.5645,0.4569,0.2168,0.1994,2.46],[0.5637,0.4569,0.2176,0.1993,2.44],[0.5628,0.4568,0.2183,0.1993,2.41],[0.562,0.4568,0.219,0.1993,2.38],[0.5616,0.4567,0.2193,0.1993,2.35],[0.5616,0.4567,0.2193,0.1993,2.35],[0.562,0.4568,0.2189,0.1993,2.35],[0.5626,0.4568,0.2183,0.1994,2.36],[0.5634,0.4569,0.2177,0.1994,2.38],[0.564,0.4569,0.2172,0.1994,2.4],[0.5643,0.457,0.2169,0.1994,2.4],[0.5642,0.457,0.217,0.1994,2.42],[0.5638,0.457,0.2174,0.1994,2.41],[0.5633,0.4569,0.2179,0.1994,2.4],[0.5627,0.457,0.2183,0.1995,2.38],[0.5621,0.4571,0.2187,0.1996,2.34],[0.5616,0.4574,0.2188,0.1995,2.18],[0.5611,0.4578,0.2188,0.1995,1.99],[0.5605,0.4583,0.2186,0.1996,1.72],[0.5597,0.4591,0.2184,0.1995,1.34],[0.5586,0.46,0.2181,0.1995,0.86],[0.5574,0.4608,0.2178,0.1996,0.37],[0.556,0.4618,0.2176,0.1997,-0.21],[0.5546,0.4627,0.2175,0.1997,-0.83],[0.5531,0.4634,0.2173,0.1998,-1.41],[0.5517,0.4641,0.2174,0.1999,-1.89],[0.5505,0.4646,0.2174,0.1999,-2.33],[0.5496,0.465,0.2173,0.2,-2.67],[0.5489,0.4653,0.2173,0.2,-2.91],[0.5485,0.4655,0.2174,0.2001,-3.05],[0.5484,0.4656,0.2173,0.2001,-3.17],[0.5483,0.4656,0.2173,0.2001,-3.19]],"rest":"t1-rest","mask":true,"reach":[0.2943,0.2995,0.7109,0.9219]},"ask":{"file":"clips/ask.mp4","fps":24,"frames":56,"size":384,"face":"idle","tracks":[[0.5483,0.4656,0.2173,0.2001,-3.18],[0.5483,0.4657,0.2174,0.2001,-3.17],[0.5485,0.4661,0.2174,0.2,-3.09],[0.5488,0.4667,0.2176,0.1999,-2.92],[0.5493,0.4675,0.2177,0.1998,-2.71],[0.5501,0.4684,0.218,0.1996,-2.41],[0.5508,0.4694,0.2183,0.1994,-2.01],[0.5516,0.47,0.2186,0.1992,-1.58],[0.5523,0.4702,0.2189,0.1992,-1.18],[0.5528,0.4698,0.2194,0.1993,-0.69],[0.5531,0.4692,0.2198,0.1993,-0.24],[0.5534,0.4684,0.2202,0.1993,0.11],[0.5535,0.4676,0.2205,0.1993,0.38],[0.5536,0.4671,0.2208,0.1992,0.59],[0.5536,0.4667,0.2209,0.1991,0.68],[0.5536,0.4665,0.221,0.1992,0.73],[0.5536,0.4664,0.221,0.1993,0.76],[0.5536,0.4662,0.221,0.1995,0.78],[0.5536,0.4661,0.221,0.1997,0.78],[0.5536,0.4661,0.221,0.1997,0.77],[0.5536,0.4661,0.221,0.1997,0.76],[0.5536,0.4661,0.221,0.1997,0.76],[0.5536,0.4661,0.221,0.1997,0.76],[0.5536,0.4661,0.221,0.1997,0.76],[0.5536,0.4661,0.221,0.1997,0.77],[0.5536,0.4661,0.221,0.1997,0.77],[0.5536,0.4661,0.221,0.1997,0.77],[0.5536,0.4661,0.221,0.1997,0.78],[0.5536,0.4661,0.221,0.1997,0.78],[0.5536,0.4661,0.221,0.1997,0.78],[0.5536,0.4661,0.221,0.1997,0.79],[0.5536,0.4661,0.221,0.1997,0.78],[0.5536,0.4661,0.221,0.1997,0.78],[0.5536,0.4661,0.221,0.1997,0.77],[0.5536,0.4661,0.2209,0.1997,0.76],[0.5536,0.4661,0.2209,0.1997,0.75],[0.5536,0.4661,0.2209,0.1997,0.75],[0.5535,0.4661,0.2209,0.1997,0.75],[0.5535,0.4662,0.2209,0.1995,0.71],[0.5534,0.4664,0.2209,0.1994,0.68],[0.5533,0.4665,0.2209,0.1992,0.63],[0.5531,0.4666,0.2208,0.199,0.52],[0.5529,0.4668,0.2207,0.1988,0.34],[0.5526,0.4668,0.2204,0.199,0.1],[0.5522,0.4667,0.2201,0.1992,-0.23],[0.5518,0.4667,0.2197,0.1993,-0.62],[0.5513,0.4666,0.2194,0.1995,-1.02],[0.5508,0.4665,0.219,0.1997,-1.41],[0.5503,0.4664,0.2186,0.1997,-1.75],[0.5499,0.4664,0.2184,0.1996,-2.03],[0.5494,0.4663,0.2181,0.1997,-2.35],[0.549,0.4661,0.2179,0.1997,-2.61],[0.5487,0.466,0.2177,0.1998,-2.83],[0.5484,0.4659,0.2176,0.1999,-3.01],[0.5483,0.4658,0.2176,0.2,-3.15],[0.5482,0.4657,0.2176,0.2001,-3.19]],"rest":"t1-rest","mask":true,"reach":[0.2943,0.2995,0.7109,0.9219]},"thumbs-up":{"file":"clips/thumbs-up.mp4","fps":24,"frames":59,"size":384,"face":"happy","tracks":[[0.5483,0.4657,0.2179,0.2001,-3.13],[0.5483,0.4657,0.2178,0.2001,-3.13],[0.5484,0.4659,0.2178,0.2,-3.07],[0.5486,0.4664,0.2178,0.2,-2.97],[0.5492,0.4676,0.2179,0.1999,-2.77],[0.5502,0.4695,0.218,0.1998,-2.47],[0.5515,0.4722,0.2181,0.1996,-2.02],[0.5532,0.4751,0.2184,0.1996,-1.5],[0.5549,0.4778,0.2186,0.1995,-0.9],[0.5564,0.4792,0.2189,0.1993,-0.35],[0.5572,0.4787,0.219,0.199,0.04],[0.5573,0.4757,0.2191,0.1992,0.21],[0.5567,0.4713,0.219,0.1993,0.15],[0.5556,0.4661,0.2189,0.1994,-0.11],[0.5542,0.4611,0.2187,0.1996,-0.47],[0.5529,0.4572,0.2186,0.1998,-0.77],[0.5519,0.4547,0.2185,0.1997,-1.01],[0.5513,0.4534,0.2184,0.1996,-1.13],[0.5511,0.4528,0.2183,0.1995,-1.17],[0.551,0.4527,0.2183,0.1995,-1.15],[0.5515,0.4549,0.2184,0.1994,-1.04],[0.5523,0.4588,0.2185,0.1995,-0.85],[0.5534,0.4638,0.2187,0.1996,-0.65],[0.5548,0.4689,0.2189,0.1998,-0.31],[0.5563,0.4738,0.2192,0.1999,0.1],[0.5575,0.4755,0.2192,0.2001,0.47],[0.5584,0.4747,0.2193,0.2003,0.78],[0.559,0.4723,0.2192,0.2005,1.02],[0.5594,0.4691,0.219,0.2006,1.14],[0.5595,0.4662,0.2188,0.2006,1.17],[0.5597,0.4645,0.2186,0.2005,1.13],[0.5599,0.4641,0.2184,0.2004,1.11],[0.5603,0.4647,0.2182,0.2003,1.15],[0.5608,0.4663,0.2181,0.2002,1.18],[0.5613,0.4679,0.218,0.2001,1.27],[0.5619,0.4692,0.2179,0.2002,1.37],[0.5623,0.47,0.2179,0.2,1.43],[0.5626,0.4701,0.2179,0.1999,1.49],[0.5628,0.4696,0.2178,0.2,1.53],[0.5628,0.469,0.2178,0.1999,1.53],[0.5627,0.4684,0.2178,0.1999,1.52],[0.5627,0.4679,0.2178,0.2,1.52],[0.5626,0.4677,0.2178,0.2,1.48],[0.5625,0.4676,0.2178,0.1999,1.47],[0.5625,0.4676,0.2178,0.1999,1.46],[0.5625,0.4676,0.2178,0.1999,1.46],[0.5625,0.4676,0.2178,0.1999,1.46],[0.5625,0.4676,0.2178,0.1999,1.46],[0.5625,0.4676,0.2178,0.1999,1.47],[0.5625,0.4676,0.2179,0.1999,1.47],[0.5625,0.4676,0.2179,0.1999,1.48],[0.5625,0.4676,0.2178,0.1999,1.45],[0.5622,0.4677,0.2178,0.1999,1.36],[0.5616,0.4678,0.2178,0.1999,1.17],[0.5605,0.4679,0.2178,0.2,0.8],[0.5585,0.468,0.218,0.1999,0.26],[0.5557,0.468,0.2184,0.1999,-0.54],[0.5521,0.4678,0.2192,0.1999,-1.46],[0.5476,0.4675,0.2204,0.2,-2.42]],"rest":"t1-rest","mask":true,"reach":[0.2943,0.2995,0.7109,0.9219]},"notebook":{"file":"clips/notebook.mp4","fps":24,"frames":107,"size":384,"face":"idle","tracks":[[0.5478,0.4639,0.2178,0.2054,-3.5],[0.5478,0.4639,0.2179,0.2054,-3.5],[0.5479,0.464,0.2179,0.2055,-3.51],[0.5481,0.4643,0.2179,0.2055,-3.47],[0.5484,0.465,0.218,0.2055,-3.36],[0.5491,0.4659,0.2178,0.2054,-3.3],[0.5506,0.4678,0.2174,0.2051,-3.13],[0.5526,0.4703,0.2168,0.2045,-2.9],[0.555,0.4733,0.2159,0.2035,-2.67],[0.5578,0.4765,0.2148,0.2026,-2.45],[0.5605,0.4798,0.2138,0.2016,-2.13],[0.5625,0.4823,0.213,0.2006,-1.89],[0.5641,0.4844,0.2124,0.1999,-1.66],[0.5651,0.4858,0.2123,0.1994,-1.41],[0.5655,0.4869,0.2129,0.199,-1.37],[0.5655,0.4877,0.2137,0.1988,-1.19],[0.5653,0.4884,0.2148,0.1987,-0.86],[0.5647,0.4893,0.2166,0.1986,-0.73],[0.5642,0.4903,0.2183,0.1985,-0.43],[0.5636,0.4915,0.2202,0.1982,-0.05],[0.563,0.493,0.2221,0.1978,0.22],[0.5626,0.4945,0.2241,0.1973,0.51],[0.5626,0.4958,0.2253,0.1964,1.1],[0.5625,0.4969,0.2266,0.1957,1.5],[0.5627,0.4976,0.2271,0.195,1.96],[0.5629,0.498,0.2274,0.1944,2.35],[0.563,0.4982,0.2276,0.194,2.58],[0.563,0.4983,0.2277,0.1938,2.66],[0.5631,0.4983,0.2278,0.1938,2.65],[0.5634,0.4983,0.2278,0.1937,2.73],[0.5638,0.4983,0.2274,0.1936,2.76],[0.564,0.4983,0.2274,0.1936,2.76],[0.564,0.4984,0.2275,0.1937,2.7],[0.5636,0.4984,0.228,0.1938,2.68],[0.5629,0.4985,0.2285,0.194,2.59],[0.562,0.4985,0.2294,0.194,2.56],[0.5617,0.4985,0.2296,0.1941,2.57],[0.5618,0.4985,0.2294,0.1939,2.69],[0.5627,0.4984,0.2287,0.1938,2.78],[0.5637,0.4984,0.2278,0.1937,2.81],[0.5642,0.4985,0.2272,0.1938,2.76],[0.564,0.4986,0.2274,0.1939,2.71],[0.5635,0.4987,0.2278,0.1941,2.62],[0.5626,0.4987,0.2285,0.1942,2.59],[0.5617,0.4986,0.2293,0.1941,2.63],[0.5613,0.4985,0.2297,0.1939,2.72],[0.5612,0.4985,0.2298,0.1939,2.73],[0.5613,0.4986,0.2298,0.1939,2.69],[0.5616,0.4987,0.2294,0.1941,2.55],[0.562,0.4989,0.2291,0.1944,2.38],[0.5623,0.499,0.2286,0.1947,2.24],[0.5626,0.4991,0.2282,0.1949,2.14],[0.5625,0.4991,0.2282,0.1949,2.12],[0.5617,0.4991,0.2291,0.1948,2.19],[0.5608,0.4991,0.2298,0.1949,2.26],[0.5594,0.4991,0.231,0.195,2.25],[0.5579,0.4992,0.2321,0.1952,2.33],[0.5564,0.4994,0.2332,0.1955,2.27],[0.5553,0.4995,0.2338,0.1957,2.21],[0.5545,0.4995,0.2347,0.1957,2.15],[0.554,0.4995,0.235,0.1957,2.15],[0.5537,0.4995,0.2354,0.1957,2.04],[0.5537,0.4995,0.2354,0.1958,2.0],[0.5537,0.4996,0.2354,0.1959,1.94],[0.5537,0.4996,0.2355,0.1959,1.9],[0.5537,0.4996,0.2355,0.196,1.87],[0.5538,0.4997,0.2356,0.196,1.85],[0.5538,0.4997,0.2356,0.196,1.84],[0.5538,0.4997,0.2356,0.1961,1.82],[0.5538,0.4997,0.2356,0.1962,1.77],[0.5538,0.4998,0.2356,0.1963,1.71],[0.5537,0.4998,0.2356,0.1963,1.67],[0.5537,0.4998,0.2356,0.1964,1.62],[0.5537,0.4996,0.2356,0.1965,1.57],[0.5536,0.4992,0.2356,0.1967,1.49],[0.5536,0.4985,0.2355,0.197,1.41],[0.5535,0.4977,0.2353,0.1974,1.3],[0.5535,0.4966,0.235,0.1978,1.19],[0.5537,0.4954,0.234,0.1981,1.27],[0.5538,0.4943,0.233,0.1985,1.27],[0.554,0.4933,0.2319,0.1989,1.18],[0.5542,0.4924,0.2309,0.1993,1.02],[0.5544,0.4916,0.2299,0.1996,0.79],[0.5546,0.4906,0.229,0.1999,0.59],[0.5548,0.4892,0.2279,0.2002,0.39],[0.5548,0.4873,0.2266,0.2007,0.12],[0.5546,0.4848,0.2251,0.2013,-0.17],[0.5542,0.4817,0.2235,0.202,-0.49],[0.5534,0.4782,0.2223,0.2029,-1.09],[0.5524,0.4749,0.2213,0.2037,-1.69],[0.5514,0.4718,0.2204,0.2044,-2.2],[0.5504,0.4691,0.2198,0.2049,-2.63],[0.5494,0.467,0.2192,0.2052,-2.95],[0.5487,0.4655,0.2188,0.2055,-3.19],[0.5482,0.4645,0.2185,0.2056,-3.38],[0.5479,0.4639,0.2183,0.2057,-3.5],[0.5477,0.4636,0.2182,0.2058,-3.57],[0.5477,0.4635,0.2182,0.2059,-3.62],[0.5477,0.4634,0.2182,0.206,-3.66],[0.5477,0.4634,0.2182,0.206,-3.66],[0.5477,0.4634,0.2181,0.206,-3.66],[0.5477,0.4634,0.2181,0.206,-3.66],[0.5477,0.4634,0.2181,0.206,-3.66],[0.5477,0.4634,0.2181,0.206,-3.65],[0.5477,0.4634,0.2181,0.206,-3.65],[0.5477,0.4635,0.2182,0.2059,-3.61],[0.5479,0.464,0.2183,0.2058,-3.52]],"rest":"ta-rest","mask":true,"reach":[0.2578,0.2917,0.7474,0.9297]},"magnifier":{"file":"clips/magnifier.mp4","fps":24,"frames":116,"size":384,"face":"curious","tracks":[[0.5479,0.464,0.2183,0.2058,-3.52],[0.5483,0.4648,0.2186,0.2057,-3.34],[0.5489,0.4663,0.2192,0.2053,-3.06],[0.5494,0.4677,0.2197,0.205,-2.78],[0.5501,0.4699,0.2208,0.2046,-2.38],[0.5506,0.4724,0.2219,0.2041,-1.91],[0.5507,0.4748,0.2232,0.2035,-1.49],[0.5503,0.4767,0.2246,0.2032,-1.17],[0.5495,0.4782,0.2262,0.2029,-0.82],[0.5482,0.4782,0.2275,0.203,-0.65],[0.5466,0.4769,0.2286,0.2032,-0.63],[0.5449,0.4734,0.2295,0.2016,-0.74],[0.5433,0.4698,0.2301,0.2015,-0.64],[0.5419,0.4663,0.2304,0.2015,-0.28],[0.5408,0.4631,0.2305,0.2017,-0.54],[0.5399,0.4605,0.2305,0.202,-0.68],[0.5393,0.4597,0.2304,0.2043,-0.74],[0.539,0.459,0.2304,0.2049,-1.0],[0.5388,0.4585,0.2304,0.2055,-1.56],[0.5387,0.4584,0.2304,0.2056,-1.47],[0.5387,0.4584,0.2304,0.2056,-1.46],[0.5387,0.4584,0.2304,0.2056,-1.45],[0.5386,0.4583,0.2304,0.2055,-1.48],[0.5385,0.4583,0.2305,0.2055,-1.51],[0.538,0.4583,0.2309,0.2054,-1.47],[0.5369,0.4582,0.2317,0.2053,-1.33],[0.5349,0.458,0.2329,0.2052,-1.21],[0.533,0.458,0.2339,0.2051,-1.07],[0.5295,0.4578,0.2362,0.205,-0.73],[0.5258,0.4576,0.2387,0.2049,-0.44],[0.5219,0.4575,0.2411,0.2049,-0.2],[0.5189,0.4574,0.2431,0.2049,0.07],[0.5148,0.4573,0.2456,0.2049,0.38],[0.5116,0.4573,0.2467,0.205,0.51],[0.5086,0.4573,0.2471,0.2052,0.68],[0.5063,0.4574,0.2471,0.2054,0.84],[0.5034,0.4575,0.2472,0.2065,0.91],[0.5014,0.4576,0.2467,0.2077,0.89],[0.5,0.4577,0.2465,0.2089,0.84],[0.4991,0.4578,0.2463,0.2099,0.73],[0.4983,0.4578,0.2464,0.2109,0.58],[0.4981,0.4578,0.2464,0.2111,0.52],[0.4981,0.4578,0.2466,0.2111,0.51],[0.4981,0.4578,0.2467,0.211,0.5],[0.4982,0.4578,0.2467,0.211,0.47],[0.4984,0.4578,0.2468,0.211,0.47],[0.4991,0.4578,0.247,0.211,0.43],[0.4998,0.4579,0.2471,0.211,0.39],[0.5018,0.4579,0.2472,0.21,0.41],[0.5045,0.4578,0.2471,0.2088,0.42],[0.5079,0.4576,0.2465,0.2076,0.4],[0.511,0.4575,0.2461,0.2064,0.37],[0.5161,0.4572,0.2446,0.2049,0.14],[0.5209,0.457,0.2427,0.2043,-0.33],[0.5258,0.4571,0.24,0.2041,-0.8],[0.5298,0.4571,0.2376,0.204,-1.11],[0.5353,0.4572,0.2331,0.2043,-1.62],[0.5395,0.4574,0.229,0.2052,-1.94],[0.5429,0.4576,0.2241,0.2061,-1.69],[0.5453,0.4577,0.2202,0.207,-1.41],[0.5484,0.4578,0.2158,0.2078,-1.34],[0.5499,0.4579,0.2132,0.2082,-1.07],[0.5509,0.458,0.2112,0.2084,-0.84],[0.5516,0.458,0.2106,0.2085,-0.96],[0.5526,0.458,0.2097,0.2085,-1.17],[0.5533,0.4579,0.2092,0.2084,-1.43],[0.5541,0.4579,0.2086,0.2083,-1.66],[0.5548,0.4578,0.2079,0.2083,-1.9],[0.5556,0.4578,0.2072,0.2082,-2.18],[0.5563,0.4577,0.2067,0.2081,-2.39],[0.5566,0.4576,0.2063,0.2081,-2.49],[0.5567,0.4576,0.2061,0.2082,-2.57],[0.5568,0.4575,0.206,0.2082,-2.6],[0.5568,0.4574,0.2058,0.2082,-2.63],[0.5569,0.4573,0.2056,0.2083,-2.66],[0.5569,0.4572,0.2055,0.2083,-2.69],[0.557,0.4572,0.2053,0.2083,-2.72],[0.557,0.4571,0.2052,0.2084,-2.75],[0.5571,0.457,0.205,0.2084,-2.78],[0.5571,0.4569,0.2049,0.2084,-2.81],[0.5572,0.4568,0.2047,0.2084,-2.84],[0.5572,0.4568,0.2046,0.2085,-2.87],[0.5573,0.4567,0.2044,0.2085,-2.9],[0.5573,0.4566,0.2043,0.2085,-2.93],[0.5574,0.4565,0.2041,0.2086,-2.96],[0.5574,0.4565,0.2039,0.2086,-2.99],[0.5568,0.4564,0.2047,0.2087,-2.9],[0.5556,0.4564,0.2059,0.2088,-2.65],[0.5543,0.4563,0.2071,0.209,-2.39],[0.5528,0.4563,0.2082,0.2092,-2.11],[0.5514,0.4563,0.2094,0.2094,-1.81],[0.5506,0.4563,0.2097,0.2095,-1.63],[0.5504,0.4563,0.2096,0.2096,-1.58],[0.5504,0.4562,0.2097,0.2096,-1.64],[0.5506,0.4558,0.21,0.2084,-1.68],[0.5509,0.4551,0.2107,0.2066,-1.91],[0.5514,0.4542,0.2116,0.204,-2.58],[0.5518,0.4544,0.2124,0.2032,-2.91],[0.5523,0.455,0.2131,0.2024,-3.07],[0.5528,0.4565,0.2139,0.2028,-3.24],[0.5532,0.4585,0.2146,0.2038,-3.15],[0.5536,0.4613,0.2152,0.2056,-2.56],[0.5539,0.4633,0.2159,0.2053,-2.29],[0.5542,0.4651,0.2165,0.2052,-2.07],[0.5543,0.4666,0.217,0.2049,-1.84],[0.5543,0.4678,0.2175,0.2047,-1.7],[0.5541,0.4683,0.2178,0.2047,-1.69],[0.5537,0.4684,0.2179,0.2047,-1.78],[0.553,0.4681,0.2181,0.2047,-1.94],[0.5521,0.4674,0.2183,0.2049,-2.1],[0.5511,0.4665,0.2185,0.2052,-2.33],[0.5502,0.4657,0.2185,0.2053,-2.56],[0.5493,0.465,0.2186,0.2054,-2.78],[0.5486,0.4644,0.2187,0.2055,-2.97],[0.5482,0.464,0.2185,0.2056,-3.18],[0.548,0.4638,0.2184,0.2056,-3.31]],"rest":"ta-rest","mask":true,"reach":[0.263,0.2943,0.7188,0.9297]},"cards-shuffle":{"file":"clips/cards-shuffle.mp4","fps":24,"frames":119,"size":384,"face":"curious","tracks":[[0.5488,0.4655,0.2176,0.207,-3.34],[0.5493,0.466,0.2175,0.2068,-3.25],[0.5502,0.4672,0.2172,0.2066,-3.17],[0.5517,0.4691,0.2168,0.2061,-3.02],[0.5538,0.4721,0.2161,0.2052,-2.8],[0.5566,0.4758,0.2148,0.2042,-2.59],[0.5597,0.4802,0.2135,0.2028,-2.3],[0.5628,0.4848,0.2122,0.2012,-1.97],[0.5657,0.4893,0.2109,0.1994,-1.71],[0.5682,0.4932,0.2096,0.1979,-1.44],[0.5702,0.4961,0.2084,0.1964,-1.27],[0.5715,0.498,0.2078,0.1954,-1.26],[0.5719,0.4988,0.2077,0.1949,-1.3],[0.5719,0.4987,0.2079,0.1951,-1.22],[0.5714,0.4977,0.2084,0.1957,-1.11],[0.5704,0.4962,0.2096,0.1966,-0.96],[0.569,0.4943,0.2111,0.1977,-0.67],[0.5675,0.4921,0.2126,0.1989,-0.23],[0.5661,0.4899,0.214,0.2001,0.26],[0.5644,0.4878,0.2157,0.2012,0.64],[0.563,0.486,0.2171,0.2022,1.02],[0.5617,0.4845,0.2182,0.203,1.34],[0.5608,0.4834,0.219,0.2035,1.51],[0.56,0.4827,0.2199,0.2039,1.52],[0.5595,0.4822,0.2204,0.2041,1.52],[0.5594,0.482,0.2205,0.2042,1.56],[0.5596,0.482,0.2205,0.2043,1.63],[0.5602,0.482,0.2201,0.2044,1.84],[0.5611,0.4822,0.2198,0.2044,2.02],[0.5624,0.4825,0.219,0.2045,2.32],[0.5638,0.4829,0.2182,0.2046,2.5],[0.5654,0.4832,0.2173,0.2045,2.66],[0.5667,0.4834,0.2166,0.2042,2.73],[0.5679,0.4835,0.2157,0.2039,2.86],[0.5688,0.4835,0.2153,0.2035,2.9],[0.5693,0.4833,0.2149,0.203,3.03],[0.5696,0.4832,0.2148,0.2028,3.11],[0.5696,0.4833,0.2149,0.2029,3.07],[0.5692,0.4835,0.2152,0.2032,3.07],[0.5685,0.4837,0.2156,0.2036,3.17],[0.5673,0.4838,0.2165,0.2038,3.2],[0.5657,0.484,0.2177,0.2041,3.17],[0.5638,0.484,0.2191,0.2041,3.2],[0.5619,0.484,0.2203,0.204,3.16],[0.5601,0.484,0.2217,0.2039,2.95],[0.5586,0.484,0.2228,0.2039,2.75],[0.5574,0.4839,0.2237,0.2039,2.54],[0.5564,0.4839,0.2242,0.2039,2.37],[0.5552,0.4837,0.2249,0.2039,1.98],[0.5535,0.4835,0.2255,0.2038,1.54],[0.551,0.483,0.2261,0.2038,0.97],[0.5476,0.4824,0.2269,0.204,0.4],[0.5433,0.4818,0.2281,0.2047,-0.26],[0.5386,0.4812,0.2293,0.2057,-0.85],[0.5338,0.4805,0.2306,0.2065,-1.45],[0.5293,0.48,0.2319,0.2076,-1.93],[0.5257,0.4796,0.2328,0.2089,-2.36],[0.5227,0.4795,0.2335,0.2103,-2.66],[0.5205,0.4796,0.234,0.2118,-2.82],[0.5191,0.4799,0.2342,0.2133,-2.95],[0.5182,0.4801,0.2344,0.2146,-3.02],[0.5176,0.4803,0.2346,0.2153,-3.06],[0.5174,0.4803,0.2347,0.2154,-3.11],[0.5173,0.4803,0.2347,0.2154,-3.14],[0.5174,0.4804,0.2347,0.2154,-3.07],[0.5174,0.4806,0.2347,0.2154,-3.0],[0.5175,0.4808,0.2347,0.2152,-2.93],[0.5176,0.4811,0.2347,0.215,-2.88],[0.5178,0.4814,0.2347,0.2142,-2.97],[0.5179,0.4813,0.2348,0.2121,-3.39],[0.5179,0.4811,0.2349,0.2094,-4.02],[0.5176,0.4798,0.2351,0.2052,-4.88],[0.5167,0.477,0.2354,0.1996,-5.94],[0.5153,0.473,0.2355,0.1935,-7.04],[0.5133,0.4683,0.2358,0.1885,-7.87],[0.5109,0.4628,0.236,0.184,-8.56],[0.5083,0.4578,0.2362,0.1816,-9.05],[0.506,0.4539,0.2364,0.1811,-9.31],[0.504,0.451,0.2367,0.1818,-9.47],[0.5026,0.4489,0.237,0.1824,-9.55],[0.5017,0.4477,0.2371,0.1833,-9.63],[0.5014,0.4471,0.2371,0.1835,-9.69],[0.5014,0.4468,0.2371,0.1831,-9.76],[0.5016,0.4468,0.2371,0.1824,-9.79],[0.5019,0.447,0.237,0.1819,-9.84],[0.5025,0.4474,0.237,0.1813,-9.77],[0.5033,0.4482,0.237,0.1811,-9.49],[0.5045,0.4495,0.237,0.1817,-8.86],[0.5064,0.452,0.2371,0.184,-7.68],[0.5088,0.4559,0.2373,0.188,-6.15],[0.5116,0.4609,0.2377,0.1931,-4.66],[0.5147,0.4664,0.2383,0.198,-3.12],[0.5176,0.472,0.2392,0.2019,-1.61],[0.52,0.4774,0.2402,0.2041,-0.41],[0.5218,0.4819,0.2412,0.2044,0.57],[0.5229,0.4859,0.2423,0.2035,1.67],[0.5234,0.4896,0.2432,0.2025,2.59],[0.5234,0.4926,0.2439,0.2017,3.28],[0.5233,0.4949,0.2445,0.2011,3.76],[0.5232,0.4965,0.2448,0.2006,4.09],[0.5231,0.4974,0.245,0.2004,4.28],[0.5231,0.4978,0.2451,0.2004,4.34],[0.5233,0.4978,0.2452,0.2004,4.31],[0.5238,0.4974,0.2451,0.2005,4.22],[0.5248,0.4966,0.2447,0.2007,3.98],[0.5262,0.4952,0.244,0.201,3.56],[0.5283,0.4933,0.2428,0.2015,3.08],[0.5309,0.4907,0.2411,0.2021,2.44],[0.5338,0.4876,0.2388,0.2028,1.63],[0.5368,0.4841,0.236,0.2036,0.77],[0.5397,0.4805,0.2327,0.2044,-0.07],[0.5423,0.4768,0.2293,0.205,-0.92],[0.5445,0.4733,0.2259,0.2056,-1.74],[0.5462,0.4704,0.2229,0.2061,-2.38],[0.5474,0.4682,0.2206,0.2064,-2.87],[0.5482,0.4667,0.2189,0.2066,-3.2],[0.5486,0.4658,0.2179,0.2068,-3.41],[0.5487,0.4654,0.2175,0.2069,-3.48],[0.5488,0.4653,0.2173,0.207,-3.52]],"rest":"tb-rest","mask":true,"reach":[0.276,0.2891,0.7865,0.9323]},"cards-fan":{"file":"clips/cards-fan.mp4","fps":24,"frames":137,"size":384,"face":"happy","tracks":[[0.5487,0.4654,0.2173,0.207,-3.52],[0.5488,0.4656,0.2174,0.207,-3.5],[0.549,0.4663,0.2175,0.2067,-3.46],[0.5495,0.4675,0.2177,0.2066,-3.38],[0.55,0.4695,0.218,0.2062,-3.25],[0.5506,0.4724,0.2184,0.2057,-3.08],[0.5508,0.4757,0.2189,0.205,-2.89],[0.5505,0.4792,0.2197,0.2043,-2.74],[0.5494,0.4826,0.2207,0.2034,-2.7],[0.5473,0.4857,0.222,0.2027,-2.77],[0.5443,0.4879,0.2237,0.2022,-2.97],[0.5405,0.4894,0.2256,0.2019,-3.15],[0.5363,0.4902,0.2276,0.2019,-3.5],[0.5319,0.4902,0.2298,0.2021,-3.82],[0.5276,0.4895,0.2319,0.2025,-4.06],[0.5235,0.4884,0.2339,0.2031,-4.16],[0.5199,0.4868,0.2356,0.2036,-4.3],[0.5165,0.4849,0.2373,0.2043,-4.2],[0.5133,0.4826,0.2388,0.205,-4.03],[0.5105,0.4801,0.2401,0.2057,-3.79],[0.5079,0.4775,0.2412,0.2063,-3.54],[0.5055,0.4745,0.2422,0.2063,-3.34],[0.5035,0.4711,0.243,0.205,-3.04],[0.5019,0.4677,0.2436,0.2027,-2.72],[0.5006,0.4646,0.2439,0.2,-2.45],[0.4996,0.4619,0.2442,0.1972,-2.22],[0.499,0.4599,0.2443,0.195,-1.94],[0.4986,0.4588,0.2443,0.1939,-1.82],[0.4983,0.4584,0.2443,0.1936,-1.74],[0.4982,0.4582,0.2443,0.1935,-1.7],[0.4981,0.4582,0.2442,0.1935,-1.65],[0.498,0.4579,0.2442,0.1931,-1.58],[0.4979,0.4575,0.2443,0.1924,-1.55],[0.4978,0.4569,0.2443,0.1915,-1.61],[0.4976,0.4562,0.2443,0.1905,-1.76],[0.4974,0.4556,0.2443,0.1895,-1.98],[0.4972,0.4551,0.2443,0.1889,-2.26],[0.4971,0.4548,0.2443,0.1887,-2.51],[0.4969,0.4546,0.2443,0.1887,-2.68],[0.4968,0.4546,0.2443,0.1887,-2.75],[0.4968,0.4546,0.2443,0.1887,-2.72],[0.4967,0.4545,0.2443,0.1887,-2.57],[0.4967,0.4545,0.2443,0.1887,-2.29],[0.4967,0.4546,0.2443,0.1889,-1.92],[0.4967,0.4547,0.2443,0.1891,-1.52],[0.4967,0.4549,0.2443,0.1894,-1.16],[0.4968,0.4552,0.2443,0.1896,-0.91],[0.4969,0.4555,0.2443,0.1895,-0.77],[0.497,0.456,0.2443,0.1889,-0.67],[0.4973,0.4563,0.2444,0.1878,-0.6],[0.4976,0.4564,0.2444,0.1859,-0.51],[0.4979,0.4565,0.2445,0.1842,-0.4],[0.4982,0.4564,0.2446,0.1826,-0.29],[0.4985,0.4561,0.2446,0.1815,-0.22],[0.4987,0.4559,0.2446,0.1809,-0.15],[0.4988,0.4558,0.2446,0.181,-0.11],[0.4989,0.4557,0.2446,0.1811,-0.07],[0.4989,0.4557,0.2446,0.1812,-0.02],[0.499,0.4556,0.2446,0.1813,0.02],[0.4991,0.4555,0.2446,0.1814,0.06],[0.4992,0.4555,0.2446,0.1815,0.1],[0.4993,0.4554,0.2446,0.1816,0.15],[0.4994,0.4554,0.2446,0.1817,0.19],[0.4995,0.4553,0.2446,0.1818,0.23],[0.4996,0.4552,0.2446,0.1819,0.28],[0.4997,0.4552,0.2446,0.182,0.32],[0.4998,0.4551,0.2446,0.1821,0.36],[0.4998,0.455,0.2446,0.1822,0.4],[0.4999,0.455,0.2446,0.1823,0.45],[0.5,0.4549,0.2446,0.1824,0.49],[0.5001,0.4549,0.2446,0.1825,0.53],[0.5002,0.4548,0.2446,0.1826,0.58],[0.5003,0.4547,0.2446,0.1827,0.62],[0.5004,0.4547,0.2446,0.1828,0.66],[0.5005,0.4546,0.2446,0.1829,0.7],[0.5006,0.4545,0.2446,0.183,0.75],[0.5007,0.4545,0.2447,0.1831,0.79],[0.5007,0.4544,0.2447,0.1832,0.83],[0.5008,0.4544,0.2447,0.1833,0.88],[0.5009,0.4543,0.2447,0.1834,0.92],[0.501,0.4542,0.2447,0.1835,0.96],[0.5011,0.4542,0.2447,0.1836,1.01],[0.5012,0.4541,0.2447,0.1837,1.05],[0.5013,0.454,0.2447,0.1838,1.09],[0.5014,0.454,0.2447,0.1839,1.13],[0.5015,0.4539,0.2447,0.184,1.18],[0.5015,0.4538,0.2447,0.1841,1.22],[0.5016,0.4538,0.2447,0.1842,1.26],[0.5017,0.4537,0.2447,0.1843,1.31],[0.5018,0.4537,0.2447,0.1844,1.35],[0.5019,0.4536,0.2447,0.1845,1.39],[0.502,0.4535,0.2447,0.1846,1.43],[0.5021,0.4535,0.2447,0.1847,1.48],[0.5022,0.4534,0.2447,0.1848,1.52],[0.5023,0.4533,0.2447,0.1849,1.56],[0.5024,0.4533,0.2447,0.185,1.61],[0.5024,0.4532,0.2447,0.1851,1.65],[0.5025,0.4532,0.2447,0.1852,1.69],[0.5026,0.4531,0.2448,0.1853,1.73],[0.5027,0.453,0.2448,0.1854,1.78],[0.5028,0.453,0.2448,0.1855,1.82],[0.5029,0.4529,0.2448,0.1856,1.86],[0.503,0.4528,0.2448,0.1857,1.91],[0.5031,0.4528,0.2448,0.1858,1.95],[0.503,0.4543,0.2448,0.1888,1.89],[0.5025,0.4571,0.2449,0.1929,1.78],[0.5016,0.4604,0.245,0.1973,1.58],[0.5001,0.4644,0.2452,0.2018,1.4],[0.498,0.4691,0.2453,0.206,1.23],[0.4955,0.473,0.2453,0.2071,1.17],[0.4928,0.4764,0.2451,0.2069,1.2],[0.4901,0.4798,0.2447,0.2062,1.35],[0.4877,0.4829,0.2442,0.2054,1.53],[0.4857,0.4855,0.2436,0.2047,1.71],[0.4842,0.4875,0.2431,0.204,1.89],[0.4834,0.489,0.2428,0.2036,2.07],[0.4831,0.49,0.2426,0.2032,2.23],[0.4832,0.4908,0.2425,0.203,2.35],[0.4838,0.4915,0.2427,0.2028,2.49],[0.485,0.492,0.243,0.2027,2.63],[0.4868,0.4922,0.2434,0.2026,2.68],[0.4894,0.4922,0.2439,0.2025,2.64],[0.4931,0.4918,0.2445,0.2027,2.5],[0.4979,0.491,0.2452,0.2028,2.38],[0.5034,0.4897,0.2452,0.2032,1.97],[0.5094,0.4879,0.2447,0.2037,1.47],[0.5157,0.4857,0.2435,0.2041,0.9],[0.5219,0.4831,0.2414,0.2045,0.27],[0.5275,0.4803,0.2381,0.2052,-0.53],[0.5328,0.4773,0.2345,0.2057,-1.18],[0.5374,0.4743,0.2306,0.2061,-1.79],[0.5414,0.4715,0.2267,0.2066,-2.33],[0.5443,0.4691,0.2234,0.2069,-2.73],[0.5464,0.4673,0.2208,0.207,-2.99],[0.5476,0.4662,0.2191,0.207,-3.16],[0.5483,0.4656,0.2182,0.2071,-3.22],[0.5485,0.4653,0.2178,0.2071,-3.22]],"rest":"tb-rest","mask":true,"reach":[0.2682,0.2917,0.7578,0.9323]},"hop":{"file":"clips/hop.mp4","fps":24,"frames":40,"size":384,"face":"happy","tracks":[[0.5489,0.4667,0.2178,0.2067,-3.27],[0.5499,0.4699,0.218,0.2066,-3.11],[0.5516,0.4754,0.2182,0.206,-2.87],[0.5538,0.4831,0.2185,0.2049,-2.57],[0.5563,0.4925,0.2189,0.2037,-2.17],[0.5585,0.5025,0.2195,0.2014,-1.68],[0.5599,0.5111,0.2203,0.1987,-1.5],[0.56,0.5169,0.2215,0.1963,-1.43],[0.5587,0.5176,0.2225,0.1941,-1.36],[0.5544,0.5053,0.2231,0.1938,-1.44],[0.548,0.4848,0.2236,0.1953,-1.77],[0.54,0.459,0.2237,0.198,-2.21],[0.5311,0.4315,0.2238,0.2004,-2.87],[0.5219,0.4055,0.224,0.2028,-3.76],[0.5151,0.3902,0.2246,0.2036,-4.66],[0.51,0.3812,0.2252,0.2034,-5.45],[0.5068,0.3769,0.2257,0.2024,-6.0],[0.5051,0.3751,0.2259,0.2018,-6.29],[0.5046,0.3746,0.2259,0.2013,-6.43],[0.5046,0.3748,0.2258,0.2011,-6.53],[0.5047,0.3756,0.2258,0.201,-6.56],[0.505,0.3777,0.2258,0.2006,-6.54],[0.5057,0.3838,0.2256,0.2005,-6.58],[0.5071,0.3949,0.2254,0.2004,-6.59],[0.5105,0.4089,0.2251,0.2004,-6.43],[0.5159,0.4252,0.2248,0.2014,-6.09],[0.5226,0.4428,0.2243,0.2029,-5.66],[0.5302,0.4582,0.2236,0.2042,-4.95],[0.538,0.4696,0.2227,0.2054,-4.13],[0.5444,0.4785,0.2219,0.2062,-3.34],[0.5491,0.4843,0.2208,0.2057,-2.7],[0.5522,0.4865,0.2199,0.2052,-2.22],[0.5536,0.4857,0.2193,0.2049,-2.03],[0.5538,0.4826,0.2188,0.2051,-2.08],[0.5531,0.4783,0.2183,0.2055,-2.32],[0.5519,0.4739,0.2181,0.2062,-2.64],[0.5507,0.4703,0.2178,0.2067,-2.95],[0.5498,0.4677,0.2176,0.207,-3.2],[0.5492,0.4662,0.2175,0.2071,-3.35],[0.5489,0.4655,0.2174,0.2072,-3.42]],"rest":"tb-rest","mask":true,"reach":[0.2604,0.224,0.737,0.9323]},"story-hourglass":{"file":"clips/story-hourglass.mp4","fps":24,"frames":124,"size":384,"face":"idle","rest":"story-hourglass-rest","mask":true,"tracks":[[0.4878,0.2786,0.3125,0.2452,-0.49],[0.4878,0.2787,0.3125,0.2452,-0.49],[0.4878,0.2787,0.3125,0.2452,-0.49],[0.4878,0.2788,0.3125,0.2452,-0.48],[0.4878,0.2788,0.3125,0.2452,-0.48],[0.4878,0.2788,0.3125,0.2452,-0.48],[0.4878,0.2788,0.3125,0.2452,-0.47],[0.4878,0.2788,0.3125,0.2452,-0.47],[0.4878,0.2788,0.3125,0.2452,-0.45],[0.4878,0.2791,0.3125,0.2452,-0.42],[0.4877,0.2799,0.3125,0.2452,-0.37],[0.4877,0.2807,0.3125,0.2452,-0.3],[0.4877,0.2814,0.3125,0.2452,-0.2],[0.4878,0.2814,0.3125,0.2452,-0.07],[0.4879,0.2802,0.3125,0.2452,0.08],[0.4881,0.2776,0.3125,0.2452,0.25],[0.4883,0.2739,0.3125,0.2452,0.44],[0.4884,0.2694,0.3125,0.2452,0.64],[0.4886,0.2648,0.3125,0.2452,0.83],[0.4889,0.2602,0.3125,0.2452,1.02],[0.4892,0.2558,0.3125,0.2452,1.18],[0.4895,0.2522,0.3125,0.2452,1.3],[0.4896,0.249,0.3125,0.2452,1.44],[0.4893,0.2463,0.3125,0.2452,1.76],[0.4883,0.2448,0.3125,0.2452,2.09],[0.4876,0.244,0.3125,0.2452,2.17],[0.4869,0.2433,0.3125,0.2452,1.86],[0.484,0.2449,0.3125,0.2452,1.13],[0.4788,0.2493,0.3125,0.2452,-0.16],[0.4726,0.2554,0.3125,0.2452,-1.89],[0.4658,0.263,0.3125,0.2452,-3.94],[0.4588,0.2721,0.3125,0.2452,-5.9],[0.4542,0.2805,0.3125,0.2452,-7.34],[0.4535,0.2889,0.3125,0.2452,-8.06],[0.4558,0.2969,0.3125,0.2452,-7.97],[0.4604,0.3049,0.3125,0.2452,-6.9],[0.4671,0.3116,0.3125,0.2452,-5.06],[0.4753,0.3167,0.3125,0.2452,-2.88],[0.4838,0.3187,0.3125,0.2452,-0.63],[0.4921,0.3184,0.3125,0.2452,1.55],[0.4998,0.3163,0.3125,0.2452,3.62],[0.5066,0.3134,0.3125,0.2452,5.48],[0.5126,0.3107,0.3125,0.2452,7.07],[0.5187,0.3085,0.3125,0.2452,8.37],[0.524,0.3066,0.3125,0.2452,9.48],[0.5288,0.3051,0.3125,0.2452,10.38],[0.5333,0.3039,0.3125,0.2452,11.07],[0.5376,0.3029,0.3125,0.2452,11.53],[0.5404,0.3021,0.3125,0.2452,11.81],[0.5429,0.3014,0.3125,0.2452,11.95],[0.5448,0.3008,0.3125,0.2452,12.0],[0.5459,0.3004,0.3125,0.2452,12.0],[0.5464,0.3001,0.3125,0.2452,12.0],[0.5469,0.2998,0.3125,0.2452,12.0],[0.5471,0.2995,0.3125,0.2452,12.0],[0.5471,0.2993,0.3125,0.2452,12.0],[0.5471,0.299,0.3125,0.2452,12.0],[0.5471,0.2988,0.3125,0.2452,12.0],[0.547,0.2985,0.3125,0.2452,12.0],[0.5469,0.2982,0.3125,0.2452,12.0],[0.5469,0.298,0.3125,0.2452,12.0],[0.5468,0.2978,0.3125,0.2452,12.0],[0.5468,0.2977,0.3125,0.2452,12.0],[0.5467,0.2976,0.3125,0.2452,12.0],[0.5467,0.2975,0.3125,0.2452,12.0],[0.5466,0.2974,0.3125,0.2452,12.0],[0.5466,0.2973,0.3125,0.2452,12.0],[0.5465,0.2972,0.3125,0.2452,12.0],[0.5465,0.2971,0.3125,0.2452,12.0],[0.5465,0.297,0.3125,0.2452,12.0],[0.5465,0.297,0.3125,0.2452,12.0],[0.5465,0.2969,0.3125,0.2452,12.0],[0.5465,0.2969,0.3125,0.2452,12.0],[0.5465,0.2968,0.3125,0.2452,12.0],[0.5465,0.2967,0.3125,0.2452,12.0],[0.5465,0.2966,0.3125,0.2452,12.0],[0.5465,0.2965,0.3125,0.2452,12.0],[0.5464,0.2964,0.3125,0.2452,12.0],[0.5464,0.2964,0.3125,0.2452,12.0],[0.5464,0.2963,0.3125,0.2452,12.0],[0.5463,0.2963,0.3125,0.2452,12.0],[0.5463,0.2963,0.3125,0.2452,12.0],[0.5458,0.2966,0.3125,0.2452,12.0],[0.5445,0.297,0.3125,0.2452,11.96],[0.5423,0.2976,0.3125,0.2452,11.85],[0.5393,0.2979,0.3125,0.2452,11.6],[0.5351,0.2977,0.3125,0.2452,11.16],[0.53,0.2967,0.3125,0.2452,10.48],[0.5245,0.295,0.3125,0.2452,9.56],[0.5186,0.2927,0.3125,0.2452,8.4],[0.5123,0.2901,0.3125,0.2452,7.09],[0.5064,0.2877,0.3125,0.2452,5.73],[0.5015,0.2856,0.3125,0.2452,4.38],[0.4979,0.2839,0.3125,0.2452,3.16],[0.4953,0.2829,0.3125,0.2452,2.13],[0.4932,0.2822,0.3125,0.2452,1.31],[0.4917,0.2817,0.3125,0.2452,0.7],[0.4906,0.2813,0.3125,0.2452,0.26],[0.4894,0.2809,0.3125,0.2452,-0.04],[0.4885,0.2802,0.3125,0.2452,-0.22],[0.488,0.2796,0.3125,0.2452,-0.34],[0.4879,0.2792,0.3125,0.2452,-0.42],[0.4878,0.2788,0.3125,0.2452,-0.46],[0.4878,0.2785,0.3125,0.2452,-0.47],[0.4878,0.2784,0.3125,0.2452,-0.48],[0.4878,0.2783,0.3125,0.2452,-0.49],[0.4878,0.2783,0.3125,0.2452,-0.49],[0.4878,0.2783,0.3125,0.2452,-0.49],[0.4878,0.2783,0.3125,0.2452,-0.5],[0.4878,0.2783,0.3125,0.2452,-0.49],[0.4878,0.2783,0.3125,0.2452,-0.49],[0.4878,0.2783,0.3125,0.2452,-0.49],[0.4878,0.2783,0.3125,0.2452,-0.48],[0.4879,0.2783,0.3125,0.2452,-0.48],[0.4879,0.2784,0.3125,0.2452,-0.48],[0.4879,0.2784,0.3125,0.2452,-0.48],[0.4879,0.2784,0.3125,0.2452,-0.48],[0.4879,0.2784,0.3125,0.2452,-0.49],[0.4879,0.2785,0.3125,0.2452,-0.49],[0.4879,0.2785,0.3125,0.2452,-0.49],[0.4879,0.2785,0.3125,0.2452,-0.5],[0.4879,0.2786,0.3125,0.2452,-0.5],[0.4879,0.2786,0.3125,0.2452,-0.5],[0.4879,0.2786,0.3125,0.2452,-0.5]],"reach":[0.1634,0.03,0.8366,0.92],"script":[[0,"idle",0,1],[0.35,"happy",0,0],[0.8,"blink",0,0],[0.84,"idle",0,0]]},"story-business":{"file":"clips/story-business.mp4","fps":24,"frames":124,"size":384,"face":"idle","rest":"story-business-rest","mask":true,"tracks":[[0.4789,0.293,0.3099,0.244,-0.19],[0.4788,0.293,0.3099,0.244,-0.2],[0.4788,0.293,0.3099,0.244,-0.2],[0.4788,0.293,0.3099,0.244,-0.22],[0.4787,0.293,0.3099,0.244,-0.23],[0.4787,0.293,0.3099,0.244,-0.26],[0.4786,0.293,0.3099,0.244,-0.3],[0.4783,0.293,0.3099,0.244,-0.35],[0.4778,0.293,0.3099,0.244,-0.43],[0.4772,0.293,0.3099,0.244,-0.52],[0.4764,0.2931,0.3099,0.244,-0.61],[0.4759,0.2933,0.3099,0.244,-0.68],[0.4763,0.2938,0.3099,0.244,-0.72],[0.4781,0.2945,0.3099,0.244,-0.72],[0.4812,0.2955,0.3099,0.244,-0.66],[0.4852,0.2966,0.3099,0.244,-0.56],[0.4893,0.2978,0.3099,0.244,-0.44],[0.4932,0.2989,0.3099,0.244,-0.31],[0.4961,0.2997,0.3099,0.244,-0.17],[0.4981,0.3002,0.3099,0.244,-0.05],[0.4994,0.3005,0.3099,0.244,0.05],[0.5005,0.3006,0.3099,0.244,0.14],[0.5012,0.3006,0.3099,0.244,0.22],[0.5017,0.3006,0.3099,0.244,0.29],[0.5019,0.3006,0.3099,0.244,0.36],[0.502,0.3006,0.3099,0.244,0.41],[0.502,0.3006,0.3099,0.244,0.44],[0.502,0.3006,0.3099,0.244,0.47],[0.502,0.3006,0.3099,0.244,0.48],[0.502,0.3006,0.3099,0.244,0.48],[0.502,0.3006,0.3099,0.244,0.5],[0.5021,0.3006,0.3099,0.244,0.53],[0.5022,0.3007,0.3099,0.244,0.58],[0.5026,0.301,0.3099,0.244,0.66],[0.5032,0.3014,0.3099,0.244,0.77],[0.5044,0.3021,0.3099,0.244,0.91],[0.506,0.3031,0.3099,0.244,1.08],[0.5081,0.3043,0.3099,0.244,1.28],[0.5108,0.3054,0.3099,0.244,1.5],[0.5137,0.3066,0.3099,0.244,1.71],[0.5162,0.3075,0.3099,0.244,1.9],[0.5183,0.3082,0.3099,0.244,2.04],[0.5196,0.3085,0.3099,0.244,2.11],[0.5197,0.3084,0.3099,0.244,2.09],[0.5183,0.3078,0.3099,0.244,1.98],[0.5161,0.3067,0.3099,0.244,1.79],[0.5134,0.3052,0.3099,0.244,1.53],[0.5104,0.3036,0.3099,0.244,1.24],[0.5077,0.302,0.3099,0.244,0.95],[0.5059,0.3006,0.3099,0.244,0.7],[0.5048,0.2996,0.3099,0.244,0.5],[0.5042,0.2992,0.3099,0.244,0.39],[0.5043,0.2992,0.3099,0.244,0.32],[0.5049,0.2998,0.3099,0.244,0.31],[0.5056,0.3007,0.3099,0.244,0.34],[0.5063,0.3018,0.3099,0.244,0.39],[0.5068,0.3028,0.3099,0.244,0.44],[0.5071,0.3036,0.3099,0.244,0.47],[0.5067,0.3039,0.3099,0.244,0.47],[0.5062,0.3034,0.3099,0.244,0.44],[0.5055,0.3021,0.3099,0.244,0.37],[0.5044,0.3003,0.3099,0.244,0.27],[0.5032,0.2983,0.3099,0.244,0.15],[0.5021,0.2968,0.3099,0.244,0.03],[0.5013,0.2969,0.3099,0.244,-0.09],[0.501,0.2998,0.3099,0.244,-0.19],[0.5011,0.3056,0.3099,0.244,-0.26],[0.5009,0.3143,0.3099,0.244,-0.32],[0.5003,0.3251,0.3099,0.244,-0.35],[0.4994,0.3363,0.3099,0.244,-0.37],[0.4984,0.3464,0.3099,0.244,-0.37],[0.4976,0.3549,0.3099,0.244,-0.32],[0.4983,0.3609,0.3099,0.244,-0.2],[0.5009,0.3637,0.3099,0.244,0.03],[0.5051,0.3636,0.3099,0.244,0.43],[0.5107,0.3605,0.3099,0.244,1.02],[0.5173,0.3542,0.3099,0.244,1.77],[0.5242,0.3452,0.3099,0.244,2.67],[0.5307,0.3342,0.3099,0.244,3.62],[0.5363,0.3226,0.3099,0.244,4.55],[0.5404,0.3117,0.3099,0.244,5.37],[0.543,0.303,0.3099,0.244,6.0],[0.5438,0.2971,0.3099,0.244,6.39],[0.5428,0.2943,0.3099,0.244,6.52],[0.54,0.2944,0.3099,0.244,6.41],[0.5357,0.2968,0.3099,0.244,6.06],[0.5298,0.3009,0.3099,0.244,5.52],[0.5223,0.306,0.3099,0.244,4.83],[0.5136,0.3115,0.3099,0.244,4.03],[0.5039,0.3165,0.3099,0.244,3.16],[0.4938,0.3197,0.3099,0.244,2.25],[0.4839,0.3208,0.3099,0.244,1.35],[0.4748,0.3198,0.3099,0.244,0.48],[0.4668,0.317,0.3099,0.244,-0.31],[0.4604,0.3129,0.3099,0.244,-1.0],[0.4559,0.3086,0.3099,0.244,-1.58],[0.453,0.3042,0.3099,0.244,-1.99],[0.4519,0.2998,0.3099,0.244,-2.25],[0.4522,0.2959,0.3099,0.244,-2.37],[0.4536,0.2928,0.3099,0.244,-2.35],[0.4558,0.2906,0.3099,0.244,-2.22],[0.4586,0.2893,0.3099,0.244,-2.02],[0.4618,0.2888,0.3099,0.244,-1.77],[0.4652,0.289,0.3099,0.244,-1.52],[0.4686,0.2897,0.3099,0.244,-1.28],[0.4717,0.2906,0.3099,0.244,-1.06],[0.4742,0.2914,0.3099,0.244,-0.86],[0.476,0.2921,0.3099,0.244,-0.7],[0.4771,0.2925,0.3099,0.244,-0.56],[0.4777,0.2927,0.3099,0.244,-0.44],[0.478,0.2927,0.3099,0.244,-0.34],[0.4782,0.2928,0.3099,0.244,-0.28],[0.4783,0.2928,0.3099,0.244,-0.24],[0.4783,0.2928,0.3099,0.244,-0.23],[0.4784,0.2928,0.3099,0.244,-0.24],[0.4784,0.2928,0.3099,0.244,-0.25],[0.4784,0.2928,0.3099,0.244,-0.27],[0.4784,0.2929,0.3099,0.244,-0.29],[0.4784,0.2929,0.3099,0.244,-0.3],[0.4784,0.2929,0.3099,0.244,-0.32],[0.4784,0.2929,0.3099,0.244,-0.32],[0.4784,0.2929,0.3099,0.244,-0.32],[0.4784,0.2929,0.3099,0.244,-0.32],[0.4784,0.2929,0.3099,0.244,-0.32]],"reach":[0.1642,0.03,0.8358,0.92],"script":[[0,"idle",0,1],[0.5,"happy",0,0],[0.85,"blink",0,0],[0.89,"idle",0,0]]},"story-celebrate":{"file":"clips/story-celebrate.mp4","fps":24,"frames":124,"size":384,"face":"happy","rest":"story-celebrate-rest","mask":true,"tracks":[[0.4861,0.4855,0.1979,0.1589,-1.01],[0.4861,0.4855,0.1979,0.1589,-1.01],[0.4861,0.4855,0.1979,0.1589,-1.01],[0.4861,0.4855,0.1979,0.1589,-1.01],[0.4861,0.4855,0.1979,0.1589,-1.01],[0.4861,0.4855,0.1979,0.1589,-1.0],[0.4861,0.4855,0.1979,0.1589,-0.99],[0.4861,0.4855,0.1979,0.1589,-0.98],[0.4861,0.4855,0.1979,0.1589,-0.96],[0.4861,0.4855,0.1979,0.1589,-0.94],[0.4861,0.4855,0.1979,0.1589,-0.92],[0.4862,0.4855,0.1979,0.1589,-0.91],[0.4862,0.4855,0.1979,0.1589,-0.91],[0.4862,0.4855,0.1979,0.1589,-0.91],[0.4862,0.4855,0.1979,0.1589,-0.93],[0.4862,0.4857,0.1979,0.1589,-0.95],[0.4862,0.4863,0.1979,0.1589,-0.98],[0.4861,0.4873,0.1979,0.1589,-1.01],[0.4861,0.4889,0.1979,0.1589,-1.03],[0.486,0.491,0.1979,0.1589,-1.06],[0.486,0.4935,0.1979,0.1589,-1.08],[0.4859,0.4964,0.1979,0.1589,-1.09],[0.4859,0.4994,0.1979,0.1589,-1.1],[0.486,0.5027,0.1979,0.1589,-1.09],[0.4859,0.506,0.1979,0.1589,-1.08],[0.4858,0.5095,0.1979,0.1589,-1.07],[0.4857,0.5132,0.1979,0.1589,-1.07],[0.4857,0.5169,0.1979,0.1589,-1.06],[0.4856,0.5207,0.1979,0.1589,-1.04],[0.4855,0.5245,0.1979,0.1589,-1.0],[0.4854,0.5283,0.1979,0.1589,-0.95],[0.4854,0.532,0.1979,0.1589,-0.88],[0.4853,0.5355,0.1979,0.1589,-0.8],[0.4852,0.5389,0.1979,0.1589,-0.73],[0.4852,0.5424,0.1979,0.1589,-0.66],[0.4852,0.5458,0.1979,0.1589,-0.61],[0.4852,0.5486,0.1979,0.1589,-0.58],[0.4852,0.5515,0.1979,0.1589,-0.57],[0.4852,0.5544,0.1979,0.1589,-0.57],[0.4852,0.5569,0.1979,0.1589,-0.59],[0.4852,0.5586,0.1979,0.1589,-0.56],[0.4852,0.5587,0.1979,0.1589,-0.51],[0.4851,0.5518,0.1979,0.1589,-0.43],[0.4853,0.532,0.1979,0.1589,-0.31],[0.4854,0.5028,0.1979,0.1589,-0.16],[0.4854,0.4673,0.1979,0.1589,-0.02],[0.4857,0.428,0.1979,0.1589,0.16],[0.4861,0.3911,0.1979,0.1589,0.31],[0.4862,0.3637,0.1979,0.1589,0.45],[0.4864,0.3427,0.1979,0.1589,0.58],[0.4867,0.3262,0.1979,0.1589,0.66],[0.4867,0.3141,0.1979,0.1589,0.72],[0.4868,0.3052,0.1979,0.1589,0.76],[0.4868,0.2987,0.1979,0.1589,0.78],[0.4868,0.2942,0.1979,0.1589,0.76],[0.4867,0.2916,0.1979,0.1589,0.72],[0.4866,0.2895,0.1979,0.1589,0.66],[0.4865,0.2878,0.1979,0.1589,0.58],[0.4864,0.2865,0.1979,0.1589,0.47],[0.4861,0.2855,0.1979,0.1589,0.35],[0.4858,0.2847,0.1979,0.1589,0.22],[0.4853,0.2842,0.1979,0.1589,0.06],[0.4848,0.2839,0.1979,0.1589,-0.13],[0.4843,0.2838,0.1979,0.1589,-0.32],[0.4838,0.2839,0.1979,0.1589,-0.54],[0.4834,0.2842,0.1979,0.1589,-0.76],[0.4829,0.2847,0.1979,0.1589,-0.99],[0.4823,0.2855,0.1979,0.1589,-1.23],[0.4817,0.2866,0.1979,0.1589,-1.47],[0.4811,0.2879,0.1979,0.1589,-1.7],[0.4805,0.2896,0.1979,0.1589,-1.93],[0.48,0.2916,0.1979,0.1589,-2.15],[0.4795,0.2937,0.1979,0.1589,-2.37],[0.479,0.296,0.1979,0.1589,-2.59],[0.4784,0.2991,0.1979,0.1589,-2.8],[0.4779,0.3024,0.1979,0.1589,-3.0],[0.4773,0.3062,0.1979,0.1589,-3.19],[0.4769,0.3106,0.1979,0.1589,-3.27],[0.4764,0.3159,0.1979,0.1589,-3.25],[0.4762,0.3231,0.1979,0.1589,-3.15],[0.4761,0.3379,0.1979,0.1589,-2.96],[0.4767,0.3615,0.1979,0.1589,-2.67],[0.4775,0.3898,0.1979,0.1589,-2.38],[0.4787,0.4211,0.1979,0.1589,-2.04],[0.4801,0.4528,0.1979,0.1589,-1.73],[0.4816,0.4795,0.1979,0.1589,-1.42],[0.4827,0.5,0.1979,0.1589,-1.11],[0.4836,0.5172,0.1979,0.1589,-0.8],[0.4842,0.5313,0.1979,0.1589,-0.55],[0.4846,0.5413,0.1979,0.1589,-0.3],[0.485,0.5462,0.1979,0.1589,-0.12],[0.4851,0.5453,0.1979,0.1589,-0.01],[0.4852,0.54,0.1979,0.1589,-0.01],[0.4853,0.5318,0.1979,0.1589,-0.07],[0.4853,0.523,0.1979,0.1589,-0.19],[0.4854,0.5153,0.1979,0.1589,-0.32],[0.4855,0.5093,0.1979,0.1589,-0.43],[0.4856,0.5046,0.1979,0.1589,-0.54],[0.4857,0.5007,0.1979,0.1589,-0.62],[0.4858,0.4977,0.1979,0.1589,-0.67],[0.4859,0.495,0.1979,0.1589,-0.71],[0.486,0.4926,0.1979,0.1589,-0.75],[0.4861,0.4904,0.1979,0.1589,-0.78],[0.4861,0.4888,0.1979,0.1589,-0.8],[0.4861,0.4874,0.1979,0.1589,-0.82],[0.4861,0.4864,0.1979,0.1589,-0.82],[0.4861,0.4859,0.1979,0.1589,-0.82],[0.4861,0.4856,0.1979,0.1589,-0.82],[0.4862,0.4855,0.1979,0.1589,-0.82],[0.4862,0.4855,0.1979,0.1589,-0.82],[0.4862,0.4855,0.1979,0.1589,-0.82],[0.4862,0.4855,0.1979,0.1589,-0.83],[0.4862,0.4855,0.1979,0.1589,-0.84],[0.4862,0.4855,0.1979,0.1589,-0.84],[0.4862,0.4855,0.1979,0.1589,-0.85],[0.4862,0.4855,0.1979,0.1589,-0.86],[0.4862,0.4855,0.1979,0.1589,-0.86],[0.4862,0.4855,0.1979,0.1589,-0.87],[0.4862,0.4855,0.1979,0.1589,-0.87],[0.4862,0.4855,0.1979,0.1589,-0.88],[0.4862,0.4855,0.1979,0.1589,-0.88],[0.4862,0.4855,0.1979,0.1589,-0.89],[0.4862,0.4855,0.1979,0.1589,-0.89],[0.4862,0.4855,0.1979,0.1589,-0.89]],"reach":[0.185,0.03,0.815,0.92],"script":[[0,"happy",0,0],[0.7,"blink",0,0],[0.74,"happy",0,0]]},"story-comedy":{"file":"clips/story-comedy.mp4","fps":24,"frames":124,"size":384,"face":"happy","rest":"story-comedy-rest","mask":true,"tracks":[[0.4859,0.3027,0.3018,0.2407,-0.35],[0.4859,0.3026,0.3018,0.2407,-0.36],[0.486,0.3026,0.3018,0.2407,-0.36],[0.4859,0.3026,0.3018,0.2407,-0.37],[0.486,0.3027,0.3018,0.2407,-0.39],[0.486,0.303,0.3018,0.2407,-0.39],[0.4861,0.3077,0.3018,0.2407,-0.41],[0.4861,0.3144,0.3018,0.2407,-0.42],[0.4861,0.3237,0.3018,0.2407,-0.44],[0.4861,0.3333,0.3018,0.2407,-0.45],[0.4861,0.3429,0.3018,0.2407,-0.46],[0.4859,0.348,0.3018,0.2407,-0.47],[0.4859,0.3512,0.3018,0.2407,-0.49],[0.4859,0.3516,0.3018,0.2407,-0.51],[0.4858,0.3517,0.3018,0.2407,-0.53],[0.4858,0.3517,0.3018,0.2407,-0.54],[0.4857,0.3517,0.3018,0.2407,-0.54],[0.4857,0.3517,0.3018,0.2407,-0.55],[0.4857,0.3518,0.3018,0.2407,-0.53],[0.4858,0.3538,0.3018,0.2407,-0.44],[0.4856,0.3556,0.3018,0.2407,-0.23],[0.4857,0.343,0.3018,0.2407,0.12],[0.4878,0.3222,0.3018,0.2407,0.68],[0.4914,0.3012,0.3018,0.2407,1.39],[0.4968,0.2825,0.3018,0.2407,2.34],[0.505,0.2683,0.3018,0.2407,3.48],[0.5151,0.2711,0.3018,0.2407,4.73],[0.5255,0.2782,0.3018,0.2407,6.02],[0.5357,0.2813,0.3018,0.2407,7.28],[0.5448,0.2772,0.3018,0.2407,8.32],[0.5515,0.2704,0.3018,0.2407,9.17],[0.5554,0.2645,0.3018,0.2407,9.76],[0.5568,0.268,0.3018,0.2407,10.16],[0.5567,0.2793,0.3018,0.2407,10.39],[0.5554,0.2933,0.3018,0.2407,10.52],[0.554,0.3022,0.3018,0.2407,10.58],[0.5535,0.3035,0.3018,0.2407,10.62],[0.5534,0.2956,0.3018,0.2407,10.66],[0.5534,0.2832,0.3018,0.2407,10.71],[0.5539,0.2727,0.3018,0.2407,10.71],[0.5547,0.269,0.3018,0.2407,10.68],[0.5553,0.2722,0.3018,0.2407,10.6],[0.556,0.2817,0.3018,0.2407,10.48],[0.5565,0.2917,0.3018,0.2407,10.41],[0.5564,0.2983,0.3018,0.2407,10.38],[0.5555,0.3019,0.3018,0.2407,10.4],[0.5544,0.2997,0.3018,0.2407,10.43],[0.5537,0.2924,0.3018,0.2407,10.52],[0.5534,0.2839,0.3018,0.2407,10.58],[0.5536,0.279,0.3018,0.2407,10.68],[0.5546,0.2778,0.3018,0.2407,10.79],[0.5554,0.2795,0.3018,0.2407,10.9],[0.5557,0.2817,0.3018,0.2407,10.98],[0.5553,0.2854,0.3018,0.2407,11.07],[0.555,0.2867,0.3018,0.2407,11.14],[0.5553,0.2864,0.3018,0.2407,11.2],[0.5561,0.2864,0.3018,0.2407,11.27],[0.5569,0.287,0.3018,0.2407,11.32],[0.5576,0.2844,0.3018,0.2407,11.38],[0.558,0.2825,0.3018,0.2407,11.45],[0.5575,0.2808,0.3018,0.2407,11.53],[0.5569,0.282,0.3018,0.2407,11.6],[0.556,0.2854,0.3018,0.2407,11.6],[0.5551,0.2905,0.3018,0.2407,11.29],[0.5525,0.2961,0.3018,0.2407,10.57],[0.5468,0.3018,0.3018,0.2407,9.37],[0.5361,0.3072,0.3018,0.2407,7.64],[0.5215,0.313,0.3018,0.2407,5.42],[0.5049,0.3201,0.3018,0.2407,2.88],[0.4883,0.3253,0.3018,0.2407,0.2],[0.4734,0.3263,0.3018,0.2407,-2.33],[0.4629,0.3234,0.3018,0.2407,-4.54],[0.4563,0.3184,0.3018,0.2407,-6.27],[0.4524,0.3135,0.3018,0.2407,-7.48],[0.4508,0.3101,0.3018,0.2407,-8.16],[0.4511,0.3089,0.3018,0.2407,-8.51],[0.452,0.3095,0.3018,0.2407,-8.61],[0.453,0.3119,0.3018,0.2407,-8.59],[0.4536,0.3151,0.3018,0.2407,-8.54],[0.4536,0.318,0.3018,0.2407,-8.53],[0.453,0.3195,0.3018,0.2407,-8.57],[0.4521,0.3195,0.3018,0.2407,-8.67],[0.451,0.3178,0.3018,0.2407,-8.8],[0.45,0.3146,0.3018,0.2407,-8.93],[0.4495,0.311,0.3018,0.2407,-9.02],[0.4496,0.308,0.3018,0.2407,-9.05],[0.4502,0.3062,0.3018,0.2407,-9.02],[0.4512,0.3062,0.3018,0.2407,-8.94],[0.4524,0.3078,0.3018,0.2407,-8.82],[0.4534,0.3107,0.3018,0.2407,-8.71],[0.4542,0.3142,0.3018,0.2407,-8.62],[0.4545,0.3175,0.3018,0.2407,-8.57],[0.4546,0.3198,0.3018,0.2407,-8.57],[0.4545,0.321,0.3018,0.2407,-8.57],[0.4543,0.3211,0.3018,0.2407,-8.57],[0.4542,0.3207,0.3018,0.2407,-8.54],[0.4542,0.3201,0.3018,0.2407,-8.38],[0.4548,0.3194,0.3018,0.2407,-8.02],[0.4563,0.3186,0.3018,0.2407,-7.46],[0.4592,0.3175,0.3018,0.2407,-6.71],[0.4629,0.3159,0.3018,0.2407,-5.78],[0.4676,0.314,0.3018,0.2407,-4.75],[0.4726,0.3116,0.3018,0.2407,-3.71],[0.4772,0.309,0.3018,0.2407,-2.76],[0.4808,0.3068,0.3018,0.2407,-1.95],[0.4834,0.3049,0.3018,0.2407,-1.31],[0.485,0.3034,0.3018,0.2407,-0.86],[0.4857,0.3026,0.3018,0.2407,-0.58],[0.4859,0.3023,0.3018,0.2407,-0.41],[0.4859,0.3023,0.3018,0.2407,-0.31],[0.4859,0.3022,0.3018,0.2407,-0.24],[0.4859,0.3021,0.3018,0.2407,-0.21],[0.4859,0.3021,0.3018,0.2407,-0.19],[0.4859,0.302,0.3018,0.2407,-0.19],[0.4859,0.302,0.3018,0.2407,-0.18],[0.4859,0.302,0.3018,0.2407,-0.18],[0.4859,0.302,0.3018,0.2407,-0.18],[0.4859,0.3021,0.3018,0.2407,-0.19],[0.4859,0.3021,0.3018,0.2407,-0.19],[0.4859,0.3021,0.3018,0.2407,-0.2],[0.4859,0.3022,0.3018,0.2407,-0.21],[0.486,0.3022,0.3018,0.2407,-0.22],[0.4859,0.3022,0.3018,0.2407,-0.22],[0.4859,0.3022,0.3018,0.2407,-0.22]],"reach":[0.1647,0.03,0.8353,0.92],"script":[[0,"happy",0,0],[0.85,"blink",0,0],[0.89,"happy",0,0]]},"story-daytime":{"file":"clips/story-daytime.mp4","fps":24,"frames":124,"size":384,"face":"happy","rest":"story-daytime-rest","mask":true,"tracks":[[0.5051,0.3264,0.2881,0.2289,-0.83],[0.505,0.3264,0.2881,0.2289,-0.81],[0.505,0.3265,0.2881,0.2289,-0.75],[0.5048,0.3265,0.2881,0.2289,-0.55],[0.504,0.3268,0.2881,0.2289,-0.23],[0.5023,0.3273,0.2881,0.2289,0.22],[0.5003,0.3278,0.2881,0.2289,0.81],[0.497,0.3286,0.2881,0.2289,1.5],[0.4933,0.3295,0.2881,0.2289,2.2],[0.4902,0.3303,0.2881,0.2289,2.9],[0.4871,0.3308,0.2881,0.2289,3.53],[0.4836,0.331,0.2881,0.2289,4.04],[0.4812,0.3305,0.2881,0.2289,4.42],[0.4787,0.3292,0.2881,0.2289,4.67],[0.4759,0.3272,0.2881,0.2289,4.79],[0.4741,0.3249,0.2881,0.2289,4.81],[0.4729,0.3222,0.2881,0.2289,4.77],[0.472,0.3198,0.2881,0.2289,4.63],[0.4714,0.3172,0.2881,0.2289,4.45],[0.4711,0.3152,0.2881,0.2289,4.2],[0.4709,0.3136,0.2881,0.2289,3.92],[0.4707,0.3121,0.2881,0.2289,3.63],[0.4705,0.3107,0.2881,0.2289,3.35],[0.4705,0.31,0.2881,0.2289,3.08],[0.4705,0.3092,0.2881,0.2289,2.83],[0.4706,0.3083,0.2881,0.2289,2.63],[0.4706,0.3077,0.2881,0.2289,2.46],[0.4707,0.3073,0.2881,0.2289,2.34],[0.4708,0.307,0.2881,0.2289,2.25],[0.4709,0.3067,0.2881,0.2289,2.18],[0.4709,0.3065,0.2881,0.2289,2.14],[0.4709,0.3065,0.2881,0.2289,2.11],[0.4709,0.3063,0.2881,0.2289,2.08],[0.4709,0.3063,0.2881,0.2289,2.04],[0.4709,0.3062,0.2881,0.2289,2.0],[0.4708,0.3062,0.2881,0.2289,1.97],[0.4708,0.3061,0.2881,0.2289,1.96],[0.4708,0.3062,0.2881,0.2289,1.95],[0.4709,0.3062,0.2881,0.2289,1.97],[0.4709,0.3063,0.2881,0.2289,2.0],[0.471,0.3063,0.2881,0.2289,2.04],[0.471,0.3064,0.2881,0.2289,2.08],[0.471,0.3065,0.2881,0.2289,2.12],[0.471,0.3066,0.2881,0.2289,2.17],[0.471,0.3067,0.2881,0.2289,2.23],[0.471,0.3069,0.2881,0.2289,2.31],[0.471,0.3071,0.2881,0.2289,2.44],[0.471,0.3075,0.2881,0.2289,2.64],[0.471,0.308,0.2881,0.2289,2.9],[0.4713,0.3088,0.2881,0.2289,3.16],[0.4719,0.3105,0.2881,0.2289,3.42],[0.4728,0.3126,0.2881,0.2289,3.61],[0.474,0.315,0.2881,0.2289,3.66],[0.4755,0.3176,0.2881,0.2289,3.51],[0.4774,0.3203,0.2881,0.2289,3.14],[0.4794,0.3222,0.2881,0.2289,2.61],[0.4817,0.3239,0.2881,0.2289,1.98],[0.4848,0.3252,0.2881,0.2289,1.29],[0.489,0.326,0.2881,0.2289,0.64],[0.4933,0.3265,0.2881,0.2289,0.1],[0.4973,0.3267,0.2881,0.2289,-0.3],[0.5008,0.3267,0.2881,0.2289,-0.57],[0.5034,0.3266,0.2881,0.2289,-0.73],[0.5046,0.3265,0.2881,0.2289,-0.8],[0.505,0.3264,0.2881,0.2289,-0.82],[0.5051,0.3263,0.2881,0.2289,-0.86],[0.5052,0.3262,0.2881,0.2289,-0.96],[0.5042,0.3267,0.2881,0.2289,-1.17],[0.5014,0.3278,0.2881,0.2289,-1.52],[0.4942,0.3299,0.2881,0.2289,-2.05],[0.4824,0.332,0.2881,0.2289,-2.79],[0.4677,0.3332,0.2881,0.2289,-3.72],[0.4524,0.3336,0.2881,0.2289,-4.82],[0.4365,0.333,0.2881,0.2289,-6.02],[0.4225,0.3312,0.2881,0.2289,-7.25],[0.4122,0.3295,0.2881,0.2289,-8.41],[0.4043,0.3285,0.2881,0.2289,-9.45],[0.398,0.3279,0.2881,0.2289,-10.3],[0.3945,0.3276,0.2881,0.2289,-10.91],[0.3946,0.3276,0.2881,0.2289,-11.29],[0.3973,0.3277,0.2881,0.2289,-11.38],[0.403,0.3279,0.2881,0.2289,-11.15],[0.4125,0.3286,0.2881,0.2289,-10.58],[0.4249,0.3299,0.2881,0.2289,-9.6],[0.4437,0.3315,0.2881,0.2289,-8.23],[0.4653,0.3328,0.2881,0.2289,-6.47],[0.4914,0.3335,0.2881,0.2289,-4.38],[0.5172,0.3333,0.2881,0.2289,-2.07],[0.5439,0.3323,0.2881,0.2289,0.29],[0.5652,0.331,0.2881,0.2289,2.55],[0.5834,0.3299,0.2881,0.2289,4.54],[0.5954,0.3294,0.2881,0.2289,6.19],[0.6037,0.3292,0.2881,0.2289,7.4],[0.6077,0.3294,0.2881,0.2289,8.22],[0.6091,0.3297,0.2881,0.2289,8.67],[0.6079,0.33,0.2881,0.2289,8.82],[0.6047,0.3304,0.2881,0.2289,8.67],[0.5998,0.3309,0.2881,0.2289,8.3],[0.5919,0.3318,0.2881,0.2289,7.65],[0.582,0.3328,0.2881,0.2289,6.79],[0.5698,0.3333,0.2881,0.2289,5.76],[0.5545,0.3327,0.2881,0.2289,4.6],[0.5393,0.3316,0.2881,0.2289,3.4],[0.5268,0.3299,0.2881,0.2289,2.3],[0.5166,0.3281,0.2881,0.2289,1.32],[0.5094,0.3266,0.2881,0.2289,0.58],[0.5069,0.3262,0.2881,0.2289,0.06],[0.5058,0.3261,0.2881,0.2289,-0.28],[0.5053,0.3262,0.2881,0.2289,-0.46],[0.5051,0.3262,0.2881,0.2289,-0.52],[0.5051,0.3261,0.2881,0.2289,-0.55],[0.5051,0.326,0.2881,0.2289,-0.57],[0.5051,0.326,0.2881,0.2289,-0.58],[0.5051,0.326,0.2881,0.2289,-0.59],[0.5051,0.326,0.2881,0.2289,-0.6],[0.5051,0.3261,0.2881,0.2289,-0.62],[0.5051,0.3262,0.2881,0.2289,-0.63],[0.5051,0.3262,0.2881,0.2289,-0.64],[0.5051,0.3262,0.2881,0.2289,-0.65],[0.5051,0.3262,0.2881,0.2289,-0.66],[0.5051,0.3262,0.2881,0.2289,-0.66],[0.5051,0.3262,0.2881,0.2289,-0.67],[0.5051,0.3261,0.2881,0.2289,-0.67],[0.5051,0.3261,0.2881,0.2289,-0.67]],"reach":[0.1211,0.03,0.8789,0.92],"script":[[0,"happy",0,0],[0.45,"blink",0,0],[0.5,"idle",1,0],[0.75,"happy",0,0]]},"story-detective":{"file":"clips/story-detective.mp4","fps":24,"frames":124,"size":384,"face":"idle","rest":"story-detective-rest","mask":true,"tracks":[[0.4842,0.3239,0.2709,0.2191,-0.26],[0.4842,0.3238,0.2709,0.2191,-0.26],[0.4834,0.3238,0.2709,0.2191,-0.27],[0.4812,0.3238,0.2709,0.2191,-0.28],[0.4788,0.3237,0.2709,0.2191,-0.28],[0.4765,0.3237,0.2709,0.2191,-0.27],[0.4731,0.3236,0.2709,0.2191,-0.25],[0.4698,0.3235,0.2709,0.2191,-0.22],[0.4677,0.3235,0.2709,0.2191,-0.2],[0.4649,0.3235,0.2709,0.2191,-0.2],[0.4621,0.3235,0.2709,0.2191,-0.26],[0.4588,0.3237,0.2709,0.2191,-0.37],[0.4559,0.324,0.2709,0.2191,-0.54],[0.4527,0.3244,0.2709,0.2191,-0.75],[0.4503,0.3249,0.2709,0.2191,-1.0],[0.4478,0.3254,0.2709,0.2191,-1.24],[0.4478,0.3266,0.2709,0.2191,-1.47],[0.4479,0.3277,0.2709,0.2191,-1.65],[0.4503,0.3295,0.2709,0.2191,-1.76],[0.4527,0.3313,0.2709,0.2191,-1.75],[0.4575,0.3337,0.2709,0.2191,-1.65],[0.4614,0.3352,0.2709,0.2191,-1.45],[0.47,0.337,0.2709,0.2191,-1.23],[0.477,0.338,0.2709,0.2191,-0.62],[0.4883,0.3391,0.2709,0.2191,0.32],[0.4972,0.3395,0.2709,0.2191,1.58],[0.5101,0.3393,0.2709,0.2191,3.11],[0.5182,0.3388,0.2709,0.2191,4.83],[0.5283,0.3376,0.2709,0.2191,6.37],[0.5347,0.3361,0.2709,0.2191,7.77],[0.5362,0.3344,0.2709,0.2191,8.79],[0.5338,0.3332,0.2709,0.2191,9.46],[0.5316,0.3319,0.2709,0.2191,9.91],[0.5308,0.3314,0.2709,0.2191,10.15],[0.5296,0.331,0.2709,0.2191,10.25],[0.5364,0.331,0.2709,0.2191,10.48],[0.543,0.3311,0.2709,0.2191,10.84],[0.5526,0.3316,0.2709,0.2191,11.2],[0.5586,0.3322,0.2709,0.2191,11.58],[0.5674,0.3331,0.2709,0.2191,11.84],[0.5732,0.334,0.2709,0.2191,11.98],[0.5808,0.3352,0.2709,0.2191,11.99],[0.5854,0.336,0.2709,0.2191,12.0],[0.5907,0.3369,0.2709,0.2191,12.0],[0.5933,0.3375,0.2709,0.2191,12.0],[0.5962,0.3381,0.2709,0.2191,12.0],[0.5975,0.3383,0.2709,0.2191,12.0],[0.599,0.3386,0.2709,0.2191,12.0],[0.5998,0.3388,0.2709,0.2191,12.0],[0.6008,0.3389,0.2709,0.2191,12.0],[0.6015,0.3391,0.2709,0.2191,12.0],[0.6021,0.3393,0.2709,0.2191,12.0],[0.6028,0.3394,0.2709,0.2191,12.0],[0.6034,0.3396,0.2709,0.2191,12.0],[0.6041,0.3397,0.2709,0.2191,12.0],[0.6047,0.3399,0.2709,0.2191,12.0],[0.6056,0.34,0.2709,0.2191,12.0],[0.6063,0.3402,0.2709,0.2191,12.0],[0.6073,0.3403,0.2709,0.2191,12.0],[0.608,0.3405,0.2709,0.2191,12.0],[0.6093,0.3409,0.2709,0.2191,12.0],[0.6102,0.3412,0.2709,0.2191,12.0],[0.6117,0.3419,0.2709,0.2191,12.0],[0.6129,0.3425,0.2709,0.2191,12.0],[0.6149,0.3434,0.2709,0.2191,12.0],[0.6162,0.344,0.2709,0.2191,12.0],[0.6179,0.3448,0.2709,0.2191,12.0],[0.619,0.3454,0.2709,0.2191,12.0],[0.6202,0.3459,0.2709,0.2191,12.0],[0.6208,0.3462,0.2709,0.2191,12.0],[0.6213,0.3465,0.2709,0.2191,12.0],[0.6216,0.3467,0.2709,0.2191,12.0],[0.6218,0.3469,0.2709,0.2191,12.0],[0.622,0.3471,0.2709,0.2191,12.0],[0.6218,0.3473,0.2709,0.2191,12.0],[0.621,0.3472,0.2709,0.2191,12.0],[0.6181,0.3464,0.2709,0.2191,12.0],[0.6152,0.3455,0.2709,0.2191,12.0],[0.6101,0.3437,0.2709,0.2191,12.0],[0.6034,0.3415,0.2709,0.2191,12.0],[0.6008,0.3402,0.2709,0.2191,12.0],[0.6003,0.3395,0.2709,0.2191,11.6],[0.595,0.3396,0.2709,0.2191,11.01],[0.592,0.3405,0.2709,0.2191,10.22],[0.5837,0.3425,0.2709,0.2191,9.21],[0.5725,0.3437,0.2709,0.2191,7.94],[0.5572,0.3447,0.2709,0.2191,6.78],[0.5442,0.3451,0.2709,0.2191,5.5],[0.5297,0.345,0.2709,0.2191,4.46],[0.5223,0.3443,0.2709,0.2191,3.5],[0.5116,0.3422,0.2709,0.2191,2.64],[0.5051,0.3404,0.2709,0.2191,1.93],[0.499,0.337,0.2709,0.2191,1.46],[0.4945,0.334,0.2709,0.2191,0.86],[0.4896,0.3302,0.2709,0.2191,0.39],[0.4874,0.3278,0.2709,0.2191,0.06],[0.4851,0.3255,0.2709,0.2191,-0.12],[0.4847,0.3246,0.2709,0.2191,-0.23],[0.4843,0.3237,0.2709,0.2191,-0.26],[0.4843,0.3237,0.2709,0.2191,-0.27],[0.4843,0.3237,0.2709,0.2191,-0.26],[0.4843,0.3237,0.2709,0.2191,-0.26],[0.4842,0.3237,0.2709,0.2191,-0.26],[0.4842,0.3237,0.2709,0.2191,-0.25],[0.4842,0.3237,0.2709,0.2191,-0.25],[0.4842,0.3237,0.2709,0.2191,-0.23],[0.4842,0.3237,0.2709,0.2191,-0.21],[0.4842,0.3237,0.2709,0.2191,-0.19],[0.4842,0.3237,0.2709,0.2191,-0.17],[0.4842,0.3237,0.2709,0.2191,-0.15],[0.4842,0.3237,0.2709,0.2191,-0.13],[0.4842,0.3237,0.2709,0.2191,-0.11],[0.4842,0.3237,0.2709,0.2191,-0.11],[0.4842,0.3237,0.2709,0.2191,-0.1],[0.4842,0.3237,0.2709,0.2191,-0.09],[0.4842,0.3237,0.2709,0.2191,-0.08],[0.4842,0.3238,0.2709,0.2191,-0.07],[0.4842,0.3238,0.2709,0.2191,-0.06],[0.4842,0.3238,0.2709,0.2191,-0.06],[0.4842,0.3238,0.2709,0.2191,-0.05],[0.4842,0.3238,0.2709,0.2191,-0.05],[0.4842,0.3238,0.2709,0.2191,-0.05],[0.4842,0.3238,0.2709,0.2191,-0.05],[0.4842,0.3238,0.2709,0.2191,-0.05]],"reach":[0.0995,0.03,0.9005,0.92],"script":[[0,"idle",-1,0],[0.8,"blink",-1,0],[0.84,"idle",-1,0]],"lens":[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,[0.3399,0.3458,0.0567,0.7,0],[0.3582,0.3438,0.0567,0.85,0],[0.3825,0.3413,0.0569,1.0,0],[0.3973,0.3399,0.0571,1.0,0],[0.4163,0.3382,0.0572,1.0,0],[0.4272,0.3373,0.0574,1.0,0],[0.4381,0.3363,0.0577,1.0,0],[0.4499,0.3362,0.0578,1.0,0],[0.4611,0.3363,0.058,1.0,0],[0.4734,0.3374,0.058,1.0,0],[0.4858,0.3384,0.0581,1.0,0],[0.5031,0.3398,0.0579,1.0,0],[0.5136,0.341,0.0577,1.0,0],[0.5281,0.3429,0.0575,1.0,0],[0.5373,0.3442,0.0574,1.0,0],[0.5491,0.3459,0.0573,1.0,0],[0.5559,0.3471,0.0573,1.0,0],[0.5636,0.3485,0.0574,1.0,0],[0.5672,0.349,0.0574,1.0,0],[0.5711,0.3497,0.0574,1.0,0],[0.5727,0.3499,0.0574,1.0,0],[0.5745,0.3502,0.0574,1.0,0],[0.5754,0.3503,0.0574,1.0,0],[0.5764,0.3505,0.0574,1.0,0],[0.5772,0.3505,0.0574,1.0,0],[0.5779,0.3506,0.0574,1.0,0],[0.5788,0.3507,0.0574,1.0,0],[0.5796,0.3508,0.0574,1.0,0],[0.5803,0.3509,0.0574,1.0,1],[0.581,0.351,0.0574,1.0,1],[0.5819,0.3511,0.0574,1.0,1],[0.5826,0.3513,0.0574,1.0,0],[0.5835,0.3515,0.0574,1.0,0],[0.5843,0.3518,0.0574,1.0,0],[0.5856,0.3523,0.0574,1.0,0],[0.5866,0.3527,0.0574,1.0,0],[0.5882,0.3535,0.0573,1.0,0],[0.5896,0.3542,0.0573,1.0,0],[0.5917,0.3552,0.0573,1.0,0],[0.5931,0.356,0.0573,1.0,0],[0.5949,0.357,0.0573,1.0,0],[0.596,0.3576,0.0573,1.0,0],[0.5972,0.3582,0.0573,1.0,0],[0.5979,0.3587,0.0574,1.0,0],[0.5985,0.3591,0.0574,1.0,0],[0.5988,0.3594,0.0574,1.0,0],[0.599,0.3598,0.0573,1.0,0],[0.5991,0.3601,0.0573,1.0,0],[0.5988,0.3602,0.0574,1.0,0],[0.5975,0.36,0.0573,1.0,0],[0.5927,0.358,0.0572,1.0,0],[0.5879,0.3559,0.0572,1.0,0],[0.5783,0.3521,0.0571,1.0,0],[0.5646,0.3468,0.0571,1.0,0],[0.5456,0.3403,0.0569,1.0,0],[0.5301,0.3355,0.0567,1.0,0],[0.5014,0.3292,0.0567,1.0,0],[0.4775,0.3245,0.0566,1.0,0],[0.4441,0.322,0.0566,0.85,0],[0.4171,0.321,0.0567,0.7,0],null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null],"blinks":[21,22,90,91]},"story-fiction":{"file":"clips/story-fiction.mp4","fps":24,"frames":124,"size":384,"face":"idle","rest":"story-fiction-rest","mask":true,"tracks":[[0.5027,0.3497,0.2922,0.2282,-0.39],[0.5027,0.3498,0.2922,0.2282,-0.4],[0.5028,0.3499,0.2922,0.2282,-0.4],[0.5028,0.3501,0.2922,0.2282,-0.41],[0.5028,0.3505,0.2922,0.2282,-0.39],[0.5029,0.3512,0.2922,0.2282,-0.38],[0.503,0.3525,0.2922,0.2282,-0.34],[0.5032,0.3546,0.2922,0.2282,-0.29],[0.5036,0.358,0.2922,0.2282,-0.22],[0.5042,0.3627,0.2922,0.2282,-0.15],[0.505,0.3686,0.2922,0.2282,-0.06],[0.506,0.3749,0.2922,0.2282,0.03],[0.5074,0.3815,0.2922,0.2282,0.13],[0.5092,0.3877,0.2922,0.2282,0.22],[0.5112,0.3931,0.2922,0.2282,0.31],[0.5135,0.3974,0.2922,0.2282,0.4],[0.5159,0.4011,0.2922,0.2282,0.5],[0.5182,0.4039,0.2922,0.2282,0.58],[0.5201,0.4058,0.2922,0.2282,0.67],[0.522,0.4073,0.2922,0.2282,0.75],[0.5235,0.4083,0.2922,0.2282,0.82],[0.5248,0.409,0.2922,0.2282,0.89],[0.526,0.4094,0.2922,0.2282,0.95],[0.527,0.4095,0.2922,0.2282,1.01],[0.528,0.4095,0.2922,0.2282,1.07],[0.5291,0.4093,0.2922,0.2282,1.12],[0.5304,0.409,0.2922,0.2282,1.16],[0.5317,0.4086,0.2922,0.2282,1.18],[0.533,0.4081,0.2922,0.2282,1.18],[0.5341,0.4074,0.2922,0.2282,1.16],[0.5347,0.4065,0.2922,0.2282,1.11],[0.5347,0.4056,0.2922,0.2282,1.03],[0.534,0.4046,0.2922,0.2282,0.9],[0.5325,0.4034,0.2922,0.2282,0.72],[0.5303,0.402,0.2922,0.2282,0.45],[0.5273,0.4004,0.2922,0.2282,0.1],[0.5235,0.3984,0.2922,0.2282,-0.39],[0.519,0.3962,0.2922,0.2282,-1.03],[0.5139,0.3938,0.2922,0.2282,-1.78],[0.5074,0.391,0.2922,0.2282,-2.64],[0.5007,0.389,0.2922,0.2282,-3.56],[0.4942,0.3878,0.2922,0.2282,-4.45],[0.4885,0.3876,0.2922,0.2282,-5.29],[0.4836,0.3884,0.2922,0.2282,-6.0],[0.4807,0.3902,0.2922,0.2282,-6.59],[0.4787,0.3923,0.2922,0.2282,-7.05],[0.4775,0.3943,0.2922,0.2282,-7.38],[0.4767,0.3959,0.2922,0.2282,-7.61],[0.4762,0.3971,0.2922,0.2282,-7.79],[0.4759,0.3979,0.2922,0.2282,-7.93],[0.4759,0.3984,0.2922,0.2282,-8.03],[0.4758,0.3988,0.2922,0.2282,-8.11],[0.4758,0.399,0.2922,0.2282,-8.17],[0.4758,0.3992,0.2922,0.2282,-8.21],[0.4757,0.3994,0.2922,0.2282,-8.22],[0.4756,0.3995,0.2922,0.2282,-8.2],[0.4755,0.3996,0.2922,0.2282,-8.13],[0.4752,0.3997,0.2922,0.2282,-8.0],[0.4745,0.3999,0.2922,0.2282,-7.81],[0.4734,0.4002,0.2922,0.2282,-7.54],[0.4722,0.4004,0.2922,0.2282,-7.24],[0.4709,0.4008,0.2922,0.2282,-6.91],[0.4695,0.4011,0.2922,0.2282,-6.59],[0.4686,0.4014,0.2922,0.2282,-6.28],[0.4681,0.4017,0.2922,0.2282,-6.0],[0.4678,0.4019,0.2922,0.2282,-5.78],[0.4678,0.4021,0.2922,0.2282,-5.61],[0.4682,0.4023,0.2922,0.2282,-5.45],[0.4689,0.4024,0.2922,0.2282,-5.31],[0.4699,0.4024,0.2922,0.2282,-5.19],[0.4714,0.4022,0.2922,0.2282,-5.05],[0.4732,0.402,0.2922,0.2282,-4.9],[0.4754,0.4016,0.2922,0.2282,-4.71],[0.478,0.4011,0.2922,0.2282,-4.49],[0.4812,0.4004,0.2922,0.2282,-4.25],[0.4844,0.3994,0.2922,0.2282,-3.98],[0.4875,0.3983,0.2922,0.2282,-3.68],[0.4905,0.3969,0.2922,0.2282,-3.39],[0.4931,0.3954,0.2922,0.2282,-3.09],[0.4947,0.3938,0.2922,0.2282,-2.81],[0.4961,0.392,0.2922,0.2282,-2.55],[0.4973,0.3903,0.2922,0.2282,-2.31],[0.4982,0.3882,0.2922,0.2282,-2.09],[0.4988,0.3863,0.2922,0.2282,-1.88],[0.4995,0.3844,0.2922,0.2282,-1.69],[0.4998,0.3825,0.2922,0.2282,-1.51],[0.5001,0.3801,0.2922,0.2282,-1.36],[0.5003,0.3779,0.2922,0.2282,-1.23],[0.5007,0.3756,0.2922,0.2282,-1.11],[0.501,0.3729,0.2922,0.2282,-1.02],[0.5014,0.3701,0.2922,0.2282,-0.94],[0.5017,0.3677,0.2922,0.2282,-0.87],[0.5019,0.3652,0.2922,0.2282,-0.81],[0.502,0.3627,0.2922,0.2282,-0.77],[0.5021,0.3605,0.2922,0.2282,-0.72],[0.5023,0.3581,0.2922,0.2282,-0.68],[0.5024,0.3559,0.2922,0.2282,-0.63],[0.5025,0.3541,0.2922,0.2282,-0.59],[0.5025,0.3527,0.2922,0.2282,-0.55],[0.5026,0.3516,0.2922,0.2282,-0.52],[0.5026,0.3511,0.2922,0.2282,-0.48],[0.5026,0.3508,0.2922,0.2282,-0.46],[0.5026,0.3506,0.2922,0.2282,-0.44],[0.5026,0.3505,0.2922,0.2282,-0.43],[0.5026,0.3504,0.2922,0.2282,-0.44],[0.5026,0.3504,0.2922,0.2282,-0.45],[0.5026,0.3504,0.2922,0.2282,-0.46],[0.5027,0.3503,0.2922,0.2282,-0.48],[0.5027,0.3503,0.2922,0.2282,-0.48],[0.5026,0.3503,0.2922,0.2282,-0.48],[0.5026,0.3503,0.2922,0.2282,-0.48],[0.5026,0.3503,0.2922,0.2282,-0.47],[0.5026,0.3503,0.2922,0.2282,-0.46],[0.5026,0.3503,0.2922,0.2282,-0.45],[0.5026,0.3503,0.2922,0.2282,-0.44],[0.5026,0.3503,0.2922,0.2282,-0.43],[0.5026,0.3503,0.2922,0.2282,-0.43],[0.5026,0.3503,0.2922,0.2282,-0.43],[0.5026,0.3503,0.2922,0.2282,-0.42],[0.5026,0.3503,0.2922,0.2282,-0.42],[0.5026,0.3503,0.2922,0.2282,-0.42],[0.5026,0.3503,0.2922,0.2282,-0.42],[0.5026,0.3503,0.2922,0.2282,-0.43],[0.5026,0.3503,0.2922,0.2282,-0.43]],"reach":[0.1998,0.03,0.8002,0.92],"script":[[0,"idle",0,1],[0.55,"happy",0,0],[0.85,"blink",0,0],[0.89,"idle",0,0]]},"story-health":{"file":"clips/story-health.mp4","fps":24,"frames":124,"size":384,"face":"happy","rest":"story-health-rest","mask":true,"tracks":[[0.5022,0.3764,0.2677,0.2093,-0.33],[0.5022,0.3764,0.2677,0.2093,-0.34],[0.5022,0.3764,0.2677,0.2093,-0.34],[0.5022,0.3764,0.2677,0.2093,-0.37],[0.5022,0.3764,0.2677,0.2093,-0.43],[0.502,0.3764,0.2677,0.2093,-0.56],[0.5012,0.3764,0.2677,0.2093,-0.84],[0.4989,0.3764,0.2677,0.2093,-1.29],[0.4948,0.3763,0.2677,0.2093,-1.96],[0.4886,0.3764,0.2677,0.2093,-2.87],[0.4802,0.3767,0.2677,0.2093,-4.03],[0.4697,0.3773,0.2677,0.2093,-5.34],[0.4582,0.3782,0.2677,0.2093,-6.73],[0.4463,0.3796,0.2677,0.2093,-8.08],[0.4343,0.3814,0.2677,0.2093,-9.3],[0.4231,0.3834,0.2677,0.2093,-10.29],[0.4129,0.3858,0.2677,0.2093,-11.04],[0.4036,0.3881,0.2677,0.2093,-11.53],[0.3951,0.3906,0.2677,0.2093,-11.82],[0.3875,0.393,0.2677,0.2093,-11.96],[0.3806,0.3952,0.2677,0.2093,-12.0],[0.3748,0.3971,0.2677,0.2093,-12.0],[0.3698,0.3989,0.2677,0.2093,-12.0],[0.3656,0.4004,0.2677,0.2093,-12.0],[0.3625,0.4016,0.2677,0.2093,-12.0],[0.3598,0.4027,0.2677,0.2093,-12.0],[0.3576,0.4035,0.2677,0.2093,-12.0],[0.356,0.4041,0.2677,0.2093,-12.0],[0.3549,0.4045,0.2677,0.2093,-12.0],[0.3542,0.4048,0.2677,0.2093,-12.0],[0.3541,0.4049,0.2677,0.2093,-12.0],[0.3545,0.4048,0.2677,0.2093,-12.0],[0.3556,0.4044,0.2677,0.2093,-12.0],[0.3573,0.4038,0.2677,0.2093,-12.0],[0.3598,0.4029,0.2677,0.2093,-12.0],[0.3634,0.4016,0.2677,0.2093,-12.0],[0.368,0.3999,0.2677,0.2093,-12.0],[0.3737,0.398,0.2677,0.2093,-12.0],[0.3812,0.3955,0.2677,0.2093,-11.93],[0.3921,0.3924,0.2677,0.2093,-11.7],[0.4044,0.3892,0.2677,0.2093,-11.26],[0.4184,0.386,0.2677,0.2093,-10.54],[0.4333,0.383,0.2677,0.2093,-9.45],[0.4488,0.3803,0.2677,0.2093,-8.01],[0.4629,0.3786,0.2677,0.2093,-6.22],[0.4773,0.3773,0.2677,0.2093,-4.17],[0.4915,0.3765,0.2677,0.2093,-1.91],[0.506,0.3761,0.2677,0.2093,0.44],[0.5203,0.3763,0.2677,0.2093,2.77],[0.5344,0.377,0.2677,0.2093,4.93],[0.5503,0.3786,0.2677,0.2093,6.85],[0.5656,0.3807,0.2677,0.2093,8.49],[0.5801,0.3832,0.2677,0.2093,9.8],[0.5937,0.386,0.2677,0.2093,10.75],[0.6056,0.3888,0.2677,0.2093,11.38],[0.6138,0.391,0.2677,0.2093,11.76],[0.6206,0.393,0.2677,0.2093,11.94],[0.6256,0.3947,0.2677,0.2093,12.0],[0.6293,0.3959,0.2677,0.2093,12.0],[0.6324,0.397,0.2677,0.2093,12.0],[0.635,0.3979,0.2677,0.2093,12.0],[0.6371,0.3988,0.2677,0.2093,12.0],[0.6389,0.3995,0.2677,0.2093,12.0],[0.6405,0.4001,0.2677,0.2093,12.0],[0.6417,0.4006,0.2677,0.2093,12.0],[0.6425,0.4009,0.2677,0.2093,12.0],[0.643,0.4011,0.2677,0.2093,12.0],[0.6432,0.4012,0.2677,0.2093,12.0],[0.643,0.4012,0.2677,0.2093,12.0],[0.6421,0.4009,0.2677,0.2093,12.0],[0.6404,0.4003,0.2677,0.2093,12.0],[0.637,0.3991,0.2677,0.2093,12.0],[0.6315,0.3973,0.2677,0.2093,12.0],[0.6237,0.395,0.2677,0.2093,11.97],[0.6138,0.3921,0.2677,0.2093,11.85],[0.6019,0.389,0.2677,0.2093,11.62],[0.5894,0.386,0.2677,0.2093,11.19],[0.5766,0.3833,0.2677,0.2093,10.5],[0.5637,0.381,0.2677,0.2093,9.56],[0.5511,0.3792,0.2677,0.2093,8.34],[0.5393,0.3778,0.2677,0.2093,6.92],[0.5284,0.3768,0.2677,0.2093,5.45],[0.5187,0.3764,0.2677,0.2093,4.02],[0.5114,0.3762,0.2677,0.2093,2.77],[0.5065,0.3751,0.2677,0.2093,1.8],[0.5035,0.3733,0.2677,0.2093,1.13],[0.502,0.3705,0.2677,0.2093,0.76],[0.502,0.3671,0.2677,0.2093,0.62],[0.5019,0.3634,0.2677,0.2093,0.63],[0.5019,0.3604,0.2677,0.2093,0.72],[0.5019,0.3582,0.2677,0.2093,0.83],[0.5019,0.357,0.2677,0.2093,0.9],[0.5019,0.3564,0.2677,0.2093,0.92],[0.5019,0.3564,0.2677,0.2093,0.92],[0.502,0.3569,0.2677,0.2093,0.89],[0.502,0.3576,0.2677,0.2093,0.85],[0.502,0.3585,0.2677,0.2093,0.78],[0.5021,0.3596,0.2677,0.2093,0.72],[0.5021,0.3607,0.2677,0.2093,0.65],[0.5021,0.3619,0.2677,0.2093,0.58],[0.5021,0.3632,0.2677,0.2093,0.51],[0.5021,0.3647,0.2677,0.2093,0.46],[0.5021,0.366,0.2677,0.2093,0.41],[0.5021,0.3673,0.2677,0.2093,0.36],[0.5022,0.3684,0.2677,0.2093,0.3],[0.5022,0.3695,0.2677,0.2093,0.25],[0.5022,0.3707,0.2677,0.2093,0.19],[0.5022,0.3718,0.2677,0.2093,0.12],[0.5022,0.3726,0.2677,0.2093,0.04],[0.5023,0.3736,0.2677,0.2093,-0.03],[0.5023,0.3744,0.2677,0.2093,-0.1],[0.5023,0.3749,0.2677,0.2093,-0.16],[0.5023,0.3754,0.2677,0.2093,-0.22],[0.5023,0.3757,0.2677,0.2093,-0.26],[0.5023,0.376,0.2677,0.2093,-0.3],[0.5023,0.3761,0.2677,0.2093,-0.32],[0.5022,0.3762,0.2677,0.2093,-0.33],[0.5023,0.3762,0.2677,0.2093,-0.34],[0.5022,0.3763,0.2677,0.2093,-0.33],[0.5023,0.3763,0.2677,0.2093,-0.32],[0.5023,0.3763,0.2677,0.2093,-0.32],[0.5023,0.3763,0.2677,0.2093,-0.31],[0.5022,0.3763,0.2677,0.2093,-0.3],[0.5022,0.3763,0.2677,0.2093,-0.3]],"reach":[0.03,0.157,0.97,0.92],"script":[[0,"happy",0,0],[0.3,"blink",0,0],[0.6,"happy",0,0]]},"story-history":{"file":"clips/story-history.mp4","fps":24,"frames":124,"size":384,"face":"idle","rest":"story-history-rest","mask":true,"tracks":[[0.4989,0.3094,0.3009,0.2367,-0.51],[0.4989,0.3094,0.3009,0.2367,-0.52],[0.4989,0.3094,0.3009,0.2367,-0.53],[0.4989,0.3094,0.3009,0.2367,-0.54],[0.4989,0.3094,0.3009,0.2367,-0.55],[0.4989,0.3094,0.3009,0.2367,-0.56],[0.4988,0.3095,0.3009,0.2367,-0.56],[0.4988,0.3095,0.3009,0.2367,-0.56],[0.4988,0.3096,0.3009,0.2367,-0.55],[0.4989,0.3097,0.3009,0.2367,-0.52],[0.4989,0.3098,0.3009,0.2367,-0.48],[0.4989,0.3101,0.3009,0.2367,-0.45],[0.4988,0.3105,0.3009,0.2367,-0.42],[0.4988,0.3111,0.3009,0.2367,-0.38],[0.4987,0.3119,0.3009,0.2367,-0.36],[0.4987,0.3131,0.3009,0.2367,-0.34],[0.4986,0.3145,0.3009,0.2367,-0.32],[0.4986,0.3158,0.3009,0.2367,-0.32],[0.4986,0.3176,0.3009,0.2367,-0.31],[0.4986,0.3193,0.3009,0.2367,-0.32],[0.4986,0.3213,0.3009,0.2367,-0.33],[0.4986,0.3231,0.3009,0.2367,-0.35],[0.4985,0.3253,0.3009,0.2367,-0.38],[0.4985,0.3273,0.3009,0.2367,-0.42],[0.4985,0.3295,0.3009,0.2367,-0.47],[0.4985,0.3315,0.3009,0.2367,-0.54],[0.4985,0.3334,0.3009,0.2367,-0.59],[0.4984,0.3352,0.3009,0.2367,-0.64],[0.4984,0.3367,0.3009,0.2367,-0.67],[0.4984,0.3381,0.3009,0.2367,-0.7],[0.4984,0.3391,0.3009,0.2367,-0.71],[0.4984,0.3399,0.3009,0.2367,-0.72],[0.4983,0.3404,0.3009,0.2367,-0.71],[0.4983,0.3407,0.3009,0.2367,-0.72],[0.4983,0.3408,0.3009,0.2367,-0.72],[0.4983,0.3409,0.3009,0.2367,-0.73],[0.4983,0.3409,0.3009,0.2367,-0.74],[0.4984,0.3409,0.3009,0.2367,-0.76],[0.4984,0.3409,0.3009,0.2367,-0.78],[0.4984,0.3409,0.3009,0.2367,-0.79],[0.4984,0.3409,0.3009,0.2367,-0.81],[0.4984,0.3409,0.3009,0.2367,-0.84],[0.4985,0.3411,0.3009,0.2367,-0.88],[0.4986,0.3415,0.3009,0.2367,-0.92],[0.4991,0.3423,0.3009,0.2367,-0.97],[0.5,0.3436,0.3009,0.2367,-1.04],[0.5016,0.3453,0.3009,0.2367,-1.14],[0.504,0.3476,0.3009,0.2367,-1.28],[0.5072,0.35,0.3009,0.2367,-1.44],[0.5107,0.3529,0.3009,0.2367,-1.67],[0.5145,0.3558,0.3009,0.2367,-1.95],[0.5182,0.3588,0.3009,0.2367,-2.26],[0.5214,0.3619,0.3009,0.2367,-2.61],[0.5241,0.3651,0.3009,0.2367,-2.95],[0.5262,0.3673,0.3009,0.2367,-3.26],[0.5276,0.3697,0.3009,0.2367,-3.55],[0.5282,0.3732,0.3009,0.2367,-3.77],[0.5284,0.3749,0.3009,0.2367,-3.89],[0.5284,0.3746,0.3009,0.2367,-3.96],[0.5284,0.3768,0.3009,0.2367,-4.0],[0.5283,0.3797,0.3009,0.2367,-4.01],[0.5283,0.3795,0.3009,0.2367,-3.99],[0.5283,0.3785,0.3009,0.2367,-3.96],[0.5282,0.3801,0.3009,0.2367,-3.93],[0.5282,0.3816,0.3009,0.2367,-3.9],[0.5282,0.38,0.3009,0.2367,-3.85],[0.5282,0.3779,0.3009,0.2367,-3.78],[0.5282,0.3782,0.3009,0.2367,-3.72],[0.5282,0.3791,0.3009,0.2367,-3.67],[0.5282,0.3769,0.3009,0.2367,-3.61],[0.5282,0.3749,0.3009,0.2367,-3.58],[0.5282,0.3769,0.3009,0.2367,-3.59],[0.5281,0.3782,0.3009,0.2367,-3.62],[0.5282,0.376,0.3009,0.2367,-3.64],[0.5283,0.3758,0.3009,0.2367,-3.66],[0.5283,0.3777,0.3009,0.2367,-3.67],[0.5284,0.3771,0.3009,0.2367,-3.67],[0.5286,0.376,0.3009,0.2367,-3.63],[0.5286,0.3757,0.3009,0.2367,-3.57],[0.5288,0.3742,0.3009,0.2367,-3.48],[0.529,0.3708,0.3009,0.2367,-3.33],[0.5288,0.3673,0.3009,0.2367,-3.12],[0.5279,0.3636,0.3009,0.2367,-2.86],[0.5262,0.3594,0.3009,0.2367,-2.54],[0.5238,0.3547,0.3009,0.2367,-2.18],[0.5204,0.3493,0.3009,0.2367,-1.79],[0.5164,0.3437,0.3009,0.2367,-1.42],[0.5122,0.3379,0.3009,0.2367,-1.09],[0.5082,0.3324,0.3009,0.2367,-0.82],[0.5047,0.3273,0.3009,0.2367,-0.62],[0.502,0.3231,0.3009,0.2367,-0.48],[0.5002,0.3197,0.3009,0.2367,-0.41],[0.4991,0.3169,0.3009,0.2367,-0.38],[0.4987,0.3147,0.3009,0.2367,-0.37],[0.4986,0.3132,0.3009,0.2367,-0.38],[0.4985,0.3121,0.3009,0.2367,-0.41],[0.4985,0.3113,0.3009,0.2367,-0.43],[0.4986,0.3108,0.3009,0.2367,-0.46],[0.4986,0.3104,0.3009,0.2367,-0.48],[0.4986,0.3101,0.3009,0.2367,-0.5],[0.4986,0.31,0.3009,0.2367,-0.53],[0.4987,0.3098,0.3009,0.2367,-0.54],[0.4987,0.3097,0.3009,0.2367,-0.55],[0.4987,0.3097,0.3009,0.2367,-0.54],[0.4987,0.3096,0.3009,0.2367,-0.52],[0.4987,0.3095,0.3009,0.2367,-0.51],[0.4987,0.3095,0.3009,0.2367,-0.48],[0.4987,0.3095,0.3009,0.2367,-0.47],[0.4987,0.3095,0.3009,0.2367,-0.45],[0.4986,0.3094,0.3009,0.2367,-0.44],[0.4986,0.3094,0.3009,0.2367,-0.42],[0.4986,0.3094,0.3009,0.2367,-0.42],[0.4986,0.3094,0.3009,0.2367,-0.41],[0.4986,0.3094,0.3009,0.2367,-0.41],[0.4986,0.3094,0.3009,0.2367,-0.42],[0.4986,0.3094,0.3009,0.2367,-0.42],[0.4987,0.3094,0.3009,0.2367,-0.42],[0.4987,0.3094,0.3009,0.2367,-0.42],[0.4987,0.3095,0.3009,0.2367,-0.42],[0.4987,0.3094,0.3009,0.2367,-0.42],[0.4987,0.3094,0.3009,0.2367,-0.42],[0.4987,0.3094,0.3009,0.2367,-0.41],[0.4987,0.3094,0.3009,0.2367,-0.41],[0.4987,0.3094,0.3009,0.2367,-0.41]],"reach":[0.1788,0.03,0.8212,0.92],"script":[[0,"idle",0,1],[0.6,"happy",0,0],[0.85,"blink",0,0],[0.89,"idle",0,0]]},"story-morning":{"file":"clips/story-morning.mp4","fps":24,"frames":124,"size":384,"face":"idle","rest":"story-morning-rest","mask":true,"tracks":[[0.4984,0.276,0.3172,0.2498,-0.3],[0.4984,0.276,0.3172,0.2498,-0.3],[0.4984,0.2761,0.3172,0.2498,-0.29],[0.4984,0.2761,0.3172,0.2498,-0.29],[0.4983,0.2761,0.3172,0.2498,-0.29],[0.4983,0.2761,0.3172,0.2498,-0.28],[0.4983,0.2762,0.3172,0.2498,-0.28],[0.4983,0.2762,0.3172,0.2498,-0.28],[0.4983,0.2762,0.3172,0.2498,-0.28],[0.4983,0.2761,0.3172,0.2498,-0.28],[0.4983,0.2761,0.3172,0.2498,-0.3],[0.4983,0.276,0.3172,0.2498,-0.31],[0.4983,0.2759,0.3172,0.2498,-0.32],[0.4983,0.2758,0.3172,0.2498,-0.31],[0.4984,0.2759,0.3172,0.2498,-0.29],[0.4983,0.2761,0.3172,0.2498,-0.25],[0.4983,0.2765,0.3172,0.2498,-0.2],[0.4983,0.2776,0.3172,0.2498,-0.13],[0.4983,0.279,0.3172,0.2498,-0.07],[0.4984,0.281,0.3172,0.2498,-0.01],[0.4984,0.2838,0.3172,0.2498,0.03],[0.4984,0.2872,0.3172,0.2498,0.05],[0.4985,0.2908,0.3172,0.2498,0.04],[0.4985,0.2954,0.3172,0.2498,0.01],[0.4985,0.3003,0.3172,0.2498,-0.02],[0.4985,0.3051,0.3172,0.2498,-0.06],[0.4985,0.3098,0.3172,0.2498,-0.11],[0.4984,0.3141,0.3172,0.2498,-0.17],[0.4983,0.3175,0.3172,0.2498,-0.23],[0.4981,0.3201,0.3172,0.2498,-0.31],[0.498,0.3219,0.3172,0.2498,-0.39],[0.4979,0.323,0.3172,0.2498,-0.49],[0.4978,0.3235,0.3172,0.2498,-0.59],[0.4977,0.3237,0.3172,0.2498,-0.7],[0.4976,0.3235,0.3172,0.2498,-0.79],[0.4975,0.323,0.3172,0.2498,-0.88],[0.4974,0.3221,0.3172,0.2498,-0.96],[0.4973,0.3209,0.3172,0.2498,-1.03],[0.4973,0.3192,0.3172,0.2498,-1.09],[0.4972,0.3173,0.3172,0.2498,-1.14],[0.4971,0.3151,0.3172,0.2498,-1.15],[0.4969,0.313,0.3172,0.2498,-1.14],[0.4968,0.3107,0.3172,0.2498,-1.07],[0.4968,0.3086,0.3172,0.2498,-0.95],[0.497,0.3065,0.3172,0.2498,-0.79],[0.4975,0.3045,0.3172,0.2498,-0.6],[0.4981,0.3027,0.3172,0.2498,-0.39],[0.4988,0.301,0.3172,0.2498,-0.21],[0.4994,0.2995,0.3172,0.2498,-0.11],[0.4997,0.2983,0.3172,0.2498,-0.1],[0.4996,0.2974,0.3172,0.2498,-0.15],[0.4992,0.2967,0.3172,0.2498,-0.28],[0.4987,0.2958,0.3172,0.2498,-0.43],[0.4983,0.2948,0.3172,0.2498,-0.58],[0.4981,0.2933,0.3172,0.2498,-0.69],[0.4984,0.2919,0.3172,0.2498,-0.76],[0.4988,0.2906,0.3172,0.2498,-0.8],[0.499,0.29,0.3172,0.2498,-0.84],[0.4992,0.2904,0.3172,0.2498,-0.87],[0.499,0.2922,0.3172,0.2498,-0.93],[0.4986,0.2948,0.3172,0.2498,-1.04],[0.4981,0.2982,0.3172,0.2498,-1.19],[0.498,0.302,0.3172,0.2498,-1.37],[0.4976,0.3057,0.3172,0.2498,-1.55],[0.4973,0.3092,0.3172,0.2498,-1.69],[0.4971,0.3126,0.3172,0.2498,-1.8],[0.497,0.3157,0.3172,0.2498,-1.89],[0.497,0.3185,0.3172,0.2498,-1.93],[0.497,0.3205,0.3172,0.2498,-1.92],[0.4971,0.3218,0.3172,0.2498,-1.91],[0.4973,0.322,0.3172,0.2498,-1.87],[0.4974,0.3212,0.3172,0.2498,-1.8],[0.4974,0.319,0.3172,0.2498,-1.69],[0.4976,0.3165,0.3172,0.2498,-1.55],[0.4978,0.3127,0.3172,0.2498,-1.37],[0.498,0.3088,0.3172,0.2498,-1.16],[0.4983,0.3051,0.3172,0.2498,-0.91],[0.4985,0.3014,0.3172,0.2498,-0.67],[0.4985,0.2973,0.3172,0.2498,-0.46],[0.4986,0.2941,0.3172,0.2498,-0.3],[0.4986,0.291,0.3172,0.2498,-0.19],[0.4986,0.2882,0.3172,0.2498,-0.13],[0.4985,0.2856,0.3172,0.2498,-0.11],[0.4985,0.2838,0.3172,0.2498,-0.11],[0.4985,0.282,0.3172,0.2498,-0.11],[0.4985,0.2804,0.3172,0.2498,-0.12],[0.4985,0.2791,0.3172,0.2498,-0.12],[0.4984,0.2781,0.3172,0.2498,-0.1],[0.4984,0.277,0.3172,0.2498,-0.1],[0.4984,0.2762,0.3172,0.2498,-0.09],[0.4984,0.2756,0.3172,0.2498,-0.08],[0.4984,0.275,0.3172,0.2498,-0.08],[0.4985,0.2745,0.3172,0.2498,-0.09],[0.4985,0.2743,0.3172,0.2498,-0.09],[0.4985,0.2742,0.3172,0.2498,-0.1],[0.4985,0.2742,0.3172,0.2498,-0.12],[0.4985,0.2743,0.3172,0.2498,-0.14],[0.4985,0.2744,0.3172,0.2498,-0.16],[0.4985,0.2746,0.3172,0.2498,-0.17],[0.4985,0.2749,0.3172,0.2498,-0.19],[0.4984,0.2752,0.3172,0.2498,-0.21],[0.4984,0.2754,0.3172,0.2498,-0.22],[0.4984,0.2757,0.3172,0.2498,-0.23],[0.4984,0.2758,0.3172,0.2498,-0.23],[0.4984,0.2758,0.3172,0.2498,-0.24],[0.4984,0.2759,0.3172,0.2498,-0.25],[0.4984,0.2759,0.3172,0.2498,-0.27],[0.4984,0.276,0.3172,0.2498,-0.28],[0.4983,0.276,0.3172,0.2498,-0.29],[0.4983,0.2761,0.3172,0.2498,-0.29],[0.4983,0.276,0.3172,0.2498,-0.28],[0.4983,0.2759,0.3172,0.2498,-0.26],[0.4984,0.2758,0.3172,0.2498,-0.24],[0.4984,0.2756,0.3172,0.2498,-0.22],[0.4984,0.2755,0.3172,0.2498,-0.2],[0.4984,0.2753,0.3172,0.2498,-0.19],[0.4984,0.2752,0.3172,0.2498,-0.19],[0.4984,0.2751,0.3172,0.2498,-0.19],[0.4984,0.275,0.3172,0.2498,-0.2],[0.4984,0.2748,0.3172,0.2498,-0.21],[0.4984,0.2747,0.3172,0.2498,-0.21],[0.4984,0.2747,0.3172,0.2498,-0.22],[0.4984,0.2746,0.3172,0.2498,-0.22],[0.4984,0.2745,0.3172,0.2498,-0.22]],"reach":[0.1974,0.03,0.8026,0.92],"script":[[0,"idle",0,0],[0.25,"blink",0,0],[0.55,"happy",0,0]]},"story-news":{"file":"clips/story-news.mp4","fps":24,"frames":124,"size":384,"face":"idle","rest":"story-news-rest","mask":true,"tracks":[[0.503,0.3217,0.2842,0.2162,-0.52],[0.503,0.3216,0.2842,0.2162,-0.53],[0.503,0.3216,0.2842,0.2162,-0.53],[0.5029,0.3215,0.2842,0.2162,-0.55],[0.5029,0.3215,0.2842,0.2162,-0.57],[0.5029,0.3215,0.2842,0.2162,-0.58],[0.5029,0.3215,0.2842,0.2162,-0.6],[0.5029,0.3215,0.2842,0.2162,-0.62],[0.5029,0.3215,0.2842,0.2162,-0.64],[0.5029,0.3215,0.2842,0.2162,-0.65],[0.5029,0.3215,0.2842,0.2162,-0.66],[0.5029,0.3215,0.2842,0.2162,-0.66],[0.5029,0.3215,0.2842,0.2162,-0.65],[0.5029,0.3215,0.2842,0.2162,-0.65],[0.5029,0.3215,0.2842,0.2162,-0.65],[0.5029,0.3215,0.2842,0.2162,-0.64],[0.5029,0.3215,0.2842,0.2162,-0.61],[0.5029,0.3216,0.2842,0.2162,-0.59],[0.5029,0.3216,0.2842,0.2162,-0.55],[0.503,0.3217,0.2842,0.2162,-0.51],[0.503,0.3217,0.2842,0.2162,-0.47],[0.503,0.3216,0.2842,0.2162,-0.43],[0.5031,0.3216,0.2842,0.2162,-0.4],[0.5031,0.3215,0.2842,0.2162,-0.39],[0.5031,0.3214,0.2842,0.2162,-0.39],[0.5031,0.3214,0.2842,0.2162,-0.39],[0.5031,0.3214,0.2842,0.2162,-0.4],[0.5031,0.3214,0.2842,0.2162,-0.41],[0.5031,0.3215,0.2842,0.2162,-0.42],[0.5031,0.3215,0.2842,0.2162,-0.42],[0.5031,0.3216,0.2842,0.2162,-0.42],[0.5031,0.3217,0.2842,0.2162,-0.43],[0.5031,0.3219,0.2842,0.2162,-0.45],[0.5031,0.3223,0.2842,0.2162,-0.48],[0.5031,0.3227,0.2842,0.2162,-0.54],[0.5031,0.3232,0.2842,0.2162,-0.65],[0.5031,0.324,0.2842,0.2162,-0.81],[0.5031,0.3251,0.2842,0.2162,-1.05],[0.5029,0.3263,0.2842,0.2162,-1.38],[0.5024,0.3278,0.2842,0.2162,-1.76],[0.5013,0.3297,0.2842,0.2162,-2.16],[0.4997,0.3316,0.2842,0.2162,-2.62],[0.4979,0.3333,0.2842,0.2162,-3.13],[0.4962,0.3344,0.2842,0.2162,-3.66],[0.4929,0.3342,0.2842,0.2162,-4.23],[0.4892,0.3333,0.2842,0.2162,-4.83],[0.4854,0.3319,0.2842,0.2162,-5.43],[0.4815,0.3303,0.2842,0.2162,-6.03],[0.4775,0.329,0.2842,0.2162,-6.56],[0.4753,0.3286,0.2842,0.2162,-7.01],[0.4741,0.3284,0.2842,0.2162,-7.39],[0.4735,0.3283,0.2842,0.2162,-7.64],[0.4733,0.3283,0.2842,0.2162,-7.77],[0.4733,0.3283,0.2842,0.2162,-7.83],[0.4734,0.3283,0.2842,0.2162,-7.86],[0.4734,0.3283,0.2842,0.2162,-7.87],[0.4734,0.3283,0.2842,0.2162,-7.86],[0.4734,0.3283,0.2842,0.2162,-7.85],[0.4734,0.3283,0.2842,0.2162,-7.84],[0.4734,0.3283,0.2842,0.2162,-7.83],[0.4734,0.3283,0.2842,0.2162,-7.82],[0.4734,0.3283,0.2842,0.2162,-7.81],[0.4735,0.3284,0.2842,0.2162,-7.81],[0.4735,0.3283,0.2842,0.2162,-7.81],[0.4734,0.3283,0.2842,0.2162,-7.8],[0.4734,0.3283,0.2842,0.2162,-7.8],[0.4734,0.3283,0.2842,0.2162,-7.8],[0.4734,0.3283,0.2842,0.2162,-7.8],[0.4734,0.3283,0.2842,0.2162,-7.81],[0.4734,0.3283,0.2842,0.2162,-7.8],[0.4734,0.3283,0.2842,0.2162,-7.8],[0.4734,0.3283,0.2842,0.2162,-7.8],[0.4734,0.3282,0.2842,0.2162,-7.8],[0.4734,0.3282,0.2842,0.2162,-7.8],[0.4734,0.3282,0.2842,0.2162,-7.78],[0.4734,0.3282,0.2842,0.2162,-7.77],[0.4738,0.3284,0.2842,0.2162,-7.71],[0.4748,0.3292,0.2842,0.2162,-7.57],[0.4765,0.3301,0.2842,0.2162,-7.34],[0.4793,0.3313,0.2842,0.2162,-7.0],[0.4825,0.3321,0.2842,0.2162,-6.52],[0.4864,0.3323,0.2842,0.2162,-5.89],[0.4901,0.3315,0.2842,0.2162,-5.15],[0.4933,0.3301,0.2842,0.2162,-4.34],[0.4961,0.3281,0.2842,0.2162,-3.54],[0.4984,0.3262,0.2842,0.2162,-2.76],[0.5,0.3247,0.2842,0.2162,-2.08],[0.5012,0.3236,0.2842,0.2162,-1.54],[0.5021,0.3228,0.2842,0.2162,-1.14],[0.5025,0.3223,0.2842,0.2162,-0.84],[0.5027,0.3221,0.2842,0.2162,-0.63],[0.5028,0.3219,0.2842,0.2162,-0.48],[0.5029,0.3218,0.2842,0.2162,-0.32],[0.503,0.3216,0.2842,0.2162,-0.18],[0.5031,0.3214,0.2842,0.2162,-0.04],[0.5034,0.3211,0.2842,0.2162,0.06],[0.5035,0.3211,0.2842,0.2162,0.12],[0.5035,0.3211,0.2842,0.2162,0.09],[0.5035,0.3212,0.2842,0.2162,0.02],[0.5034,0.3213,0.2842,0.2162,-0.12],[0.5031,0.3217,0.2842,0.2162,-0.27],[0.5031,0.3218,0.2842,0.2162,-0.42],[0.503,0.3218,0.2842,0.2162,-0.54],[0.503,0.3218,0.2842,0.2162,-0.64],[0.5029,0.3217,0.2842,0.2162,-0.67],[0.5029,0.3216,0.2842,0.2162,-0.69],[0.503,0.3216,0.2842,0.2162,-0.71],[0.503,0.3216,0.2842,0.2162,-0.72],[0.503,0.3216,0.2842,0.2162,-0.73],[0.503,0.3216,0.2842,0.2162,-0.74],[0.503,0.3216,0.2842,0.2162,-0.75],[0.503,0.3216,0.2842,0.2162,-0.75],[0.503,0.3216,0.2842,0.2162,-0.76],[0.503,0.3216,0.2842,0.2162,-0.76],[0.503,0.3216,0.2842,0.2162,-0.76],[0.503,0.3216,0.2842,0.2162,-0.75],[0.503,0.3216,0.2842,0.2162,-0.75],[0.503,0.3216,0.2842,0.2162,-0.75],[0.503,0.3216,0.2842,0.2162,-0.75],[0.503,0.3216,0.2842,0.2162,-0.75],[0.503,0.3216,0.2842,0.2162,-0.75],[0.503,0.3216,0.2842,0.2162,-0.75],[0.503,0.3216,0.2842,0.2162,-0.75],[0.503,0.3216,0.2842,0.2162,-0.75]],"reach":[0.1966,0.03,0.8034,0.92],"script":[[0,"idle",0,0],[0.25,"idle",0,1],[0.7,"happy",0,0],[0.85,"blink",0,0],[0.89,"idle",0,0]]},"story-night":{"file":"clips/story-night.mp4","fps":24,"frames":124,"size":384,"face":"idle","rest":"story-night-rest","mask":true,"tracks":[[0.4374,0.336,0.2636,0.2077,0.65],[0.4374,0.336,0.2636,0.2077,0.65],[0.4374,0.336,0.2636,0.2077,0.65],[0.4374,0.3361,0.2636,0.2077,0.64],[0.4376,0.3368,0.2636,0.2077,0.63],[0.4377,0.3389,0.2636,0.2077,0.62],[0.4378,0.3428,0.2636,0.2077,0.6],[0.438,0.3484,0.2636,0.2077,0.61],[0.4383,0.3555,0.2636,0.2077,0.65],[0.4386,0.3631,0.2636,0.2077,0.76],[0.439,0.3696,0.2636,0.2077,1.0],[0.4395,0.3738,0.2636,0.2077,1.38],[0.4399,0.3747,0.2636,0.2077,1.91],[0.4401,0.3722,0.2636,0.2077,2.59],[0.4401,0.3664,0.2636,0.2077,3.37],[0.4399,0.3581,0.2636,0.2077,4.21],[0.4396,0.3489,0.2636,0.2077,5.06],[0.4392,0.3392,0.2636,0.2077,5.9],[0.4389,0.3302,0.2636,0.2077,6.68],[0.4389,0.3227,0.2636,0.2077,7.41],[0.4391,0.3165,0.2636,0.2077,8.05],[0.4394,0.3114,0.2636,0.2077,8.62],[0.4399,0.3081,0.2636,0.2077,9.11],[0.4404,0.306,0.2636,0.2077,9.52],[0.4407,0.3048,0.2636,0.2077,9.84],[0.4409,0.3041,0.2636,0.2077,10.09],[0.441,0.3039,0.2636,0.2077,10.26],[0.4412,0.3037,0.2636,0.2077,10.38],[0.4413,0.3036,0.2636,0.2077,10.46],[0.4415,0.3034,0.2636,0.2077,10.55],[0.4416,0.3032,0.2636,0.2077,10.63],[0.4418,0.303,0.2636,0.2077,10.71],[0.4419,0.3028,0.2636,0.2077,10.79],[0.442,0.3027,0.2636,0.2077,10.87],[0.4422,0.3025,0.2636,0.2077,10.95],[0.4423,0.3023,0.2636,0.2077,11.03],[0.4425,0.3021,0.2636,0.2077,11.11],[0.4426,0.3019,0.2636,0.2077,11.19],[0.4427,0.3018,0.2636,0.2077,11.27],[0.4429,0.3016,0.2636,0.2077,11.35],[0.443,0.3014,0.2636,0.2077,11.43],[0.4432,0.3012,0.2636,0.2077,11.52],[0.4433,0.301,0.2636,0.2077,11.6],[0.4435,0.3009,0.2636,0.2077,11.67],[0.4436,0.3007,0.2636,0.2077,11.7],[0.4437,0.3005,0.2636,0.2077,11.69],[0.4437,0.3012,0.2636,0.2077,11.65],[0.4438,0.3031,0.2636,0.2077,11.58],[0.4437,0.3072,0.2636,0.2077,11.48],[0.4434,0.3134,0.2636,0.2077,11.37],[0.4417,0.3236,0.2636,0.2077,11.25],[0.4389,0.3342,0.2636,0.2077,11.09],[0.4343,0.3442,0.2636,0.2077,10.87],[0.4281,0.3529,0.2636,0.2077,10.55],[0.4197,0.3597,0.2636,0.2077,10.08],[0.4105,0.3626,0.2636,0.2077,9.47],[0.4001,0.3642,0.2636,0.2077,8.68],[0.3892,0.3649,0.2636,0.2077,7.74],[0.3782,0.3647,0.2636,0.2077,6.67],[0.3679,0.3639,0.2636,0.2077,5.54],[0.3585,0.3629,0.2636,0.2077,4.37],[0.3508,0.3618,0.2636,0.2077,3.24],[0.3452,0.3604,0.2636,0.2077,2.18],[0.3417,0.3589,0.2636,0.2077,1.25],[0.3408,0.3571,0.2636,0.2077,0.51],[0.3425,0.3551,0.2636,0.2077,0.04],[0.3472,0.3525,0.2636,0.2077,-0.14],[0.3547,0.3497,0.2636,0.2077,0.07],[0.3651,0.3467,0.2636,0.2077,0.68],[0.3785,0.3435,0.2636,0.2077,1.73],[0.3948,0.3406,0.2636,0.2077,3.1],[0.4133,0.3383,0.2636,0.2077,4.7],[0.4335,0.3368,0.2636,0.2077,6.37],[0.4548,0.3363,0.2636,0.2077,7.98],[0.4758,0.3368,0.2636,0.2077,9.37],[0.4968,0.3385,0.2636,0.2077,10.47],[0.5153,0.3406,0.2636,0.2077,11.22],[0.5309,0.343,0.2636,0.2077,11.69],[0.5434,0.3454,0.2636,0.2077,11.92],[0.5527,0.3476,0.2636,0.2077,12.0],[0.5577,0.349,0.2636,0.2077,12.0],[0.5603,0.35,0.2636,0.2077,12.0],[0.5603,0.3505,0.2636,0.2077,12.0],[0.5579,0.3505,0.2636,0.2077,12.0],[0.5536,0.3501,0.2636,0.2077,12.0],[0.5477,0.3493,0.2636,0.2077,12.0],[0.5403,0.3485,0.2636,0.2077,12.0],[0.5316,0.3476,0.2636,0.2077,12.0],[0.522,0.3468,0.2636,0.2077,12.0],[0.5114,0.3461,0.2636,0.2077,11.98],[0.5002,0.3456,0.2636,0.2077,11.87],[0.487,0.3455,0.2636,0.2077,11.58],[0.4743,0.3458,0.2636,0.2077,11.05],[0.4623,0.3459,0.2636,0.2077,10.23],[0.4523,0.3458,0.2636,0.2077,9.11],[0.4442,0.3453,0.2636,0.2077,7.75],[0.4401,0.344,0.2636,0.2077,6.25],[0.4378,0.3423,0.2636,0.2077,4.76],[0.437,0.3406,0.2636,0.2077,3.4],[0.4368,0.3389,0.2636,0.2077,2.27],[0.4369,0.3377,0.2636,0.2077,1.44],[0.4371,0.3369,0.2636,0.2077,0.91],[0.4372,0.3363,0.2636,0.2077,0.61],[0.4373,0.3361,0.2636,0.2077,0.48],[0.4372,0.3361,0.2636,0.2077,0.43],[0.4372,0.3361,0.2636,0.2077,0.42],[0.4372,0.3361,0.2636,0.2077,0.43],[0.4372,0.3361,0.2636,0.2077,0.43],[0.4372,0.3361,0.2636,0.2077,0.43],[0.4372,0.3361,0.2636,0.2077,0.44],[0.4372,0.3361,0.2636,0.2077,0.44],[0.4372,0.3361,0.2636,0.2077,0.45],[0.4372,0.3361,0.2636,0.2077,0.46],[0.4372,0.3361,0.2636,0.2077,0.47],[0.4372,0.3361,0.2636,0.2077,0.49],[0.4372,0.3361,0.2636,0.2077,0.5],[0.4372,0.3361,0.2636,0.2077,0.52],[0.4372,0.3361,0.2636,0.2077,0.53],[0.4372,0.3361,0.2636,0.2077,0.53],[0.4372,0.3361,0.2636,0.2077,0.54],[0.4372,0.3361,0.2636,0.2077,0.54],[0.4372,0.3361,0.2636,0.2077,0.53],[0.4372,0.3361,0.2636,0.2077,0.53],[0.4372,0.3361,0.2636,0.2077,0.53]],"reach":[0.089,0.03,0.911,0.92],"script":[[0,"blink",0,0],[0.3,"idle",0,0],[0.38,"blink",0,0],[0.7,"happy",0,0]]},"story-science":{"file":"clips/story-science.mp4","fps":24,"frames":124,"size":384,"face":"idle","rest":"story-science-rest","mask":true,"tracks":[[0.4999,0.3099,0.2962,0.2362,-0.56],[0.4999,0.3099,0.2962,0.2362,-0.56],[0.4999,0.3099,0.2962,0.2362,-0.56],[0.4999,0.3099,0.2962,0.2362,-0.57],[0.4999,0.3099,0.2962,0.2362,-0.55],[0.4999,0.3099,0.2962,0.2362,-0.53],[0.4999,0.3099,0.2962,0.2362,-0.5],[0.4999,0.31,0.2962,0.2362,-0.45],[0.4993,0.3103,0.2962,0.2362,-0.4],[0.4985,0.3109,0.2962,0.2362,-0.34],[0.4965,0.3123,0.2962,0.2362,-0.25],[0.4935,0.3138,0.2962,0.2362,-0.15],[0.4904,0.3154,0.2962,0.2362,-0.01],[0.4864,0.3172,0.2962,0.2362,0.18],[0.4828,0.3187,0.2962,0.2362,0.4],[0.4794,0.3197,0.2962,0.2362,0.64],[0.4766,0.3204,0.2962,0.2362,0.91],[0.4738,0.3211,0.2962,0.2362,1.16],[0.4721,0.3211,0.2962,0.2362,1.39],[0.4704,0.3211,0.2962,0.2362,1.61],[0.4699,0.3207,0.2962,0.2362,1.86],[0.4703,0.3202,0.2962,0.2362,2.13],[0.4713,0.3196,0.2962,0.2362,2.55],[0.4736,0.3189,0.2962,0.2362,3.22],[0.4761,0.3183,0.2962,0.2362,4.13],[0.4807,0.3179,0.2962,0.2362,5.25],[0.4873,0.3182,0.2962,0.2362,6.55],[0.4933,0.3186,0.2962,0.2362,7.87],[0.5001,0.3197,0.2962,0.2362,9.13],[0.5066,0.3209,0.2962,0.2362,10.22],[0.5115,0.3222,0.2962,0.2362,11.02],[0.5144,0.3233,0.2962,0.2362,11.56],[0.5178,0.3246,0.2962,0.2362,11.89],[0.5194,0.3253,0.2962,0.2362,12.0],[0.5214,0.326,0.2962,0.2362,12.0],[0.5226,0.3265,0.2962,0.2362,12.0],[0.5236,0.3268,0.2962,0.2362,12.0],[0.5243,0.3269,0.2962,0.2362,12.0],[0.525,0.327,0.2962,0.2362,12.0],[0.5258,0.3271,0.2962,0.2362,12.0],[0.5266,0.3272,0.2962,0.2362,12.0],[0.5276,0.3274,0.2962,0.2362,12.0],[0.5284,0.3276,0.2962,0.2362,12.0],[0.5292,0.3277,0.2962,0.2362,12.0],[0.5297,0.3278,0.2962,0.2362,12.0],[0.5302,0.328,0.2962,0.2362,12.0],[0.5304,0.328,0.2962,0.2362,12.0],[0.5306,0.328,0.2962,0.2362,12.0],[0.5307,0.328,0.2962,0.2362,12.0],[0.5309,0.328,0.2962,0.2362,12.0],[0.531,0.328,0.2962,0.2362,12.0],[0.531,0.328,0.2962,0.2362,12.0],[0.5311,0.3281,0.2962,0.2362,12.0],[0.5311,0.3281,0.2962,0.2362,12.0],[0.5311,0.3281,0.2962,0.2362,12.0],[0.5311,0.3281,0.2962,0.2362,12.0],[0.5311,0.3281,0.2962,0.2362,12.0],[0.5311,0.3281,0.2962,0.2362,12.0],[0.531,0.3281,0.2962,0.2362,12.0],[0.531,0.328,0.2962,0.2362,12.0],[0.531,0.328,0.2962,0.2362,12.0],[0.531,0.328,0.2962,0.2362,12.0],[0.531,0.328,0.2962,0.2362,12.0],[0.531,0.328,0.2962,0.2362,12.0],[0.531,0.328,0.2962,0.2362,12.0],[0.531,0.328,0.2962,0.2362,12.0],[0.531,0.3281,0.2962,0.2362,12.0],[0.531,0.3281,0.2962,0.2362,12.0],[0.5307,0.328,0.2962,0.2362,12.0],[0.5299,0.3277,0.2962,0.2362,12.0],[0.5284,0.3271,0.2962,0.2362,12.0],[0.5261,0.326,0.2962,0.2362,12.0],[0.5228,0.3246,0.2962,0.2362,11.94],[0.5187,0.3227,0.2962,0.2362,11.75],[0.5141,0.3207,0.2962,0.2362,11.37],[0.5093,0.3185,0.2962,0.2362,10.77],[0.5046,0.3163,0.2962,0.2362,9.9],[0.5,0.3143,0.2962,0.2362,8.81],[0.4954,0.3124,0.2962,0.2362,7.55],[0.4912,0.3107,0.2962,0.2362,6.22],[0.4877,0.3095,0.2962,0.2362,4.94],[0.4846,0.3085,0.2962,0.2362,3.8],[0.4823,0.308,0.2962,0.2362,2.84],[0.4811,0.3077,0.2962,0.2362,2.12],[0.4804,0.3077,0.2962,0.2362,1.59],[0.48,0.3077,0.2962,0.2362,1.21],[0.4802,0.3078,0.2962,0.2362,0.93],[0.4806,0.3079,0.2962,0.2362,0.71],[0.4816,0.3081,0.2962,0.2362,0.51],[0.4833,0.3083,0.2962,0.2362,0.35],[0.4858,0.3085,0.2962,0.2362,0.19],[0.4883,0.3088,0.2962,0.2362,0.06],[0.4915,0.3091,0.2962,0.2362,-0.06],[0.4944,0.3093,0.2962,0.2362,-0.16],[0.4967,0.3094,0.2962,0.2362,-0.24],[0.4982,0.3096,0.2962,0.2362,-0.3],[0.4995,0.3097,0.2962,0.2362,-0.36],[0.4998,0.3098,0.2962,0.2362,-0.4],[0.4999,0.3098,0.2962,0.2362,-0.43],[0.4999,0.3098,0.2962,0.2362,-0.45],[0.4998,0.3098,0.2962,0.2362,-0.47],[0.4999,0.3099,0.2962,0.2362,-0.48],[0.4999,0.3099,0.2962,0.2362,-0.48],[0.4999,0.3099,0.2962,0.2362,-0.49],[0.4999,0.3098,0.2962,0.2362,-0.5],[0.4999,0.3098,0.2962,0.2362,-0.5],[0.4999,0.3098,0.2962,0.2362,-0.51],[0.4999,0.3098,0.2962,0.2362,-0.51],[0.4999,0.3099,0.2962,0.2362,-0.52],[0.4999,0.3099,0.2962,0.2362,-0.52],[0.4999,0.3099,0.2962,0.2362,-0.52],[0.4999,0.3099,0.2962,0.2362,-0.52],[0.4999,0.3099,0.2962,0.2362,-0.53],[0.4999,0.3099,0.2962,0.2362,-0.53],[0.4999,0.3099,0.2962,0.2362,-0.54],[0.4999,0.3099,0.2962,0.2362,-0.54],[0.4999,0.3099,0.2962,0.2362,-0.55],[0.4999,0.3099,0.2962,0.2362,-0.55],[0.4999,0.3099,0.2962,0.2362,-0.55],[0.4999,0.3099,0.2962,0.2362,-0.55],[0.4999,0.3099,0.2962,0.2362,-0.55],[0.4999,0.3099,0.2962,0.2362,-0.55],[0.4999,0.3099,0.2962,0.2362,-0.55],[0.4999,0.3099,0.2962,0.2362,-0.55]],"reach":[0.1678,0.03,0.8322,0.92],"script":[[0,"idle",0,0],[0.25,"idle",-1,-1],[0.65,"happy",0,0],[0.85,"blink",0,0],[0.89,"idle",0,0]]},"story-tech":{"file":"clips/story-tech.mp4","fps":24,"frames":124,"size":384,"face":"idle","rest":"story-tech-rest","mask":true,"tracks":[[0.5459,0.3504,0.2718,0.2144,-0.65],[0.5459,0.3503,0.2718,0.2144,-0.63],[0.5456,0.3505,0.2718,0.2144,-0.59],[0.544,0.3509,0.2718,0.2144,-0.3],[0.5403,0.3521,0.2718,0.2144,0.35],[0.5339,0.354,0.2718,0.2144,1.37],[0.5215,0.3577,0.2718,0.2144,2.73],[0.5073,0.3615,0.2718,0.2144,4.43],[0.4932,0.3653,0.2718,0.2144,6.23],[0.4807,0.3685,0.2718,0.2144,7.99],[0.4704,0.3709,0.2718,0.2144,9.5],[0.4658,0.3717,0.2718,0.2144,10.65],[0.4631,0.3722,0.2718,0.2144,11.44],[0.4612,0.3727,0.2718,0.2144,11.89],[0.4594,0.3736,0.2718,0.2144,12.0],[0.4583,0.3744,0.2718,0.2144,12.0],[0.4576,0.375,0.2718,0.2144,12.0],[0.4577,0.3752,0.2718,0.2144,12.0],[0.4597,0.3745,0.2718,0.2144,12.0],[0.4623,0.3733,0.2718,0.2144,12.0],[0.4645,0.3724,0.2718,0.2144,12.0],[0.4659,0.3716,0.2718,0.2144,12.0],[0.4659,0.3715,0.2718,0.2144,12.0],[0.464,0.3724,0.2718,0.2144,12.0],[0.4618,0.3733,0.2718,0.2144,12.0],[0.4599,0.374,0.2718,0.2144,12.0],[0.4587,0.3744,0.2718,0.2144,12.0],[0.4587,0.3743,0.2718,0.2144,12.0],[0.4594,0.3738,0.2718,0.2144,12.0],[0.4605,0.3732,0.2718,0.2144,12.0],[0.4613,0.3728,0.2718,0.2144,12.0],[0.4616,0.3727,0.2718,0.2144,12.0],[0.4613,0.3728,0.2718,0.2144,12.0],[0.4606,0.3731,0.2718,0.2144,12.0],[0.46,0.3735,0.2718,0.2144,12.0],[0.46,0.3735,0.2718,0.2144,12.0],[0.4605,0.3733,0.2718,0.2144,12.0],[0.4616,0.3729,0.2718,0.2144,12.0],[0.4629,0.3725,0.2718,0.2144,12.0],[0.4636,0.3721,0.2718,0.2144,12.0],[0.4635,0.3721,0.2718,0.2144,12.0],[0.4627,0.3724,0.2718,0.2144,12.0],[0.4611,0.373,0.2718,0.2144,12.0],[0.4594,0.3735,0.2718,0.2144,12.0],[0.4585,0.374,0.2718,0.2144,12.0],[0.4583,0.3741,0.2718,0.2144,12.0],[0.4589,0.3738,0.2718,0.2144,12.0],[0.46,0.3733,0.2718,0.2144,12.0],[0.4605,0.3732,0.2718,0.2144,12.0],[0.4595,0.3736,0.2718,0.2144,12.0],[0.4554,0.376,0.2718,0.2144,12.0],[0.4503,0.38,0.2718,0.2144,12.0],[0.445,0.3855,0.2718,0.2144,12.0],[0.4406,0.3914,0.2718,0.2144,12.0],[0.4387,0.3956,0.2718,0.2144,12.0],[0.4407,0.3962,0.2718,0.2144,12.0],[0.4446,0.3934,0.2718,0.2144,12.0],[0.4498,0.3876,0.2718,0.2144,12.0],[0.4584,0.3796,0.2718,0.2144,12.0],[0.4666,0.3726,0.2718,0.2144,12.0],[0.4744,0.3675,0.2718,0.2144,12.0],[0.4813,0.3645,0.2718,0.2144,12.0],[0.4871,0.3632,0.2718,0.2144,12.0],[0.4892,0.3633,0.2718,0.2144,12.0],[0.4903,0.3635,0.2718,0.2144,12.0],[0.4909,0.3636,0.2718,0.2144,12.0],[0.4911,0.3636,0.2718,0.2144,12.0],[0.4911,0.3636,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.4909,0.3634,0.2718,0.2144,12.0],[0.4909,0.3634,0.2718,0.2144,12.0],[0.4909,0.3634,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.491,0.3635,0.2718,0.2144,12.0],[0.4898,0.3636,0.2718,0.2144,12.0],[0.4872,0.364,0.2718,0.2144,11.94],[0.482,0.3673,0.2718,0.2144,11.75],[0.4769,0.3746,0.2718,0.2144,11.33],[0.4764,0.3882,0.2718,0.2144,10.59],[0.4803,0.403,0.2718,0.2144,9.5],[0.4887,0.4135,0.2718,0.2144,8.11],[0.5019,0.4165,0.2718,0.2144,6.48],[0.517,0.4107,0.2718,0.2144,4.79],[0.528,0.3959,0.2718,0.2144,3.18],[0.5364,0.3783,0.2718,0.2144,1.8],[0.5416,0.3649,0.2718,0.2144,0.74],[0.5444,0.3561,0.2718,0.2144,0.04],[0.5455,0.352,0.2718,0.2144,-0.36],[0.5458,0.3506,0.2718,0.2144,-0.52],[0.5458,0.3506,0.2718,0.2144,-0.56],[0.5458,0.3506,0.2718,0.2144,-0.56],[0.5458,0.3506,0.2718,0.2144,-0.55],[0.5458,0.3506,0.2718,0.2144,-0.54],[0.5457,0.3505,0.2718,0.2144,-0.54],[0.5457,0.3505,0.2718,0.2144,-0.55],[0.5458,0.3505,0.2718,0.2144,-0.55],[0.5458,0.3505,0.2718,0.2144,-0.56],[0.5458,0.3505,0.2718,0.2144,-0.56],[0.5458,0.3505,0.2718,0.2144,-0.56],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57],[0.5458,0.3505,0.2718,0.2144,-0.57]],"reach":[0.1894,0.03,0.8106,0.92],"script":[[0,"idle",0,1],[0.4,"happy",0,0],[0.8,"blink",0,0],[0.84,"idle",0,0]]},"story-trophy":{"file":"clips/story-trophy.mp4","fps":24,"frames":124,"size":384,"face":"happy","rest":"story-trophy-rest","mask":true,"tracks":[[0.543,0.3385,0.2824,0.2181,-0.62],[0.543,0.3385,0.2824,0.2181,-0.63],[0.543,0.3385,0.2824,0.2181,-0.64],[0.543,0.3385,0.2824,0.2181,-0.66],[0.543,0.3385,0.2824,0.2181,-0.68],[0.543,0.3385,0.2824,0.2181,-0.69],[0.543,0.3385,0.2824,0.2181,-0.7],[0.543,0.3385,0.2824,0.2181,-0.7],[0.543,0.3385,0.2824,0.2181,-0.7],[0.543,0.3385,0.2824,0.2181,-0.69],[0.543,0.3385,0.2824,0.2181,-0.69],[0.543,0.3385,0.2824,0.2181,-0.69],[0.543,0.3385,0.2824,0.2181,-0.7],[0.543,0.3385,0.2824,0.2181,-0.71],[0.543,0.3385,0.2824,0.2181,-0.73],[0.5431,0.3385,0.2824,0.2181,-0.74],[0.5431,0.3385,0.2824,0.2181,-0.76],[0.5431,0.3385,0.2824,0.2181,-0.79],[0.5431,0.3385,0.2824,0.2181,-0.84],[0.5431,0.3385,0.2824,0.2181,-0.9],[0.543,0.3398,0.2824,0.2181,-0.98],[0.5431,0.3432,0.2824,0.2181,-1.06],[0.5432,0.348,0.2824,0.2181,-0.75],[0.543,0.3534,0.2824,0.2181,-0.5],[0.542,0.3595,0.2824,0.2181,-0.32],[0.5425,0.3614,0.2824,0.2181,-0.19],[0.5398,0.3589,0.2824,0.2181,-0.14],[0.5362,0.3494,0.2824,0.2181,-0.55],[0.5318,0.3349,0.2824,0.2181,-0.96],[0.5278,0.3181,0.2824,0.2181,-1.78],[0.5222,0.304,0.2824,0.2181,-2.55],[0.5198,0.2933,0.2824,0.2181,-3.23],[0.5183,0.2894,0.2824,0.2181,-3.84],[0.5179,0.2907,0.2824,0.2181,-4.35],[0.518,0.2937,0.2824,0.2181,-4.39],[0.5182,0.2962,0.2824,0.2181,-4.43],[0.5183,0.2972,0.2824,0.2181,-4.45],[0.5182,0.2969,0.2824,0.2181,-4.46],[0.518,0.2957,0.2824,0.2181,-4.49],[0.5179,0.2943,0.2824,0.2181,-4.51],[0.5178,0.2937,0.2824,0.2181,-4.53],[0.5178,0.2935,0.2824,0.2181,-4.56],[0.5177,0.2934,0.2824,0.2181,-4.59],[0.5177,0.2934,0.2824,0.2181,-4.61],[0.5177,0.2934,0.2824,0.2181,-4.64],[0.5177,0.2934,0.2824,0.2181,-4.63],[0.5177,0.2935,0.2824,0.2181,-4.6],[0.5177,0.2935,0.2824,0.2181,-4.54],[0.5183,0.285,0.2824,0.2181,-4.44],[0.5189,0.272,0.2824,0.2181,-4.31],[0.5193,0.256,0.2824,0.2181,-4.19],[0.5197,0.2462,0.2824,0.2181,-4.06],[0.5198,0.2506,0.2824,0.2181,-3.97],[0.5194,0.2677,0.2824,0.2181,-3.94],[0.5191,0.2887,0.2824,0.2181,-3.94],[0.5189,0.3113,0.2824,0.2181,-3.99],[0.5188,0.3255,0.2824,0.2181,-4.07],[0.5188,0.324,0.2824,0.2181,-4.16],[0.5185,0.3164,0.2824,0.2181,-4.23],[0.5181,0.3087,0.2824,0.2181,-4.3],[0.5176,0.3021,0.2824,0.2181,-4.35],[0.5171,0.2974,0.2824,0.2181,-4.39],[0.5168,0.2945,0.2824,0.2181,-4.44],[0.5168,0.2936,0.2824,0.2181,-4.47],[0.5168,0.2934,0.2824,0.2181,-4.43],[0.5171,0.2938,0.2824,0.2181,-4.29],[0.5177,0.2993,0.2824,0.2181,-4.04],[0.5184,0.3079,0.2824,0.2181,-3.68],[0.5197,0.3182,0.2824,0.2181,-3.22],[0.5216,0.3293,0.2824,0.2181,-2.73],[0.5241,0.3399,0.2824,0.2181,-2.22],[0.527,0.3453,0.2824,0.2181,-1.78],[0.5304,0.347,0.2824,0.2181,-1.41],[0.5338,0.3465,0.2824,0.2181,-1.13],[0.5371,0.3445,0.2824,0.2181,-0.93],[0.5396,0.3425,0.2824,0.2181,-0.84],[0.5414,0.3406,0.2824,0.2181,-0.76],[0.5425,0.3393,0.2824,0.2181,-0.7],[0.5431,0.3385,0.2824,0.2181,-0.66],[0.543,0.3385,0.2824,0.2181,-0.63],[0.543,0.3385,0.2824,0.2181,-0.61],[0.543,0.3385,0.2824,0.2181,-0.6],[0.5429,0.3385,0.2824,0.2181,-0.59],[0.5429,0.3385,0.2824,0.2181,-0.58],[0.5429,0.3385,0.2824,0.2181,-0.57],[0.5429,0.3384,0.2824,0.2181,-0.57],[0.5429,0.3384,0.2824,0.2181,-0.56],[0.5429,0.3384,0.2824,0.2181,-0.57],[0.543,0.3384,0.2824,0.2181,-0.57],[0.543,0.3384,0.2824,0.2181,-0.58],[0.543,0.3384,0.2824,0.2181,-0.59],[0.543,0.3384,0.2824,0.2181,-0.6],[0.543,0.3384,0.2824,0.2181,-0.61],[0.543,0.3384,0.2824,0.2181,-0.62],[0.543,0.3384,0.2824,0.2181,-0.63],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.65],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.65],[0.543,0.3384,0.2824,0.2181,-0.65],[0.543,0.3384,0.2824,0.2181,-0.66],[0.543,0.3384,0.2824,0.2181,-0.66],[0.543,0.3384,0.2824,0.2181,-0.67],[0.543,0.3384,0.2824,0.2181,-0.68],[0.543,0.3384,0.2824,0.2181,-0.68],[0.543,0.3384,0.2824,0.2181,-0.69],[0.543,0.3384,0.2824,0.2181,-0.69],[0.543,0.3384,0.2824,0.2181,-0.68],[0.543,0.3384,0.2824,0.2181,-0.68],[0.543,0.3384,0.2824,0.2181,-0.67],[0.543,0.3384,0.2824,0.2181,-0.66],[0.543,0.3384,0.2824,0.2181,-0.65],[0.543,0.3384,0.2824,0.2181,-0.65],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.64],[0.543,0.3384,0.2824,0.2181,-0.64]],"reach":[0.1216,0.03,0.8784,0.92],"script":[[0,"happy",0,0],[0.5,"blink",0,0],[0.54,"happy",0,0]]}}} \ No newline at end of file diff --git a/core/network/src/main/java/cx/aswin/boxlore/core/network/NetworkModule.kt b/core/network/src/main/java/cx/aswin/boxlore/core/network/NetworkModule.kt index acdb3255e..8bdd2f0ba 100644 --- a/core/network/src/main/java/cx/aswin/boxlore/core/network/NetworkModule.kt +++ b/core/network/src/main/java/cx/aswin/boxlore/core/network/NetworkModule.kt @@ -123,6 +123,9 @@ object NetworkModule { */ fun createBoxLoreApiV2(baseUrl: String, client: OkHttpClient): BoxLoreApiV2 = retrofit(baseUrl, client).create(BoxLoreApiV2::class.java) + /** Builds [SupportApi] on the same cached client as [BoxLoreApi] ([createCachedClient]). */ + fun createSupportApi(baseUrl: String, client: OkHttpClient): SupportApi = retrofit(baseUrl, client).create(SupportApi::class.java) + private fun retrofit(baseUrl: String, client: OkHttpClient): Retrofit = Retrofit.Builder() .baseUrl(baseUrl) .client(client) diff --git a/core/network/src/main/java/cx/aswin/boxlore/core/network/SupportApi.kt b/core/network/src/main/java/cx/aswin/boxlore/core/network/SupportApi.kt new file mode 100644 index 000000000..12af6c32e --- /dev/null +++ b/core/network/src/main/java/cx/aswin/boxlore/core/network/SupportApi.kt @@ -0,0 +1,30 @@ +package cx.aswin.boxlore.core.network + +import cx.aswin.boxlore.core.network.model.SupportListResponse +import cx.aswin.boxlore.core.network.model.SupportNewThreadRequest +import cx.aswin.boxlore.core.network.model.SupportPhotoResponse +import cx.aswin.boxlore.core.network.model.SupportThreadResponse +import okhttp3.RequestBody +import retrofit2.Call +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST + +/** + * Support conversations (`/support/...`). Requests carry the API session from the shared client, + * which is how the API knows which install a conversation belongs to. Every label, status and + * limit comes from the responses, so they change without an app update. + * Build it with [NetworkModule.createSupportApi] on the same cached client as [BoxLoreApi]. + */ +interface SupportApi { + /** `meta` (switches, limits, push topic) and this install's conversations. */ + @GET("support/threads") + fun threads(): Call + + @POST("support/threads") + fun startThread(@Body request: SupportNewThreadRequest): Call + + /** Raw JPEG, PNG or WebP bytes; answers 403 while photos are switched off. */ + @POST("support/photos") + fun uploadPhoto(@Body photo: RequestBody): Call +} diff --git a/core/network/src/main/java/cx/aswin/boxlore/core/network/model/SupportModels.kt b/core/network/src/main/java/cx/aswin/boxlore/core/network/model/SupportModels.kt new file mode 100644 index 000000000..f9000354b --- /dev/null +++ b/core/network/src/main/java/cx/aswin/boxlore/core/network/model/SupportModels.kt @@ -0,0 +1,67 @@ +package cx.aswin.boxlore.core.network.model + +import kotlinx.serialization.Serializable + +/** Wire shapes for [cx.aswin.boxlore.core.network.SupportApi]; unknown fields are ignored. */ +@Serializable +data class SupportListResponse( + val meta: SupportMetaDto? = null, + val threads: List = emptyList(), +) + +@Serializable +data class SupportMetaDto( + val notice: String? = null, + val canStartNew: Boolean = true, + val photos: SupportPhotoLimitsDto = SupportPhotoLimitsDto(), + val maxBody: Int = 2000, + val pushTopic: String? = null, + val replyAuthor: String? = null, +) + +@Serializable +data class SupportPhotoLimitsDto( + val enabled: Boolean = false, + val max: Int = 0, + val maxBytes: Long = 0, +) + +@Serializable +data class SupportThreadDto( + val id: String, + val category: String = "other", + val title: String = "", + val status: SupportStatusDto? = null, + val updatedAt: Long = 0, + val unread: Boolean = false, + val canReply: Boolean = true, +) + +@Serializable +data class SupportStatusDto( + val key: String = "waiting", + val label: String = "", + val tone: String = "neutral", + val showTime: Boolean = true, +) + +@Serializable +data class SupportThreadResponse(val thread: SupportThreadDto? = null) + +@Serializable +data class SupportPhotoResponse(val id: String) + +@Serializable +data class SupportNewThreadRequest( + val category: String, + val body: String, + val photos: List = emptyList(), + val appVersion: String? = null, + val device: String? = null, + val diagnostics: String? = null, + val logs: String? = null, +) + +/** Error bodies (`paused`, `limit`, `photos_off`, ...) with an optional message to show as is. */ +@Serializable +data class SupportErrorResponse(val error: String = "", val message: String? = null) diff --git a/core/support/README.md b/core/support/README.md new file mode 100644 index 000000000..444de9541 --- /dev/null +++ b/core/support/README.md @@ -0,0 +1,65 @@ +# `:core:support` + +## Purpose + +Listener feedback as support conversations with the boxlore API (`/support/...`): reading what the +server allows (switches, limits, the push topic), sending a new message, and uploading photos. +Wording, statuses and limits come from the server, so they change without an app update. + +It does not own any UI (the screens live in `:feature:settings`) and does not talk to Telegram or +send pushes; that is the API's job. + +## Public API + +- `SupportRepository`: `meta` (`StateFlow`), `refresh()`, `uploadPhoto(bytes, mimeType)`, + `send(OutgoingFeedback): SendResult`. Application-scoped: create it once in `AppContainer`. +- `SupportMeta`, `OutgoingFeedback`, `SendResult` (`Sent`, `SentByEmailForm`, `Refused`, `Failed`). +- `PhotoPrep`: shrinks a picked photo (long edge 1600 px, JPEG) before upload; `targetSize` is pure. + +## Internal structure + +```text +src/main/java/cx/aswin/boxlore/core/support/ + SupportRepository.kt API calls, old-form fallback, error mapping + SupportModels.kt SupportMeta, OutgoingFeedback, SendResult + PhotoPrep.kt photo downscaling +``` + +## Dependencies + +- → `:core:network` (`SupportApi`, wire models; built on the shared cached client) +- OkHttp, Retrofit, kotlinx-serialization (error bodies), coroutines + +Features use it; it never depends on a feature, the design system or the catalog. + +## Threading / lifecycle + +- All calls run on the injected IO dispatcher; callers use `viewModelScope`. +- Cancellation is rethrown; network and parsing errors become `SendResult.Failed` or `null`. + +## Persistence & identity + +- Nothing stored on the phone. The API knows the install from the API session the shared client + attaches (`X-Boxlore-Session`); the app never sends an id of its own. +- Fallback: when the support API answers 404, 401 or 503 `support_unavailable`, the message goes + through the old `POST /feedback` form (`legacySubmit`), so feedback sent before the server side is + live (or without a session) is never lost. The old form has no `audio` category (sent as `bug`) + and no photos. + +## Testing notes + +- `SupportRepositoryTest` runs the real `SupportApi` against MockWebServer: meta parsing, sending, + the fallback cases, refusals with the server's message, no connection, photos off, `PhotoPrep.targetSize`. + +```bash +./gradlew :core:support:testDebugUnitTest +``` + +## CI relevance + +Runs in the unit-test workflow like every `:core:*` module; included in merged Kover coverage. + +## See also + +- The API's support routes live in the separate API repository (`/support/...`). +- [`feature/settings/README.md`](../../feature/settings/README.md) (Feedback screens) diff --git a/core/support/build.gradle.kts b/core/support/build.gradle.kts new file mode 100644 index 000000000..06238bbd0 --- /dev/null +++ b/core/support/build.gradle.kts @@ -0,0 +1,52 @@ +plugins { + alias(libs.plugins.androidLibrary) + alias(libs.plugins.kover) +} + +kover { + currentProject { + createVariant("merged") { + add("debug") + } + } +} + +android { + namespace = "cx.aswin.boxlore.core.support" + compileSdk = 37 + + defaultConfig { + minSdk = 31 + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } + } + testOptions { + unitTests.all { + it.useJUnitPlatform() + } + } +} + +dependencies { + implementation(projects.core.network) + + implementation(libs.okhttp) + implementation(libs.retrofit) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.coroutines.android) + + testImplementation(projects.core.testing) + testImplementation(libs.junit) + testImplementation(libs.junit.jupiter) + testRuntimeOnly(libs.junit.vintage.engine) + testRuntimeOnly(libs.junit.platform.launcher) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.okhttp.mockwebserver) +} diff --git a/core/support/src/main/java/cx/aswin/boxlore/core/support/PhotoPrep.kt b/core/support/src/main/java/cx/aswin/boxlore/core/support/PhotoPrep.kt new file mode 100644 index 000000000..fe776b2f1 --- /dev/null +++ b/core/support/src/main/java/cx/aswin/boxlore/core/support/PhotoPrep.kt @@ -0,0 +1,39 @@ +package cx.aswin.boxlore.core.support + +import android.graphics.Bitmap +import android.graphics.ImageDecoder +import android.net.Uri +import java.io.ByteArrayOutputStream + +/** + * Shrinks a picked photo or screenshot before upload: the long edge to at most [LONG_EDGE] px, + * re-encoded as JPEG, so a screenshot is a few hundred KB instead of several MB. + */ +object PhotoPrep { + const val LONG_EDGE = 1600 + private const val JPEG_QUALITY = 82 + + /** Target size for an image of [width] x [height] (never upscaled). */ + fun targetSize(width: Int, height: Int, longEdge: Int = LONG_EDGE): Pair { + if (width <= 0 || height <= 0) return 0 to 0 + val longest = maxOf(width, height) + if (longest <= longEdge) return width to height + val scale = longEdge.toDouble() / longest + return maxOf(1, (width * scale).toInt()) to maxOf(1, (height * scale).toInt()) + } + + /** Decodes [uri] (from the photo picker), scales it down and returns JPEG bytes, or null. */ + fun prepare(contentResolver: android.content.ContentResolver, uri: Uri): ByteArray? = runCatching { + val source = ImageDecoder.createSource(contentResolver, uri) + val bitmap = ImageDecoder.decodeBitmap(source) { decoder, info, _ -> + val (w, h) = targetSize(info.size.width, info.size.height) + decoder.setTargetSize(w, h) + decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE + } + ByteArrayOutputStream().use { out -> + bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out) + bitmap.recycle() + out.toByteArray() + } + }.getOrNull() +} diff --git a/core/support/src/main/java/cx/aswin/boxlore/core/support/SupportModels.kt b/core/support/src/main/java/cx/aswin/boxlore/core/support/SupportModels.kt new file mode 100644 index 000000000..fed580052 --- /dev/null +++ b/core/support/src/main/java/cx/aswin/boxlore/core/support/SupportModels.kt @@ -0,0 +1,41 @@ +package cx.aswin.boxlore.core.support + +/** What the support API allows right now; every value comes from the server. */ +data class SupportMeta( + val canStartNew: Boolean, + val notice: String?, + val photosEnabled: Boolean, + val maxPhotos: Int, + val maxPhotoBytes: Long, + val maxBody: Int, + val pushTopic: String?, +) { + companion object { + /** Before the first answer, or when the API is unreachable: text only, nothing switched on. */ + val Offline = SupportMeta(canStartNew = true, notice = null, photosEnabled = false, maxPhotos = 0, maxPhotoBytes = 0, maxBody = 2000, pushTopic = null) + } +} + +/** One message to start a conversation. [photoIds] come from [SupportRepository.uploadPhoto]. */ +data class OutgoingFeedback( + val category: String, + val body: String, + val appVersion: String?, + val device: String?, + val diagnostics: String?, + val logs: String?, + val photoIds: List = emptyList(), +) + +sealed interface SendResult { + /** Saved as a conversation; replies will show up in the app. */ + data class Sent(val threadId: String) : SendResult + + /** The support API isn't available (not live yet, or no session): sent through the old form instead. */ + data object SentByEmailForm : SendResult + + /** New conversations are paused, or this phone hit today's limit; [message] comes from the server. */ + data class Refused(val message: String) : SendResult + + data object Failed : SendResult +} diff --git a/core/support/src/main/java/cx/aswin/boxlore/core/support/SupportRepository.kt b/core/support/src/main/java/cx/aswin/boxlore/core/support/SupportRepository.kt new file mode 100644 index 000000000..01cce57d0 --- /dev/null +++ b/core/support/src/main/java/cx/aswin/boxlore/core/support/SupportRepository.kt @@ -0,0 +1,130 @@ +package cx.aswin.boxlore.core.support + +import cx.aswin.boxlore.core.network.SupportApi +import cx.aswin.boxlore.core.network.model.SupportErrorResponse +import cx.aswin.boxlore.core.network.model.SupportListResponse +import cx.aswin.boxlore.core.network.model.SupportNewThreadRequest +import java.io.IOException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody +import retrofit2.Response + +/** + * Talks to the support API (`/support/...`). Sending never loses a message: when the support API + * isn't available yet (an older server, or no API session) it falls back to the old feedback form + * through [legacySubmit]. + */ +class SupportRepository( + private val api: SupportApi, + private val legacySubmit: suspend (category: String, message: String, appVersion: String, diagnostics: String?, logs: String?) -> Boolean, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) { + private val _meta = MutableStateFlow(SupportMeta.Offline) + + /** The last known switches and limits; [SupportMeta.Offline] until [refresh] succeeds. */ + val meta: StateFlow = _meta.asStateFlow() + + /** Reads the switches (and the conversation list, used from the next app update on). */ + suspend fun refresh(): SupportMeta? = withContext(ioDispatcher) { + val response = call { api.threads().execute() } ?: return@withContext null + if (!response.isSuccessful) return@withContext null + val meta = response.body()?.toMeta() ?: return@withContext null + _meta.value = meta + meta + } + + /** Uploads one prepared photo; null when photos are off or the upload fails. */ + suspend fun uploadPhoto(bytes: ByteArray, mimeType: String): String? = withContext(ioDispatcher) { + if (!_meta.value.photosEnabled) return@withContext null + val body = bytes.toRequestBody(mimeType.toMediaType()) + val response = call { api.uploadPhoto(body).execute() } ?: return@withContext null + response.body()?.id?.takeIf { response.isSuccessful } + } + + suspend fun send(feedback: OutgoingFeedback): SendResult = withContext(ioDispatcher) { + val request = SupportNewThreadRequest( + category = feedback.category, + body = feedback.body, + photos = feedback.photoIds, + appVersion = feedback.appVersion, + device = feedback.device, + diagnostics = feedback.diagnostics, + logs = feedback.logs, + ) + val response = call { api.startThread(request).execute() } ?: return@withContext SendResult.Failed + when { + response.isSuccessful -> response.body()?.thread?.id?.let { SendResult.Sent(it) } ?: SendResult.Failed + usesOldForm(response) -> sendThroughOldForm(feedback) + response.code() == HTTP_CONFLICT || response.code() == HTTP_TOO_MANY -> SendResult.Refused( + response.errorBody()?.message() ?: DEFAULT_REFUSAL, + ) + else -> SendResult.Failed + } + } + + private suspend fun sendThroughOldForm(feedback: OutgoingFeedback): SendResult { + // The old form has no audio category and no photo field. + val category = if (feedback.category == "audio") "bug" else feedback.category + val ok = legacySubmit(category, feedback.body, feedback.appVersion ?: "unknown", feedback.diagnostics, feedback.logs) + return if (ok) SendResult.SentByEmailForm else SendResult.Failed + } + + private inline fun call(block: () -> Response): Response? = try { + block() + } catch (e: CancellationException) { + throw e + } catch (_: IOException) { + null + } catch (_: RuntimeException) { + null + } + + companion object { + private const val HTTP_UNAUTHORIZED = 401 + private const val HTTP_NOT_FOUND = 404 + private const val HTTP_CONFLICT = 409 + private const val HTTP_TOO_MANY = 429 + private const val HTTP_UNAVAILABLE = 503 + private const val DEFAULT_REFUSAL = "This can't be sent right now. Please try again later." + private val errorJson = Json { ignoreUnknownKeys = true } + + /** Not live yet (404), no API session (401) or switched off (503 `support_unavailable`). */ + internal fun usesOldForm(response: Response<*>): Boolean { + val code = response.code() + if (code == HTTP_NOT_FOUND || code == HTTP_UNAUTHORIZED) return true + return code == HTTP_UNAVAILABLE && response.peekError()?.error == "support_unavailable" + } + + private fun Response<*>.peekError(): SupportErrorResponse? = errorBody()?.let { body -> + runCatching { errorJson.decodeFromString(SupportErrorResponse.serializer(), body.source().peek().readUtf8()) }.getOrNull() + } + + private fun okhttp3.ResponseBody.message(): String? = runCatching { + errorJson.decodeFromString(SupportErrorResponse.serializer(), string()).message?.takeIf { it.isNotBlank() } + }.getOrNull() + } +} + +internal fun SupportListResponse.toMeta(): SupportMeta? { + val m = meta ?: return null + return SupportMeta( + canStartNew = m.canStartNew, + notice = m.notice?.takeIf { it.isNotBlank() }, + photosEnabled = m.photos.enabled && m.photos.max > 0, + maxPhotos = m.photos.max.coerceIn(0, MAX_PHOTOS_CEILING), + maxPhotoBytes = m.photos.maxBytes, + maxBody = m.maxBody.coerceIn(1, MAX_BODY_CEILING), + pushTopic = m.pushTopic, + ) +} + +private const val MAX_PHOTOS_CEILING = 10 +private const val MAX_BODY_CEILING = 10_000 diff --git a/core/support/src/test/java/cx/aswin/boxlore/core/support/SupportRepositoryTest.kt b/core/support/src/test/java/cx/aswin/boxlore/core/support/SupportRepositoryTest.kt new file mode 100644 index 000000000..635c3bb63 --- /dev/null +++ b/core/support/src/test/java/cx/aswin/boxlore/core/support/SupportRepositoryTest.kt @@ -0,0 +1,133 @@ +package cx.aswin.boxlore.core.support + +import cx.aswin.boxlore.core.network.NetworkModule +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class SupportRepositoryTest { + private lateinit var server: MockWebServer + private val legacyCalls = mutableListOf>() + private var legacyOk = true + + private fun repository(): SupportRepository { + val api = NetworkModule.createSupportApi(server.url("/").toString(), OkHttpClient()) + return SupportRepository( + api = api, + legacySubmit = { category, message, version, diagnostics, logs -> + legacyCalls += listOf(category, message, version, diagnostics, logs) + legacyOk + }, + ioDispatcher = Dispatchers.Unconfined, + ) + } + + private val feedback = OutgoingFeedback( + category = "audio", + body = "Static after ten minutes", + appVersion = "0.0.30", + device = "Pixel 9", + diagnostics = "Android 17", + logs = "log line", + ) + + @BeforeEach + fun setUp() { + server = MockWebServer().apply { start() } + } + + @AfterEach + fun tearDown() { + runCatching { server.shutdown() } + } + + @Test + fun `reads the switches and limits from the server`() = runTest { + server.enqueue( + MockResponse().setBody( + """{"meta":{"notice":"Slow this week","canStartNew":true,"photos":{"enabled":true,"max":5,"maxBytes":5242880},"maxBody":2000,"pushTopic":"support_ab","replyAuthor":"boxlore team","extra":1},"threads":[]}""", + ), + ) + val repo = repository() + val meta = repo.refresh() + assertEquals(SupportMeta(true, "Slow this week", true, 5, 5_242_880, 2000, "support_ab"), meta) + assertEquals(meta, repo.meta.value) + assertEquals("/support/threads", server.takeRequest().path) + } + + @Test + fun `sends a conversation and keeps the audio category`() = runTest { + server.enqueue(MockResponse().setResponseCode(201).setBody("""{"thread":{"id":"a1b2c3d4e5f6a1b2c3d4e5f6","status":{"key":"waiting"}}}""")) + val result = repository().send(feedback) + assertEquals(SendResult.Sent("a1b2c3d4e5f6a1b2c3d4e5f6"), result) + val body = server.takeRequest().body.readUtf8() + assertTrue(body.contains("\"category\":\"audio\"")) + assertTrue(body.contains("\"device\":\"Pixel 9\"")) + assertTrue(legacyCalls.isEmpty()) + } + + @Test + fun `falls back to the old form when the support API is not there or has no session`() = runTest { + for (reply in listOf( + MockResponse().setResponseCode(404).setBody("""{"error":"Not Found"}"""), + MockResponse().setResponseCode(401).setBody("""{"error":"session_required"}"""), + MockResponse().setResponseCode(503).setBody("""{"error":"support_unavailable"}"""), + )) { + server.enqueue(reply) + assertEquals(SendResult.SentByEmailForm, repository().send(feedback)) + } + assertEquals(3, legacyCalls.size) + assertEquals(listOf("bug", "Static after ten minutes", "0.0.30", "Android 17", "log line"), legacyCalls.first()) + } + + @Test + fun `a failing support API is not mistaken for a missing one`() = runTest { + server.enqueue(MockResponse().setResponseCode(503).setBody("""{"error":"support_failed"}""")) + assertEquals(SendResult.Failed, repository().send(feedback)) + assertTrue(legacyCalls.isEmpty()) + } + + @Test + fun `shows the server's reason when paused or over the limit`() = runTest { + server.enqueue(MockResponse().setResponseCode(409).setBody("""{"error":"paused","message":"Back on Monday"}""")) + server.enqueue(MockResponse().setResponseCode(429).setBody("""{"error":"limit"}""")) + val repo = repository() + assertEquals(SendResult.Refused("Back on Monday"), repo.send(feedback)) + val limited = repo.send(feedback) + assertTrue(limited is SendResult.Refused) + assertFalse((limited as SendResult.Refused).message.isBlank()) + } + + @Test + fun `no connection is a failure, and the old form result is passed on`() = runTest { + server.shutdown() + assertEquals(SendResult.Failed, repository().send(feedback)) + server = MockWebServer().apply { start() } + legacyOk = false + server.enqueue(MockResponse().setResponseCode(404)) + assertEquals(SendResult.Failed, repository().send(feedback)) + } + + @Test + fun `photos are not uploaded while switched off`() = runTest { + assertNull(repository().uploadPhoto(byteArrayOf(1, 2, 3), "image/jpeg")) + assertEquals(0, server.requestCount) + } + + @Test + fun `photo target size keeps the shape and never upscales`() { + assertEquals(1600 to 800, PhotoPrep.targetSize(3200, 1600)) + assertEquals(720 to 1600, PhotoPrep.targetSize(1080, 2400)) + assertEquals(400 to 300, PhotoPrep.targetSize(400, 300)) + assertEquals(0 to 0, PhotoPrep.targetSize(0, 300)) + } +} diff --git a/core/testing/src/test/java/cx/aswin/boxlore/core/testing/architecture/ArchitectureGuardTest.kt b/core/testing/src/test/java/cx/aswin/boxlore/core/testing/architecture/ArchitectureGuardTest.kt index c493e5737..3d7468ddf 100644 --- a/core/testing/src/test/java/cx/aswin/boxlore/core/testing/architecture/ArchitectureGuardTest.kt +++ b/core/testing/src/test/java/cx/aswin/boxlore/core/testing/architecture/ArchitectureGuardTest.kt @@ -442,6 +442,7 @@ class ArchitectureGuardTest { "rss" to "cx.aswin.boxlore.core.rss", "ranking" to "cx.aswin.boxlore.core.ranking", "database" to "cx.aswin.boxlore.core.database", + "support" to "cx.aswin.boxlore.core.support", ) val violations = modulePackageRoots.flatMap { (module, expectedRoot) -> diff --git a/feature/settings/README.md b/feature/settings/README.md index b7499e53a..479b35aee 100644 --- a/feature/settings/README.md +++ b/feature/settings/README.md @@ -20,7 +20,8 @@ Owns the unified Settings hub, category pages (Account, Sync & Backups, Appearan - `SettingsScreen`, `SettingsViewModel`, `SettingsViewModelAssembler`, and `ProfileSettingsDestination` for the settings hub and sub-pages. - Shared `SettingsScaffold` headers use a smaller 36sp expanded title and naturally wrap to two lines when needed, including Cloud Sync & Backups. The expanded title smoothly reduces to 22sp on scroll; the separate compact row always uses 22sp and retains two-line support for narrow screens and larger system fonts. Material's two-row app bar measures the title height, so a hidden expanded title cannot inflate the navigation row or force a single-line ellipsis. - `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. +- `FeedbackScreen`, `FeedbackViewModel`, `DiagnosticCollector`, and `LogcatCollector` under `feedback/` for sticky draft feedback submissions, in-process sanitized logcat extraction, device diagnostics, and GitHub issue reporting. `FeedbackScreen(supportRepository, boxcastPrefs, onBack, bottomContentPadding)` takes `SupportRepository` from `:core:support` (app wiring passes the mini player clearance). +- **Feedback, redesigned 2026-11-02 (support conversations, phase 2).** "Share your thoughts" opens straight on the form: bold circular type pills (Feature idea, Bug report, Audio / Stream, General; `FeedbackTypePills`), a roomy message card that takes the free height, photos (only while the server's `meta.photos` switch is on; picked with the system photo picker, no storage permission, downscaled to 1600 px JPEG by `PhotoPrep`), an Attach diagnostics row, **Send feedback** at the bottom and a **How we handle feedback** link to the privacy page. Windows shorter than 600dp scroll with a 220dp message card. While typing (message focused and the keyboard up) the pills fold into one pill in the header and a slim bar with photos, diagnostics and **Send** rides on the keyboard, which replaces the bottom chrome padding (`isTypingLayout`, `feedbackBottomPadding`). The top-bar menu has Preview diagnostics, Rate on Play, Report on GitHub and Discard draft. The email and steps fields are gone; an older draft's steps join its message. Send is disabled while the server pauses new messages (`canStartNew`), with the server's notice or `PAUSED_NOTICE`; a refusal (today's limit) shows the server's words and keeps the draft. After sending, `FeedbackSentScreen` shows Echo's `feedback-sent` sequence (magnifier, then rest), "Feedback sent" and, when the message became a conversation and notifications are off, a **Know when the team replies** card (`canAskForNotifications`: Android's prompt while it will still show, otherwise the app's notification settings; re-checked on resume, **Not now** hides it). A message that fell back to the old one-way form (`SendResult.SentByEmailForm`) gets neither the reply promise nor the card (`feedbackSentBody`). - `AutoDownloadSettingsScreen` and `SmartDownloadsSettingsScreen` under `downloads/` for download management. - `AccountSettingsPage`, `SyncAndBackupsPage`, `AppearanceSettingsPage`, `PlaybackSettingsPage`, `PrivacySettingsPage`, `LibrarySettingsPage`, `DownloadsSettingsPage`, `AboutSettingsPage`, `SupportDevelopmentPage`. - Sync & Backups export copy names only what the JSON backup writes: subscriptions, folders, history, likes and settings (`FULL_LIBRARY_EXPORT_SUMMARY`, covered by `SyncAndBackupsPageTest`). Settings copy spells the product name boxlore in lowercase, including sentence starts. @@ -78,8 +79,10 @@ src/main/java/cx/aswin/boxlore/feature/settings/ feedback/ DiagnosticCollector.kt FeedbackDialogs.kt - FeedbackScreen.kt - FeedbackSuccessView.kt + FeedbackComposeParts.kt # type pills, message card, photo row, typing bar + FeedbackNotificationAsk.kt # notifications on/off, Android prompt or settings + FeedbackScreen.kt # compose screen, typing layout, FeedbackContent switch + FeedbackSentScreen.kt # Echo, thanks, notifications card, Done FeedbackViewModel.kt LogcatCollector.kt pages/ @@ -135,7 +138,7 @@ src/main/java/cx/aswin/boxlore/feature/settings/ ## Dependencies -- Project dependencies: `:core:model`, `:core:domain`, `:core:catalog`, `:core:downloads`, `:core:network`, `:core:prefs`, `:core:designsystem`, `:core:analytics`, `:core:ranking`, and `:core:rss`. +- Project dependencies: `:core:model`, `:core:domain`, `:core:catalog`, `:core:downloads`, `:core:network`, `:core:prefs`, `:core:designsystem`, `:core:analytics`, `:core:ranking`, `:core:rss`, and `:core:support`. - Libraries: Compose, Navigation, lifecycle ViewModel/runtime, Coil, Kotlin serialization, AndroidX Credentials, Google ID, Roborazzi. - Bundled Support world assets: 31 WebP drawables in `res/drawable-nodpi/support_*` (about 1 MB) and six display fonts in `res/font/support_*` (about 680 KB): Press Start 2P (neon), Michroma (orbit), Cinzel Decorative and Cinzel (alchemy), Rubik Microbe (abyss codenames only, bundled as a Latin subset) and Uncial Antiqua (sky). Each font's SIL Open Font License is in `licenses/`. Press Start 2P and Uncial Antiqua have Reserved Font Names, so they ship unmodified. - Reverse-edge rule: feature modules must not depend on other feature modules. @@ -159,7 +162,7 @@ src/main/java/cx/aswin/boxlore/feature/settings/ ## Testing notes -- Settings analytics: a control sends `settings_interaction` only when its value changes (`trackedForAnalytics` reads the current `AppearanceUiState`; `trackDownloadSetting` for Smart Downloads and automatic downloads, which now report each control as `smart_downloads_*` and `auto_download_*`; the playback lengths compare with the saved value). Player appearance reports `mini_player_seek_buttons_toggled` and `full_player_style_changed`. A sent feedback form sends `feedback_sent` (category, with email, with diagnostics; never the text), and the Feedback screen's rate button sends `store_review_opened`. `AppearanceActionsTrackedTest` and `AutoDownloadSettingsActionsTest` cover re-picks reporting nothing. +- Settings analytics: a control sends `settings_interaction` only when its value changes (`trackedForAnalytics` reads the current `AppearanceUiState`; `trackDownloadSetting` for Smart Downloads and automatic downloads, which now report each control as `smart_downloads_*` and `auto_download_*`; the playback lengths compare with the saved value). Player appearance reports `mini_player_seek_buttons_toggled` and `full_player_style_changed`. A sent feedback message sends `feedback_sent` (category with audio counted as bug, `with_email` always false now that there is no email field, with diagnostics; never the text) only once the server or the old form accepted it, and the Feedback screen's rate button sends `store_review_opened`. `AppearanceActionsTrackedTest` and `AutoDownloadSettingsActionsTest` cover re-picks reporting nothing. - Tablet top bars align with their bounded content column and keep a flat surface. Footer clearance follows actual mini-player visibility. RSS match confirmation remains scrollable in short viewports; the Support page fits without scrolling instead (see Support worlds). Phone spacing remains unchanged. - Tablet settings use the shared Utility cap (720dp), including Smart Downloads, Feedback and Support outside the ordinary settings scaffold. Existing account/editor caps remain smaller or bound each editor column. Phone constraints are unchanged. @@ -175,6 +178,7 @@ src/main/java/cx/aswin/boxlore/feature/settings/ - `SupportPageLayoutTest` covers the arrangement thresholds (full stacked page, side by side, mission card cut-off, larger text) and renders the control and every world in 392×800 and 360×600 portrait and 800×392 landscape windows with no scrollable node, the pay button and tier info fully on screen, and the mission card shown only when it fits. - `SupportEntryImpressionTest` covers one impression per view, none for a configuration restore, a new one after returning, and the real Settings hub's top-bar surface. - Not covered by JVM tests: rendering quality on real devices and fold postures. Those need a phone, tablet or foldable check. +- Feedback: `FeedbackViewModelTest` covers draft restore (old steps joined), the server's message limit, a conversation send (body trimmed, diagnostics, analytics without the text), the old-form fallback without the reply promise, refusal and failure keeping the draft with a retry, blank messages, photos ignored while switched off, de-duplicated picks up to the limit uploaded in order before the message, a failed or oversized photo sending nothing, paused sending, and Discard draft. `FeedbackLayoutPolicyTest` covers the typing-layout and keyboard-clearance rules, when Android's notification prompt can still show, and the sent-screen copy. `FeedbackAdaptiveTransitionTest` (Robolectric) keeps one live view model and screen while the window crosses the 600dp width, 480dp height and 840dp split gates in both directions, portrait/landscape tablets, a short 800×360 window that scrolls, and a fold going flat → tabletop → flat, then recreates it (`StateRestorationTester`): the typed message, the picked type and a reachable, enabled Send survive, Send goes out once, and the sent screen keeps Done and the notifications card (or its dismissal) through the same walk; an old-form send never shows the card. Not covered by JVM tests: the typing layout with a real keyboard (Robolectric has no IME insets), the photo picker, and Echo's motion; those need a phone and tablet check. Manual acceptance: on a phone, a tablet and a foldable, write a message, rotate and fold with the keyboard up and down, then send; the draft stays, Send stays reachable above the keyboard, and the sent screen offers notifications only while they are off. - Unit tests live under `feature/settings/src/test`. - `AccountPagePresentationTest` covers the onboarding and Settings layouts, picking each sign-in method and getting back to the cards, the check-email and confirm-email screens (resend cooldown), every sync state with its action, the Sync now label at 200% text in RTL, password-only Reset password, Echo press feedback, and that every Echo pose the page uses ships in the Echo manifest with a stage reach. `AccountPageAdaptiveTest` keeps one live page while the window moves phone → 599/600dp → tablet split → 479/480dp tall → portrait tablet → phone and checks that the chosen method, the typed email and the password mode survive, in both directions, and across activity recreation (`StateRestorationTester`). It also keeps a live syncing onboarding page across the same windows: the No need to wait note stays on screen above the tiles with Continue reachable, and finishing sync mid-resize turns all three tiles done and removes the note. Tests hold Echo on its resting pose with `LocalEchoMotion provides false` so the screen can go idle. App navigation tests cover hiding restored playback during Account and onboarding and restoring normal player presentation afterward. - `AutoDownloadSettingsActionsTest` exercises the same action callbacks and background presentation used by the screen: all four episode limits, invalid-limit rejection, help opening/dismissal, both background-control visibility states, and retaining all network/charging combinations when checks are disabled and re-enabled. No device or screenshot automation is required. diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index aa4a14c67..fd3c141f6 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -63,6 +63,7 @@ dependencies { implementation(projects.core.analytics) implementation(projects.core.ranking) implementation(projects.core.rss) + implementation(projects.core.support) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) 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 ef09514bc..cf8fd3dda 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 @@ -28,6 +28,12 @@ data class DiagnosticInfo( /** * Compact summary designed to fit comfortably inside the in-app feedback body (~120 chars). */ + /** "Google Pixel 9", without repeating a brand the model already starts with. */ + fun deviceLabel(): String { + val brand = manufacturer.replaceFirstChar { it.uppercaseChar() } + return if (model.startsWith(manufacturer, ignoreCase = true)) model else "$brand $model".trim() + } + fun toCondensedSummary(): String = "App: v$appVersion ($buildCode) | OS: Android $androidRelease (API $sdkInt) | Device: $manufacturer $model | Net: $networkType | Audio: $audioRoute" /** diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackComposeParts.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackComposeParts.kt new file mode 100644 index 000000000..d4c2db790 --- /dev/null +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackComposeParts.kt @@ -0,0 +1,371 @@ +package cx.aswin.boxlore.feature.settings.feedback + +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.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +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.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Send +import androidx.compose.material.icons.rounded.AddPhotoAlternate +import androidx.compose.material.icons.rounded.BugReport +import androidx.compose.material.icons.rounded.ChatBubbleOutline +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Headphones +import androidx.compose.material.icons.rounded.Image +import androidx.compose.material.icons.rounded.KeyboardArrowDown +import androidx.compose.material.icons.rounded.Lightbulb +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +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.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight + +internal const val FEEDBACK_MESSAGE_TAG = "feedback_message" +internal const val FEEDBACK_SEND_TAG = "feedback_send" +internal const val FEEDBACK_PILLS_TAG = "feedback_pills" +internal const val FEEDBACK_TYPING_BAR_TAG = "feedback_typing_bar" +internal const val FEEDBACK_PHOTOS_TAG = "feedback_photos" +internal const val FEEDBACK_PRIVACY_URL = "https://boxlore.in/privacy#feedback" + +internal val FeedbackCategory.icon: ImageVector + get() = when (this) { + FeedbackCategory.FEATURE -> Icons.Rounded.Lightbulb + FeedbackCategory.BUG -> Icons.Rounded.BugReport + FeedbackCategory.AUDIO -> Icons.Rounded.Headphones + FeedbackCategory.OTHER -> Icons.Rounded.ChatBubbleOutline + } + +/** The genre-chip look from Explore: round, 48 dp, icon first, primary container when chosen. */ +@Composable +internal fun FeedbackTypePill(category: FeedbackCategory, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { + FilterChip( + selected = selected, + onClick = onClick, + label = { Text(category.label, fontWeight = GoogleSansWeight.semiBold) }, + leadingIcon = { Icon(category.icon, contentDescription = null, modifier = Modifier.size(18.dp)) }, + modifier = modifier.heightIn(min = 48.dp), + shape = CircleShape, + border = null, + colors = FilterChipDefaults.filterChipColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + labelColor = MaterialTheme.colorScheme.onSurfaceVariant, + iconColor = MaterialTheme.colorScheme.onSurfaceVariant, + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, + selectedLeadingIconColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun FeedbackTypePills(selected: FeedbackCategory, onSelect: (FeedbackCategory) -> Unit, modifier: Modifier = Modifier) { + FlowRow( + modifier = modifier.testTag(FEEDBACK_PILLS_TAG), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + FeedbackCategory.entries.forEach { category -> + FeedbackTypePill(category = category, selected = category == selected, onClick = { onSelect(category) }) + } + } +} + +/** Compact type picker for the typing layout: the chosen pill, opening a menu of the four types. */ +@Composable +internal fun FeedbackTypeMenuPill(selected: FeedbackCategory, onSelect: (FeedbackCategory) -> Unit) { + var open by remember { mutableStateOf(false) } + Box { + Surface( + onClick = { open = true }, + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier + .heightIn(min = 40.dp) + .semantics { contentDescription = "Type: ${selected.label}. Change" }, + ) { + Row( + modifier = Modifier.padding(start = 10.dp, end = 8.dp, top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon(selected.icon, contentDescription = null, modifier = Modifier.size(16.dp)) + Text(selected.label, style = MaterialTheme.typography.labelLarge, fontWeight = GoogleSansWeight.semiBold) + Icon(Icons.Rounded.KeyboardArrowDown, contentDescription = null, modifier = Modifier.size(18.dp)) + } + } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + FeedbackCategory.entries.forEach { category -> + DropdownMenuItem( + text = { Text(category.label) }, + leadingIcon = { Icon(category.icon, contentDescription = null) }, + trailingIcon = if (category == selected) { + { Icon(Icons.Rounded.Check, contentDescription = null) } + } else { + null + }, + onClick = { + onSelect(category) + open = false + }, + ) + } + } + } +} + +/** The roomy, softly filled message card; the caller decides its height. */ +@Composable +internal fun FeedbackMessageCard( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + maxChars: Int, + onFocusChanged: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Column(Modifier.padding(start = 18.dp, end = 18.dp, top = 16.dp, bottom = 12.dp)) { + Box(Modifier.weight(1f, fill = true).fillMaxWidth()) { + if (value.isEmpty()) { + Text(placeholder, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + BasicTextField( + value = value, + onValueChange = onValueChange, + textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + modifier = Modifier + .fillMaxSize() + .testTag(FEEDBACK_MESSAGE_TAG) + .semantics { contentDescription = "Your message" } + .onFocusChanged { onFocusChanged(it.isFocused) }, + ) + } + Text( + text = "${value.length}/$maxChars", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.align(Alignment.End), + ) + } + } +} + +@Composable +internal fun FeedbackPhotoRow( + photos: List, + canAdd: Boolean, + maxPhotos: Int, + onAdd: () -> Unit, + onRemove: (PickedPhoto) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth().testTag(FEEDBACK_PHOTOS_TAG), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + onClick = onAdd, + enabled = canAdd, + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainer, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .size(64.dp) + .semantics { contentDescription = "Add a photo or screenshot" }, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { + Icon(Icons.Rounded.AddPhotoAlternate, contentDescription = null, modifier = Modifier.size(20.dp)) + Text("Add", style = MaterialTheme.typography.labelSmall, fontWeight = GoogleSansWeight.semiBold) + } + } + photos.forEachIndexed { index, photo -> + Box(Modifier.size(64.dp)) { + AsyncImage( + model = photo.uri, + contentDescription = "Photo ${index + 1}", + contentScale = ContentScale.Crop, + modifier = Modifier + .size(64.dp) + .clip(RoundedCornerShape(20.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHighest), + ) + Surface( + onClick = { onRemove(photo) }, + shape = CircleShape, + color = MaterialTheme.colorScheme.inverseSurface, + contentColor = MaterialTheme.colorScheme.inverseOnSurface, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(2.dp) + .size(24.dp) + .semantics { contentDescription = "Remove photo ${index + 1}" }, + ) { + Icon(Icons.Rounded.Close, contentDescription = null, modifier = Modifier.padding(5.dp)) + } + } + } + Spacer(Modifier.weight(1f)) + Text("Up to $maxPhotos", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +internal fun FeedbackDiagnosticsRow(checked: Boolean, onCheckedChange: (Boolean) -> Unit, modifier: Modifier = Modifier) { + Row(modifier = modifier.fillMaxWidth().heightIn(min = 48.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + text = "Include device diagnostics", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f), + ) + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + modifier = Modifier.semantics { contentDescription = "Include device diagnostics" }, + ) + } +} + +@Composable +internal fun FeedbackSendButton(enabled: Boolean, sending: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, label: String = "Send feedback") { + Button( + onClick = onClick, + enabled = enabled && !sending, + shape = CircleShape, + modifier = modifier.testTag(FEEDBACK_SEND_TAG), + colors = ButtonDefaults.buttonColors(), + contentPadding = ButtonDefaults.ContentPadding, + ) { + if (sending) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onPrimary) + } else { + Icon(Icons.AutoMirrored.Rounded.Send, contentDescription = null, modifier = Modifier.size(20.dp)) + } + Spacer(Modifier.width(10.dp)) + Text(if (sending) "Sending" else label, style = MaterialTheme.typography.titleMedium, fontWeight = GoogleSansWeight.semiBold) + } +} + +@Composable +internal fun FeedbackPrivacyLink(onOpen: () -> Unit, modifier: Modifier = Modifier) { + TextButton(onClick = onOpen, modifier = modifier.heightIn(min = 40.dp)) { + Text( + text = "How we handle feedback", + style = MaterialTheme.typography.labelLarge, + fontWeight = GoogleSansWeight.semiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textDecoration = TextDecoration.Underline, + ) + } +} + +/** The bar that sits right on the keyboard while typing: photos, diagnostics, Send. */ +@Composable +internal fun FeedbackTypingBar( + state: FeedbackUiState, + onAddPhoto: () -> Unit, + onToggleDiagnostics: () -> Unit, + onSend: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp).testTag(FEEDBACK_TYPING_BAR_TAG), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (state.meta.photosEnabled) { + Surface( + onClick = onAddPhoto, + enabled = state.canAddPhotos, + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceContainer, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.heightIn(min = 48.dp).semantics { contentDescription = "Photos and screenshots, ${state.photos.size} attached" }, + ) { + Row(Modifier.padding(horizontal = 14.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Rounded.Image, contentDescription = null, modifier = Modifier.size(18.dp)) + if (state.photos.isNotEmpty()) { + Spacer(Modifier.width(6.dp)) + Text("${state.photos.size}", style = MaterialTheme.typography.labelLarge, fontWeight = GoogleSansWeight.semiBold) + } + } + } + } + FilterChip( + selected = state.attachDiagnostics, + onClick = onToggleDiagnostics, + label = { Text("Diagnostics", fontWeight = GoogleSansWeight.semiBold) }, + leadingIcon = if (state.attachDiagnostics) { + { Icon(Icons.Rounded.Check, contentDescription = null, modifier = Modifier.size(18.dp)) } + } else { + null + }, + shape = CircleShape, + border = null, + modifier = Modifier.heightIn(min = 48.dp), + colors = FilterChipDefaults.filterChipColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + labelColor = MaterialTheme.colorScheme.onSurfaceVariant, + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, + selectedLeadingIconColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) + Spacer(Modifier.weight(1f)) + FeedbackSendButton(enabled = state.canSend, sending = state.isSubmitting, onClick = onSend, label = "Send", modifier = Modifier.heightIn(min = 48.dp)) + } +} + +@Composable +internal fun FeedbackIconButton(icon: ImageVector, description: String, onClick: () -> Unit) { + IconButton(onClick = onClick) { Icon(icon, contentDescription = description) } +} diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackNotificationAsk.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackNotificationAsk.kt new file mode 100644 index 000000000..376376091 --- /dev/null +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackNotificationAsk.kt @@ -0,0 +1,63 @@ +package cx.aswin.boxlore.feature.settings.feedback + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat + +/** + * The "turn on notifications" ask after sending feedback. Same rules as the show page and import + * card: ask Android only when it will still show its prompt, otherwise open the app's notification + * settings. + */ +internal fun areAppNotificationsEnabled(context: Context): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED + if (!granted) return false + } + return NotificationManagerCompat.from(context).areNotificationsEnabled() +} + +/** Whether Android will still show its notification prompt (pure; tested). */ +internal fun canAskForNotifications(sdkInt: Int, granted: Boolean, askedBefore: Boolean, shouldShowRationale: Boolean): Boolean { + if (sdkInt < Build.VERSION_CODES.TIRAMISU || granted) return false + return !askedBefore || shouldShowRationale +} + +internal fun canAskForNotifications(context: Context, askedBefore: Boolean): Boolean { + val sdk = Build.VERSION.SDK_INT + val granted = sdk >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED + val activity = context.findActivity() + val rationale = activity != null && sdk >= Build.VERSION_CODES.TIRAMISU && + ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.POST_NOTIFICATIONS) + return canAskForNotifications(sdk, granted, askedBefore, rationale) +} + +internal fun openAppNotificationSettings(context: Context) { + val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) + .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + try { + context.startActivity(intent) + } catch (_: android.content.ActivityNotFoundException) { + context.startActivity( + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) + } +} + +private tailrec fun Context.findActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null +} 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 c0b8c609a..2e9f57fe1 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,747 +1,364 @@ package cx.aswin.boxlore.feature.settings.feedback -import android.content.ActivityNotFoundException -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.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues 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.heightIn +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape 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.material.icons.rounded.Visibility -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.material.icons.rounded.MoreVert +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem 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.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.platform.LocalContext -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.unit.Dp 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.analytics.AnalyticsHelper import cx.aswin.boxlore.core.designsystem.analytics.TrackScreenView import cx.aswin.boxlore.core.designsystem.layout.BoxloreCardFamily import cx.aswin.boxlore.core.designsystem.layout.boxloreCardWidth -import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight import cx.aswin.boxlore.core.prefs.BoxcastPrefs +import cx.aswin.boxlore.core.support.SupportRepository -private val FEEDBACK_CONTENT_BOTTOM_PADDING = 240.dp +/** Below this height the full layout scrolls instead of stretching the message card. */ +internal val FEEDBACK_TALL_ENOUGH = 600.dp +private val FEEDBACK_SHORT_MESSAGE_HEIGHT = 220.dp -@OptIn(ExperimentalMaterial3Api::class) +/** + * "Share your thoughts": the type pills, a roomy message card, photos (when the server switches + * them on), diagnostics and Send. While typing, the pills fold into one pill in the header and a + * slim bar rides on the keyboard, so Send is always reachable. After sending, [FeedbackSentScreen]. + */ @Composable fun FeedbackScreen( - podcastRepository: PodcastRepository, + supportRepository: SupportRepository, boxcastPrefs: BoxcastPrefs, onBack: () -> Unit, modifier: Modifier = Modifier, + bottomContentPadding: Dp = 0.dp, ) { TrackScreenView("feedback") val context = LocalContext.current val feedbackViewModel: FeedbackViewModel = viewModel( - factory = FeedbackViewModel.Factory(podcastRepository, boxcastPrefs, context), + factory = FeedbackViewModel.Factory(supportRepository, boxcastPrefs, context), ) val uiState by feedbackViewModel.uiState.collectAsStateWithLifecycle() BackHandler(onBack = onBack) - Scaffold( - modifier = modifier.imePadding(), - topBar = { - TopAppBar( - modifier = Modifier.boxloreCardWidth(BoxloreCardFamily.Utility), - 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( - uiState = uiState, - onDone = onBack, - bottomPadding = FEEDBACK_CONTENT_BOTTOM_PADDING, - modifier = Modifier - .fillMaxSize() - .boxloreCardWidth(BoxloreCardFamily.Utility) - .padding(paddingValues) - .padding(horizontal = 24.dp), - ) - } else { - FeedbackFormView( - modifier = Modifier - .fillMaxSize() - .boxloreCardWidth(BoxloreCardFamily.Utility) - .padding(paddingValues) - .verticalScroll(rememberScrollState()) - .padding( - start = 20.dp, - top = 12.dp, - end = 20.dp, - bottom = FEEDBACK_CONTENT_BOTTOM_PADDING, - ), - uiState = uiState, - viewModel = feedbackViewModel, - context = context, - ) - } - - if (uiState.logsPreview != null || uiState.isLoadingLogs) { - LogsPreviewDialog( - logs = uiState.logsPreview, - isLoading = uiState.isLoadingLogs, - diagnosticInfo = uiState.diagnosticInfo, - onShare = { shareFeedbackDiagnostics(context, uiState) }, - onDismissRequest = { feedbackViewModel.clearLogsPreview() }, - ) - } - } -} - -@Composable -private fun FeedbackFormView( - modifier: Modifier = Modifier, - uiState: FeedbackUiState, - viewModel: FeedbackViewModel, - context: Context, -) { - Column( + FeedbackContent( + state = uiState, + actions = FeedbackComposeActions( + onBack = onBack, + onCategorySelected = feedbackViewModel::onCategorySelected, + onMessageChanged = feedbackViewModel::onMessageChanged, + onAttachDiagnosticsChanged = feedbackViewModel::onAttachDiagnosticsChanged, + onPhotosPicked = feedbackViewModel::onPhotosPicked, + onRemovePhoto = feedbackViewModel::onRemovePhoto, + onSubmit = feedbackViewModel::onSubmit, + onPreviewDiagnostics = feedbackViewModel::loadLogsForPreview, + onDiscardDraft = feedbackViewModel::onDiscardDraft, + ), + boxcastPrefs = boxcastPrefs, + bottomContentPadding = bottomContentPadding, 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, - enabled = !uiState.isSubmitting, - onMessageChanged = { viewModel.onMessageChanged(it) }, - ) - - if (uiState.category.isBugReport) { - FeedbackStepsInput( - steps = uiState.stepsToReproduce, - enabled = !uiState.isSubmitting, - onStepsChanged = { viewModel.onStepsChanged(it) }, - ) - } - - FeedbackEmailInput( - category = uiState.category, - email = uiState.email, - enabled = !uiState.isSubmitting, - onEmailChanged = { viewModel.onEmailChanged(it) }, - ) + ) - FeedbackDiagnosticsSection( - attachDiagnostics = uiState.attachDiagnostics, + if (uiState.logsPreview != null || uiState.isLoadingLogs) { + LogsPreviewDialog( + logs = uiState.logsPreview, + isLoading = uiState.isLoadingLogs, diagnosticInfo = uiState.diagnosticInfo, - onToggleAttach = { viewModel.onAttachDiagnosticsChanged(it) }, - onPreviewLogs = { viewModel.loadLogsForPreview() }, + onShare = { shareFeedbackDiagnostics(context, uiState) }, + onDismissRequest = feedbackViewModel::clearLogsPreview, ) - - FeedbackCommunityRow( - gitHubUrl = buildFeedbackGitHubIssueUrl(uiState), - context = context, - ) - - FeedbackSubmitButton( - isSubmitting = uiState.isSubmitting, - message = uiState.message, - errorMessage = uiState.errorMessage, - onSubmit = { viewModel.onSubmit() }, - ) - - Spacer(modifier = Modifier.height(24.dp)) } } +internal class FeedbackComposeActions( + val onBack: () -> Unit, + val onCategorySelected: (FeedbackCategory) -> Unit, + val onMessageChanged: (String) -> Unit, + val onAttachDiagnosticsChanged: (Boolean) -> Unit, + val onPhotosPicked: (List) -> Unit, + val onRemovePhoto: (PickedPhoto) -> Unit, + val onSubmit: () -> Unit, + val onPreviewDiagnostics: () -> Unit, + val onDiscardDraft: () -> Unit, +) + +/** The compose screen until the message is sent, then [FeedbackSentScreen]; Done leaves like Back. */ @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, +internal fun FeedbackContent( + state: FeedbackUiState, + actions: FeedbackComposeActions, + boxcastPrefs: BoxcastPrefs, + bottomContentPadding: Dp, + modifier: Modifier = Modifier, ) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - text = "Category", - style = MaterialTheme.typography.labelLarge, - fontWeight = GoogleSansWeight.medium, - color = MaterialTheme.colorScheme.onSurface, + if (state.isSuccess) { + FeedbackSentScreen( + boxcastPrefs = boxcastPrefs, + repliesInApp = state.repliesInApp, + onDone = actions.onBack, + bottomContentPadding = bottomContentPadding, + modifier = modifier, ) - - 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, - ), - ) - } - } + } else { + FeedbackComposeScreen(state = state, actions = actions, bottomContentPadding = bottomContentPadding, modifier = modifier) } } -@Composable -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), - 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, - ), - ) +/** True while the listener is writing: the message has focus and the keyboard is up. */ +internal fun isTypingLayout(messageFocused: Boolean, imeBottom: Dp): Boolean = messageFocused && imeBottom > 0.dp - Text( - text = "${message.length}/2000", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.End, - ) - } -} +/** Space under the content: the keyboard while it's up, otherwise the app's bottom chrome and the system bar. */ +internal fun feedbackBottomPadding(imeBottom: Dp, chrome: Dp, systemBar: Dp): Dp = if (imeBottom > 0.dp) imeBottom else chrome + systemBar @Composable -private fun FeedbackStepsInput( - steps: String, - enabled: Boolean = true, - onStepsChanged: (String) -> Unit, +internal fun FeedbackComposeScreen( + state: FeedbackUiState, + actions: FeedbackComposeActions, + bottomContentPadding: Dp, + modifier: Modifier = Modifier, ) { - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text( - text = "Steps to Reproduce (Optional)", - style = MaterialTheme.typography.labelLarge, - fontWeight = GoogleSansWeight.medium, - color = MaterialTheme.colorScheme.onSurface, - ) - - Surface( - shape = MaterialTheme.shapes.small, - color = MaterialTheme.colorScheme.surfaceContainerHigh, - modifier = Modifier.fillMaxWidth(), + var messageFocused by rememberSaveable { mutableStateOf(false) } + val imeBottom = WindowInsets.ime.asPaddingValues().calculateBottomPadding() + val systemBar = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + val typing = isTypingLayout(messageFocused, imeBottom) + val picker = rememberLauncherForActivityResult( + ActivityResultContracts.PickMultipleVisualMedia(maxItems = state.meta.maxPhotos.coerceAtLeast(2)), + ) { uris -> actions.onPhotosPicked(uris) } + val pickPhotos = { picker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) } + + Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) { + Box( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding() + .padding(bottom = feedbackBottomPadding(imeBottom, bottomContentPadding, systemBar)), + contentAlignment = Alignment.TopCenter, ) { - 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, - ) + Column(Modifier.fillMaxSize().boxloreCardWidth(BoxloreCardFamily.Utility)) { + if (typing) { + TypingLayout(state, actions, onFocusChanged = { messageFocused = it }, onAddPhoto = pickPhotos) + } else { + FullLayout(state, actions, onFocusChanged = { messageFocused = it }, onAddPhoto = pickPhotos) + } } } - - OutlinedTextField( - value = steps, - onValueChange = onStepsChanged, - enabled = enabled, - 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, - ), - ) } } -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, - enabled: Boolean = true, - onEmailChanged: (String) -> Unit, +private fun ColumnScope.FullLayout( + state: FeedbackUiState, + actions: FeedbackComposeActions, + onFocusChanged: (Boolean) -> Unit, + onAddPhoto: () -> 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, - enabled = enabled, - modifier = Modifier.fillMaxWidth(), - placeholder = { + val uriHandler = LocalUriHandler.current + TopRow(state, actions) + BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) { + val tall = maxHeight >= FEEDBACK_TALL_ENOUGH + val content: @Composable ColumnScope.() -> Unit = { + Column(Modifier.padding(horizontal = 20.dp)) { + Text("Share your thoughts", style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(4.dp)) Text( - text = "you@example.com", + "Bugs, ideas, anything at all. The boxlore team reads every message.", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), - ) - }, - supportingText = { - Text( - text = getEmailSupportingText(category), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - }, - 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, - ) + state.shownNotice?.let { notice -> + Spacer(Modifier.height(12.dp)) + Surface(shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.secondaryContainer) { + Text( + notice, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + } } - Switch( - checked = attachDiagnostics, - onCheckedChange = onToggleAttach, + } + FeedbackTypePills( + selected = state.category, + onSelect = actions.onCategorySelected, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 20.dp), + ) + FeedbackMessageCard( + value = state.message, + onValueChange = actions.onMessageChanged, + placeholder = state.category.placeholder, + maxChars = state.meta.maxBody.coerceAtMost(FeedbackViewModel.MAX_MESSAGE), + onFocusChanged = onFocusChanged, + modifier = Modifier + .padding(start = 16.dp, end = 16.dp, top = 16.dp) + .fillMaxWidth() + .then(if (tall) Modifier.weight(1f) else Modifier.height(FEEDBACK_SHORT_MESSAGE_HEIGHT)), + ) + if (state.meta.photosEnabled) { + FeedbackPhotoRow( + photos = state.photos, + canAdd = state.canAddPhotos, + maxPhotos = state.meta.maxPhotos, + onAdd = onAddPhoto, + onRemove = actions.onRemovePhoto, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 12.dp), ) } - - if (attachDiagnostics && diagnosticInfo != null) { - DiagnosticsDetailsContent( - diagnosticInfo = diagnosticInfo, - onPreviewLogs = onPreviewLogs, + FeedbackDiagnosticsRow( + checked = state.attachDiagnostics, + onCheckedChange = actions.onAttachDiagnosticsChanged, + modifier = Modifier.padding(start = 20.dp, end = 16.dp, top = 8.dp), + ) + state.errorMessage?.let { error -> + Text( + error, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp), ) } + FeedbackSendButton( + enabled = state.canSend, + sending = state.isSubmitting, + onClick = actions.onSubmit, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp).fillMaxWidth().height(64.dp), + ) + FeedbackPrivacyLink( + onOpen = { uriHandler.openUri(FEEDBACK_PRIVACY_URL) }, + modifier = Modifier.align(Alignment.CenterHorizontally).padding(bottom = 4.dp), + ) + } + if (tall) { + Column(Modifier.fillMaxSize(), content = content) + } else { + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()), content = content) } } } @Composable -private fun DiagnosticsDetailsContent( - diagnosticInfo: DiagnosticInfo, - onPreviewLogs: () -> Unit, +private fun ColumnScope.TypingLayout( + state: FeedbackUiState, + actions: FeedbackComposeActions, + onFocusChanged: (Boolean) -> Unit, + onAddPhoto: () -> 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, + modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(horizontal = 4.dp), 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, - ) + FeedbackIconButton(Icons.AutoMirrored.Rounded.ArrowBack, "Navigate back", actions.onBack) + Text("Share your thoughts", style = MaterialTheme.typography.titleLarge, modifier = Modifier.weight(1f)) + Box(Modifier.padding(end = 8.dp)) { + FeedbackTypeMenuPill(selected = state.category, onSelect = actions.onCategorySelected) } } + FeedbackMessageCard( + value = state.message, + onValueChange = actions.onMessageChanged, + placeholder = state.category.placeholder, + maxChars = state.meta.maxBody.coerceAtMost(FeedbackViewModel.MAX_MESSAGE), + onFocusChanged = onFocusChanged, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp).fillMaxWidth().weight(1f), + ) + state.errorMessage?.let { error -> + Text(error, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp)) + } + FeedbackTypingBar( + state = state, + onAddPhoto = onAddPhoto, + onToggleDiagnostics = { actions.onAttachDiagnosticsChanged(!state.attachDiagnostics) }, + onSend = actions.onSubmit, + ) } @Composable -private fun FeedbackCommunityRow( - gitHubUrl: String, - context: Context, -) { +private fun TopRow(state: FeedbackUiState, actions: FeedbackComposeActions) { + val context = LocalContext.current + val uriHandler = LocalUriHandler.current + var menuOpen by remember { mutableStateOf(false) } Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, ) { - OutlinedButton( - onClick = { - 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 + FeedbackIconButton(Icons.AutoMirrored.Rounded.ArrowBack, "Navigate back", actions.onBack) + Box { + FeedbackIconButton(Icons.Rounded.MoreVert, "More options", { menuOpen = true }) + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem(text = { Text("Preview diagnostics") }, onClick = { + menuOpen = false + actions.onPreviewDiagnostics() + }) + DropdownMenuItem(text = { Text("Rate on Play") }, onClick = { + menuOpen = false + AnalyticsHelper.trackStoreReviewOpened(method = "store_page", source = "feedback_screen") + openPlayStorePage(context) + }) + DropdownMenuItem(text = { Text("Report on GitHub") }, onClick = { + menuOpen = false + uriHandler.openUri(buildFeedbackGitHubIssueUrl(state)) + }) + if (state.message.isNotBlank()) { + DropdownMenuItem(text = { Text("Discard draft") }, onClick = { + menuOpen = false + actions.onDiscardDraft() + }) } - }, - 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 = { - cx.aswin.boxlore.core.analytics.AnalyticsHelper - .trackStoreReviewOpened(method = "store_page", source = "feedback_screen") - val pkgName = context.packageName - val marketUri = try { - Uri.parse("market://details?id=$pkgName") - } catch (_: Exception) { - Uri.parse("https://play.google.com/store/apps/details?id=$pkgName") - } - 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 - } - } - }, - 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, - ) - } +/** The app's Play listing: the store app when there is one, else the web page. */ +internal fun openPlayStorePage(context: android.content.Context) { + val pkg = context.packageName + val tries = listOf(Uri.parse("market://details?id=$pkg"), Uri.parse("https://play.google.com/store/apps/details?id=$pkg")) + for (uri in tries) { + try { + context.startActivity(Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + return + } catch (_: android.content.ActivityNotFoundException) { + // Try the next way, or do nothing when neither store nor browser exists. } } } diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSentScreen.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSentScreen.kt new file mode 100644 index 000000000..383e2e52a --- /dev/null +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSentScreen.kt @@ -0,0 +1,173 @@ +package cx.aswin.boxlore.feature.settings.feedback + +import android.Manifest +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +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.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.NotificationsNone +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LifecycleResumeEffect +import cx.aswin.boxlore.core.designsystem.echo.EchoCharacter +import cx.aswin.boxlore.core.designsystem.layout.BoxloreCardFamily +import cx.aswin.boxlore.core.designsystem.layout.boxloreCardWidth +import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight +import cx.aswin.boxlore.core.prefs.BoxcastPrefs + +internal const val FEEDBACK_SENT_TAG = "feedback_sent" +internal const val FEEDBACK_NOTIFY_CARD_TAG = "feedback_notify_card" + +/** Echo looks through the magnifier once, then rests. */ +internal const val FEEDBACK_SENT_ECHO = "feedback-sent" + +/** The reply promise only when the message became a conversation; the old form has no replies in the app. */ +internal fun feedbackSentBody(repliesInApp: Boolean): String = if (repliesInApp) { + "Thanks for telling us. The boxlore team reads every message, and the reply will show up in Feedback." +} else { + "Thanks for telling us. The boxlore team reads every message." +} + +/** + * After sending: Echo, a thank-you, and (when notifications are off) a card to turn them on so the + * listener hears about the team's reply. A message sent through the old one-way form gets neither. The card re-checks when the listener comes back from settings. + */ +@Composable +internal fun FeedbackSentScreen( + boxcastPrefs: BoxcastPrefs, + repliesInApp: Boolean, + onDone: () -> Unit, + bottomContentPadding: Dp, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + var notificationsOn by remember { mutableStateOf(areAppNotificationsEnabled(context)) } + var dismissed by rememberSaveable { mutableStateOf(false) } + LifecycleResumeEffect(Unit) { + notificationsOn = areAppNotificationsEnabled(context) + onPauseOrDispose { } + } + val askLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { + notificationsOn = areAppNotificationsEnabled(context) + } + val turnOn = { + if (canAskForNotifications(context, boxcastPrefs.hasRequestedNotificationPermission())) { + boxcastPrefs.setHasRequestedNotificationPermission() + askLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } else { + openAppNotificationSettings(context) + } + } + val systemBar = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + + Surface(modifier = modifier.fillMaxSize().testTag(FEEDBACK_SENT_TAG), color = MaterialTheme.colorScheme.surface) { + Box( + modifier = Modifier.fillMaxSize().statusBarsPadding().padding(bottom = bottomContentPadding + systemBar), + contentAlignment = Alignment.TopCenter, + ) { + Column(Modifier.fillMaxSize().boxloreCardWidth(BoxloreCardFamily.Utility)) { + Row(Modifier.fillMaxWidth().padding(horizontal = 4.dp)) { + FeedbackIconButton(Icons.Rounded.Close, "Close", onDone) + } + Column( + modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + EchoCharacter(sequence = FEEDBACK_SENT_ECHO, modifier = Modifier.size(width = 168.dp, height = 228.dp)) + Spacer(Modifier.height(12.dp)) + Text("Feedback sent", style = MaterialTheme.typography.headlineMedium, textAlign = TextAlign.Center) + Spacer(Modifier.height(8.dp)) + Text( + feedbackSentBody(repliesInApp), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + if (repliesInApp && !notificationsOn && !dismissed) { + Spacer(Modifier.height(28.dp)) + NotificationCard(onTurnOn = turnOn, onNotNow = { dismissed = true }) + } + Spacer(Modifier.height(16.dp)) + } + FilledTonalButton( + onClick = onDone, + shape = CircleShape, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp).fillMaxWidth().height(64.dp), + ) { + Text("Done", style = MaterialTheme.typography.titleMedium, fontWeight = GoogleSansWeight.semiBold) + } + } + } + } +} + +@Composable +private fun NotificationCard(onTurnOn: () -> Unit, onNotNow: () -> Unit) { + Surface( + shape = RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier.fillMaxWidth().testTag(FEEDBACK_NOTIFY_CARD_TAG), + ) { + Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) { + Surface(shape = CircleShape, color = MaterialTheme.colorScheme.primaryContainer, contentColor = MaterialTheme.colorScheme.onPrimaryContainer) { + Icon(Icons.Rounded.NotificationsNone, contentDescription = null, modifier = Modifier.padding(11.dp).size(22.dp)) + } + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Know when the team replies", style = MaterialTheme.typography.titleMedium, fontWeight = GoogleSansWeight.semiBold) + Text( + "Replies can take a day or two. Turn on notifications so you don't miss one.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = onTurnOn, shape = CircleShape, modifier = Modifier.weight(1f).heightIn(min = 48.dp), colors = ButtonDefaults.buttonColors()) { + Text("Turn on notifications", fontWeight = GoogleSansWeight.semiBold) + } + TextButton(onClick = onNotNow, modifier = Modifier.heightIn(min = 48.dp)) { + Text("Not now", fontWeight = GoogleSansWeight.semiBold) + } + } + } + } +} 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 deleted file mode 100644 index 14e61b228..000000000 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSuccessView.kt +++ /dev/null @@ -1,431 +0,0 @@ -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.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 -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.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 - -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, - 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() - 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 = 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 { - "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 = 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 { - "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 = 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 { - "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 = 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 { - "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, - bottomPadding: Dp = 240.dp, -) { - val content = remember(uiState.category, uiState.email) { - getFeedbackSuccessContent(uiState.category, uiState.email) - } - - Column( - modifier = modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.Top, - 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(32.dp)) - Button( - onClick = onDone, - modifier = Modifier - .fillMaxWidth() - .height(48.dp), - shape = MaterialTheme.shapes.large, - ) { - Text("Done") - } - Spacer(modifier = Modifier.height(bottomPadding)) - } -} - -@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), - ) - } -} - -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, - 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 = cardColor, - modifier = modifier.fillMaxWidth(), - ) { - Row( - modifier = Modifier.padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(14.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Surface( - shape = CircleShape, - color = iconContainerColor, - modifier = Modifier.size(40.dp), - ) { - Box(contentAlignment = Alignment.Center) { - Icon( - imageVector = iconVector, - contentDescription = null, - tint = iconTint, - 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.large, - color = MaterialTheme.colorScheme.surfaceContainerLow, - modifier = modifier.fillMaxWidth(), - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - SummaryCategoryRow(category = uiState.category) - - 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(category: FeedbackCategory) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - 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 = category.label, - 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, - ) { - Row( - modifier = Modifier.weight(1f, fill = false), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Icon( - imageVector = Icons.Rounded.Email, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = "Contact email", - style = MaterialTheme.typography.bodySmall, - 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, - ) - } -} - -@Composable -private fun SummaryDiagnosticsRow() { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Icon( - imageVector = Icons.Rounded.Analytics, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - 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, - ) - } - } - } -} 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 bf81d8ffd..2271f931b 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 @@ -7,9 +7,13 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import cx.aswin.boxlore.core.analytics.AnalyticsHelper -import cx.aswin.boxlore.core.catalog.PodcastRepository import cx.aswin.boxlore.core.prefs.BoxcastPrefs import cx.aswin.boxlore.core.prefs.FeedbackDraft +import cx.aswin.boxlore.core.support.OutgoingFeedback +import cx.aswin.boxlore.core.support.PhotoPrep +import cx.aswin.boxlore.core.support.SendResult +import cx.aswin.boxlore.core.support.SupportMeta +import cx.aswin.boxlore.core.support.SupportRepository import java.net.URLEncoder import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers @@ -34,36 +38,48 @@ enum class FeedbackCategory( val FeedbackCategory.isBugReport: Boolean get() = this == FeedbackCategory.BUG || this == FeedbackCategory.AUDIO +/** A photo or screenshot picked for the message; uploaded only when the message is sent. */ +data class PickedPhoto(val uri: Uri) + data class FeedbackUiState( val category: FeedbackCategory = FeedbackCategory.FEATURE, val message: String = "", - val stepsToReproduce: String = "", - val email: String = "", val attachDiagnostics: Boolean = false, + val photos: List = emptyList(), + val meta: SupportMeta = SupportMeta.Offline, val isDraftRestored: Boolean = false, val isSubmitting: Boolean = false, val isSuccess: Boolean = false, + /** True when the message became a conversation, so the reply will show up in the app. */ + val repliesInApp: Boolean = false, val errorMessage: String? = null, val diagnosticInfo: DiagnosticInfo? = null, val logsPreview: String? = null, val isLoadingLogs: Boolean = false, +) { + val canAddPhotos: Boolean get() = meta.photosEnabled && photos.size < meta.maxPhotos + val canSend: Boolean get() = message.isNotBlank() && !isSubmitting && meta.canStartNew + + /** The server's notice, or a plain one when new messages are paused without one. */ + val shownNotice: String? get() = meta.notice ?: PAUSED_NOTICE.takeUnless { meta.canStartNew } +} + +internal const val PAUSED_NOTICE = "Sending is paused for now. Your message stays saved here." + +/** What the screen needs from support; the factory wires [SupportRepository], tests pass fakes. */ +class FeedbackSupport( + val meta: StateFlow, + val refresh: suspend () -> Unit, + val uploadPhoto: suspend (bytes: ByteArray, mimeType: String) -> String?, + val send: suspend (OutgoingFeedback) -> SendResult, ) class FeedbackViewModel( - private val podcastRepository: PodcastRepository, + private val support: FeedbackSupport, private val boxcastPrefs: BoxcastPrefs, private val context: Context, private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, - private val submitFeedbackAction: ( - suspend ( - category: String, - message: String, - appVersion: String, - email: String?, - diagnostics: String?, - logs: String?, - ) -> Boolean - )? = null, + private val readPhoto: (Uri) -> ByteArray? = { uri -> PhotoPrep.prepare(context.contentResolver, uri) }, ) : ViewModel() { private val _uiState = MutableStateFlow(FeedbackUiState()) @@ -71,59 +87,40 @@ class FeedbackViewModel( init { loadInitialState() + viewModelScope.launch { support.meta.collect { meta -> _uiState.update { it.copy(meta = meta) } } } + viewModelScope.launch { runCatching { support.refresh() } } } private fun loadInitialState() { val diagnostics = DiagnosticCollector.collect(context) val draft = boxcastPrefs.getFeedbackDraft() - - if (draft != null && (draft.message.isNotBlank() || draft.stepsToReproduce.isNotBlank())) { - val matchingCategory = FeedbackCategory.entries.find { it.id == draft.category } - ?: FeedbackCategory.FEATURE - + // Older drafts kept "steps to reproduce" apart; they now join the message. + val draftMessage = draft?.let { + listOf(it.message.trim(), it.stepsToReproduce.trim()).filter(String::isNotBlank).joinToString("\n\n") + }.orEmpty() + if (draft != null && draftMessage.isNotBlank()) { _uiState.update { it.copy( - category = matchingCategory, - message = draft.message, - stepsToReproduce = draft.stepsToReproduce, - email = draft.email, + category = FeedbackCategory.entries.find { c -> c.id == draft.category } ?: FeedbackCategory.FEATURE, + message = draftMessage.take(MAX_MESSAGE), attachDiagnostics = draft.attachDiagnostics, isDraftRestored = true, diagnosticInfo = diagnostics, ) } } else { - _uiState.update { - it.copy(diagnosticInfo = diagnostics) - } + _uiState.update { it.copy(diagnosticInfo = diagnostics) } } } fun onCategorySelected(category: FeedbackCategory) { - _uiState.update { - it.copy( - category = category, - attachDiagnostics = category.isBugReport, - errorMessage = null, - ) - } + _uiState.update { it.copy(category = category, attachDiagnostics = category.isBugReport, 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) } + val limit = _uiState.value.meta.maxBody.coerceAtMost(MAX_MESSAGE) + _uiState.update { it.copy(message = message.take(limit), errorMessage = null) } saveCurrentDraft() } @@ -132,8 +129,17 @@ class FeedbackViewModel( saveCurrentDraft() } - fun onDismissDraftBanner() { - _uiState.update { it.copy(isDraftRestored = false) } + fun onPhotosPicked(uris: List) { + _uiState.update { state -> + if (!state.meta.photosEnabled) return@update state + val room = (state.meta.maxPhotos - state.photos.size).coerceAtLeast(0) + val added = uris.distinct().filter { uri -> state.photos.none { it.uri == uri } }.take(room).map(::PickedPhoto) + state.copy(photos = state.photos + added, errorMessage = null) + } + } + + fun onRemovePhoto(photo: PickedPhoto) { + _uiState.update { it.copy(photos = it.photos - photo) } } fun onDiscardDraft() { @@ -142,9 +148,8 @@ class FeedbackViewModel( it.copy( category = FeedbackCategory.FEATURE, message = "", - stepsToReproduce = "", - email = "", attachDiagnostics = false, + photos = emptyList(), isDraftRestored = false, errorMessage = null, ) @@ -153,32 +158,26 @@ class FeedbackViewModel( private fun saveCurrentDraft() { val current = _uiState.value - if (current.message.isBlank() && current.stepsToReproduce.isBlank()) { + if (current.message.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( + FeedbackDraft( + category = current.category.id, + message = current.message, + email = "", + stepsToReproduce = "", + attachDiagnostics = current.attachDiagnostics, + ), ) - boxcastPrefs.saveFeedbackDraft(draft) } fun loadLogsForPreview() { _uiState.update { it.copy(isLoadingLogs = true) } viewModelScope.launch { - val logs = withContext(ioDispatcher) { - LogcatCollector.collectSanitizedLogcat(context = context, maxLines = 150) - } - _uiState.update { - it.copy( - logsPreview = logs, - isLoadingLogs = false, - ) - } + val logs = withContext(ioDispatcher) { LogcatCollector.collectSanitizedLogcat(context = context, maxLines = 150) } + _uiState.update { it.copy(logsPreview = logs, isLoadingLogs = false) } } } @@ -189,133 +188,113 @@ 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.") } + val body = state.message.trim() + if (body.isBlank()) { + _uiState.update { it.copy(errorMessage = "Write a message first.") } return } - _uiState.update { it.copy(isSubmitting = true, errorMessage = null) } viewModelScope.launch { - val wireCategory = mapWireCategory(state.category) - val appVersion = state.diagnosticInfo?.appVersion ?: "unknown" val diagnosticsInfo = state.diagnosticInfo ?: DiagnosticCollector.collect(context) - - 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) + val photoIds = uploadPhotos(state) + if (photoIds == null) { + _uiState.update { it.copy(isSubmitting = false, errorMessage = "Couldn't attach your photos. Check your connection and try again.") } + return@launch } - - val success = submit( - wireCategory, - fullMessage, - appVersion, - state.email.trim().ifBlank { null }, - diagnosticsReport, - sanitizedLogs, + val result = support.send( + OutgoingFeedback( + category = state.category.id, + body = body, + appVersion = diagnosticsInfo.appVersion, + device = diagnosticsInfo.deviceLabel(), + diagnostics = diagnosticsReport, + logs = sanitizedLogs, + photoIds = photoIds, + ), ) + handleResult(state, result) + } + } - if (success) { - AnalyticsHelper.trackFeedbackSent( - category = wireCategory, - withEmail = state.email.isNotBlank(), - withDiagnostics = state.attachDiagnostics, - ) - } - handleSubmissionResult(success) + /** Photo ids in order, or null when any upload failed (nothing is sent half-attached). */ + private suspend fun uploadPhotos(state: FeedbackUiState): List? { + if (state.photos.isEmpty() || !state.meta.photosEnabled) return emptyList() + val ids = mutableListOf() + for (photo in state.photos) { + val bytes = withContext(ioDispatcher) { readPhoto(photo.uri) } ?: return null + if (state.meta.maxPhotoBytes > 0 && bytes.size > state.meta.maxPhotoBytes) return null + ids += support.uploadPhoto(bytes, "image/jpeg") ?: return null } + return ids } - private suspend fun prepareDiagnosticPayload( - state: FeedbackUiState, - diagnosticsInfo: DiagnosticInfo, - ): Pair { + 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 + return diagnosticsInfo.toMarkdownReport() to sanitizedLogs } - private fun handleSubmissionResult(success: Boolean) { - if (success) { - boxcastPrefs.clearFeedbackDraft() - _uiState.update { - it.copy( - isSubmitting = false, - isSuccess = true, - isDraftRestored = false, + private fun handleResult(state: FeedbackUiState, result: SendResult) { + when (result) { + is SendResult.Sent, SendResult.SentByEmailForm -> { + val repliesInApp = result is SendResult.Sent + AnalyticsHelper.trackFeedbackSent( + category = analyticsCategory(state.category), + withEmail = false, + withDiagnostics = state.attachDiagnostics, ) + boxcastPrefs.clearFeedbackDraft() + _uiState.update { + it.copy(isSubmitting = false, isSuccess = true, repliesInApp = repliesInApp, isDraftRestored = false, photos = emptyList()) + } } - } else { - _uiState.update { - it.copy( - isSubmitting = false, - errorMessage = "Couldn't send feedback right now. Please check your connection and try again.", - ) + is SendResult.Refused -> _uiState.update { it.copy(isSubmitting = false, errorMessage = result.message) } + SendResult.Failed -> _uiState.update { + it.copy(isSubmitting = false, errorMessage = "Couldn't send your feedback. Check your connection and try again.") } } } class Factory( - private val podcastRepository: PodcastRepository, + private val supportRepository: SupportRepository, 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 + override fun create(modelClass: Class): T = FeedbackViewModel( + support = FeedbackSupport( + meta = supportRepository.meta, + refresh = { supportRepository.refresh() }, + uploadPhoto = supportRepository::uploadPhoto, + send = supportRepository::send, + ), + boxcastPrefs = boxcastPrefs, + context = context.applicationContext, + ) as T + } + + companion object { + const val MAX_MESSAGE = 2000 } } -private fun mapWireCategory(category: FeedbackCategory): String = when (category) { +/** Analytics keeps the categories it always had: audio counts as a bug report. */ +private fun analyticsCategory(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 { - val prefix = if (state.category == FeedbackCategory.AUDIO) { - "[Category: Audio / Stream Issue]\n\n" - } else { - "" - } - val steps = if (state.category.isBugReport && state.stepsToReproduce.isNotBlank()) { - "\n\nSteps to reproduce:\n${state.stepsToReproduce.trim()}" - } else { - "" - } - val diagnostics = if (state.attachDiagnostics) { - "\n\n---\nDiagnostics: ${diagnosticsInfo.toCondensedSummary()}" - } else { - "" - } - 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() val body = buildString { append("### Description\n") append(state.message.ifBlank { "Describe the issue or feature request here." }) append("\n\n") - if (state.category.isBugReport && state.stepsToReproduce.isNotBlank()) { - append("### Steps to Reproduce\n") - append(state.stepsToReproduce) - append("\n\n") - } 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") @@ -345,35 +324,3 @@ fun shareFeedbackDiagnostics(context: Context, state: FeedbackUiState) { shareIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(shareIntent) } - -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") - } - append(state.diagnosticInfo?.toMarkdownReport(state.logsPreview) ?: "No diagnostics available.") - } - - 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/test/java/cx/aswin/boxlore/feature/settings/TabletSettingsCaptureTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/TabletSettingsCaptureTest.kt index dbe2b0dc4..972f7e760 100644 --- a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/TabletSettingsCaptureTest.kt +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/TabletSettingsCaptureTest.kt @@ -2,13 +2,14 @@ package cx.aswin.boxlore.feature.settings import android.app.Application import androidx.compose.runtime.Composable -import cx.aswin.boxlore.core.catalog.PodcastRepository import cx.aswin.boxlore.core.catalog.sync.CloudSyncUiStatus import cx.aswin.boxlore.core.designsystem.theme.BoxLoreTheme +import cx.aswin.boxlore.core.network.SupportApi import cx.aswin.boxlore.core.prefs.AutoDownloadBackgroundSettings import cx.aswin.boxlore.core.prefs.BoxcastPrefs import cx.aswin.boxlore.core.prefs.DiscoverySections import cx.aswin.boxlore.core.prefs.UserPreferencesRepository +import cx.aswin.boxlore.core.support.SupportRepository import cx.aswin.boxlore.core.testing.PhoneGoldenConfiguration import cx.aswin.boxlore.core.testing.PhoneGoldenRule import cx.aswin.boxlore.feature.settings.downloads.AutoDownloadSettingsScreen @@ -60,7 +61,7 @@ class TabletSettingsCaptureTest(private val scene: String, configuration: PhoneG when { scene.startsWith("automatic") -> AutoDownloadSettingsScreen(prefs, {}) scene.startsWith("smart") -> SmartDownloadsSettingsScreen(prefs, {}) - scene == "feedback" -> FeedbackScreen(mock(PodcastRepository::class.java), mock(BoxcastPrefs::class.java), {}) + scene == "feedback" -> FeedbackScreen(SupportRepository(mock(SupportApi::class.java), { _, _, _, _, _ -> false }), mock(BoxcastPrefs::class.java), {}) else -> { val destination = captureDestination(scene) AdaptiveSettingsLayout(destination, SettingsScreenConfig({}, {}), {}) { current -> CaptureSettingsPage(current) } diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackAdaptiveTransitionTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackAdaptiveTransitionTest.kt new file mode 100644 index 000000000..3240da967 --- /dev/null +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackAdaptiveTransitionTest.kt @@ -0,0 +1,244 @@ +package cx.aswin.boxlore.feature.settings.feedback + +import android.app.Application +import android.content.Context +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasScrollAction +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.StateRestorationTester +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.unit.dp +import androidx.test.core.app.ApplicationProvider +import cx.aswin.boxlore.core.designsystem.echo.LocalEchoMotion +import cx.aswin.boxlore.core.designsystem.layout.BoxloreFold +import cx.aswin.boxlore.core.designsystem.layout.BoxloreFoldOrientation +import cx.aswin.boxlore.core.designsystem.layout.BoxloreFoldState +import cx.aswin.boxlore.core.designsystem.layout.BoxloreLayoutBounds +import cx.aswin.boxlore.core.designsystem.layout.BoxloreWindowLayout +import cx.aswin.boxlore.core.designsystem.layout.LocalBoxlorePaneWidth +import cx.aswin.boxlore.core.designsystem.layout.LocalBoxloreWindowLayout +import cx.aswin.boxlore.core.designsystem.theme.BoxLoreTheme +import cx.aswin.boxlore.core.prefs.BoxcastPrefs +import cx.aswin.boxlore.core.support.OutgoingFeedback +import cx.aswin.boxlore.core.support.SendResult +import cx.aswin.boxlore.core.support.SupportMeta +import cx.aswin.boxlore.core.testing.PhoneGoldenConfiguration +import cx.aswin.boxlore.core.testing.PhoneGoldenRule +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * Drives the live feedback screen across the shared window gates in both directions and through a + * recreation: the typed message, the picked type and the reachable Send survive, the message goes + * out once, and the sent screen keeps its Done and notification card through the same changes. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33], application = Application::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class FeedbackAdaptiveTransitionTest { + @get:Rule val fixture = PhoneGoldenRule(PhoneGoldenConfiguration(1_280, 960, false)) + + private val window = mutableStateOf(COMPACT) + private val sent = mutableListOf() + private var result: SendResult = SendResult.Sent("t1") + private var backs = 0 + private lateinit var viewModel: FeedbackViewModel + private lateinit var restoration: StateRestorationTester + + @Test + fun `the draft, type and Send survive window changes and recreation, then the message goes out once`() { + render() + fixture.compose.onNodeWithText("Bug report").performClick() + fixture.compose.onNodeWithTag(FEEDBACK_MESSAGE_TAG).performTextInput(MESSAGE) + fixture.compose.waitForIdle() + val owner = viewModel + + acrossWindowChanges { + composeStateKept() + assertSame("The same view model keeps the draft", owner, viewModel) + } + recreate(FOLD_TABLETOP) + composeStateKept() + + reach(FEEDBACK_SEND_TAG).performClick() + fixture.compose.waitUntil(5_000) { viewModel.uiState.value.isSuccess } + fixture.compose.waitForIdle() + assertEquals(1, sent.size) + assertEquals(MESSAGE, sent.single().body) + assertEquals("bug", sent.single().category) + + acrossWindowChanges { + fixture.compose.onNodeWithTag(FEEDBACK_SENT_TAG).assertExists() + reach(FEEDBACK_NOTIFY_CARD_TAG) + reach("Done", byText = true) + } + reach("Not now", byText = true).performClick() + fixture.compose.waitForIdle() + acrossWindowChanges { fixture.compose.onNodeWithTag(FEEDBACK_NOTIFY_CARD_TAG).assertDoesNotExist() } + recreate(COMPACT) + fixture.compose.onNodeWithTag(FEEDBACK_NOTIFY_CARD_TAG).assertDoesNotExist() + + fixture.compose.onNodeWithText("Done").performClick() + fixture.compose.waitForIdle() + assertEquals(1, backs) + assertEquals(1, sent.size) + } + + @Test + fun `a message sent through the old form never offers notifications`() { + result = SendResult.SentByEmailForm + render() + fixture.compose.onNodeWithTag(FEEDBACK_MESSAGE_TAG).performTextInput(MESSAGE) + reach(FEEDBACK_SEND_TAG).performClick() + fixture.compose.waitUntil(5_000) { viewModel.uiState.value.isSuccess } + acrossWindowChanges { + fixture.compose.onNodeWithText(feedbackSentBody(repliesInApp = false)).assertExists() + fixture.compose.onNodeWithTag(FEEDBACK_NOTIFY_CARD_TAG).assertDoesNotExist() + } + } + + private fun composeStateKept() { + fixture.compose.onNodeWithText(MESSAGE).assertExists() + fixture.compose.onNodeWithText("Bug report").assertIsSelected() + reach(FEEDBACK_SEND_TAG).assertIsEnabled() + assertEquals(1, fixture.compose.onAllNodes(hasText(MESSAGE)).fetchSemanticsNodes().size) + } + + /** Scrolls to the node when it sits in a scrolling column (short windows), then checks it is on screen. */ + private fun reach(key: String, byText: Boolean = false): SemanticsNodeInteraction { + val matcher = if (byText) hasText(key) else hasTestTag(key) + val scrolled = fixture.compose.onAllNodes(matcher and hasAnyAncestor(hasScrollAction())).fetchSemanticsNodes().isNotEmpty() + val node = fixture.compose.onNode(matcher) + if (scrolled) node.performScrollTo() + return node.assertIsDisplayed() + } + + private fun acrossWindowChanges(assertion: () -> Unit) { + val start = window.value + (TRANSITIONS + start).forEachIndexed { step, size -> + fixture.compose.runOnIdle { window.value = size } + fixture.compose.runOnUiThread { Snapshot.sendApplyNotifications() } + fixture.compose.waitForIdle() + try { + assertion() + } catch (failure: AssertionError) { + throw AssertionError("After window step $step ($size): ${failure.message}", failure) + } + } + } + + private fun recreate(to: BoxloreWindowLayout) { + fixture.compose.runOnIdle { window.value = to } + restoration.emulateSavedInstanceStateRestore() + fixture.compose.waitForIdle() + } + + private fun render() { + val context = ApplicationProvider.getApplicationContext() + context.getSharedPreferences(BoxcastPrefs.PREFS_NAME, Context.MODE_PRIVATE).edit().clear().commit() + val prefs = BoxcastPrefs(context) + val support = FeedbackSupport( + meta = MutableStateFlow(SupportMeta.Offline), + refresh = {}, + uploadPhoto = { _, _ -> null }, + send = { feedback -> + sent += feedback + result + }, + ) + // Built outside composition, like the navigation entry's view model store. + viewModel = FeedbackViewModel(support, prefs, context, ioDispatcher = Dispatchers.Unconfined) + fixture.prepare() + fixture.compose.mainClock.autoAdvance = true + restoration = StateRestorationTester(fixture.compose) + restoration.setContent { + val size = window.value + val state by viewModel.uiState.collectAsState() + CompositionLocalProvider( + LocalBoxloreWindowLayout provides size, + LocalBoxlorePaneWidth provides size.widthDp, + LocalEchoMotion provides false, + ) { + Box(Modifier.width(size.widthDp.dp).height(size.heightDp.dp)) { + BoxLoreTheme(dynamicColor = false) { + FeedbackContent( + state = state, + actions = FeedbackComposeActions( + onBack = { backs++ }, + onCategorySelected = viewModel::onCategorySelected, + onMessageChanged = viewModel::onMessageChanged, + onAttachDiagnosticsChanged = viewModel::onAttachDiagnosticsChanged, + onPhotosPicked = viewModel::onPhotosPicked, + onRemovePhoto = viewModel::onRemovePhoto, + onSubmit = viewModel::onSubmit, + onPreviewDiagnostics = {}, + onDiscardDraft = viewModel::onDiscardDraft, + ), + boxcastPrefs = prefs, + bottomContentPadding = 96.dp, + ) + } + } + } + } + fixture.compose.waitForIdle() + } + + companion object { + private const val MESSAGE = "The sleep timer stops one episode early" + private val COMPACT = BoxloreWindowLayout(599f, 800f) + private val FOLD_FLAT = BoxloreWindowLayout(673f, 841f) + private val FOLD_TABLETOP = FOLD_FLAT.copy( + folds = listOf( + BoxloreFold(BoxloreFoldState.HalfOpened, BoxloreFoldOrientation.Horizontal, BoxloreLayoutBounds(0f, 410f, 673f, 431f), true, true), + ), + ) + + /** Each gate is crossed in both directions, including the short window that scrolls; the walk ends where it started. */ + private val TRANSITIONS = listOf( + BoxloreWindowLayout(599f, 800f), + BoxloreWindowLayout(600f, 800f), + BoxloreWindowLayout(599f, 800f), + BoxloreWindowLayout(840f, 479f), + BoxloreWindowLayout(840f, 480f), + BoxloreWindowLayout(840f, 479f), + BoxloreWindowLayout(839f, 600f), + BoxloreWindowLayout(840f, 600f), + BoxloreWindowLayout(839f, 600f), + BoxloreWindowLayout(600f, 960f), + BoxloreWindowLayout(960f, 600f), + BoxloreWindowLayout(600f, 960f), + BoxloreWindowLayout(800f, 360f), + BoxloreWindowLayout(360f, 800f), + FOLD_FLAT, + FOLD_TABLETOP, + FOLD_FLAT, + ) + } +} diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackLayoutPolicyTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackLayoutPolicyTest.kt new file mode 100644 index 000000000..9e1e80ed2 --- /dev/null +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackLayoutPolicyTest.kt @@ -0,0 +1,34 @@ +package cx.aswin.boxlore.feature.settings.feedback + +import androidx.compose.ui.unit.dp +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The typing layout, keyboard clearance and notification-ask rules, without composition. */ +class FeedbackLayoutPolicyTest { + @Test fun `the typing layout needs both focus and a keyboard`() { + assertTrue(isTypingLayout(messageFocused = true, imeBottom = 300.dp)) + assertFalse(isTypingLayout(messageFocused = true, imeBottom = 0.dp)) + assertFalse(isTypingLayout(messageFocused = false, imeBottom = 300.dp)) + } + + @Test fun `the keyboard replaces the bottom chrome instead of stacking on it`() { + assertEquals(300.dp, feedbackBottomPadding(imeBottom = 300.dp, chrome = 96.dp, systemBar = 24.dp)) + assertEquals(120.dp, feedbackBottomPadding(imeBottom = 0.dp, chrome = 96.dp, systemBar = 24.dp)) + } + + @Test fun `Android's prompt is asked for only while it will still show`() { + assertTrue(canAskForNotifications(sdkInt = 33, granted = false, askedBefore = false, shouldShowRationale = false)) + assertTrue(canAskForNotifications(sdkInt = 33, granted = false, askedBefore = true, shouldShowRationale = true)) + assertFalse("Denied twice: open settings instead", canAskForNotifications(33, granted = false, askedBefore = true, shouldShowRationale = false)) + assertFalse(canAskForNotifications(sdkInt = 33, granted = true, askedBefore = false, shouldShowRationale = false)) + assertFalse("No runtime prompt before Android 13", canAskForNotifications(32, granted = false, askedBefore = false, shouldShowRationale = false)) + } + + @Test fun `only a conversation promises a reply in Feedback`() { + assertTrue(feedbackSentBody(repliesInApp = true).contains("reply will show up in Feedback")) + assertFalse(feedbackSentBody(repliesInApp = false).contains("reply")) + } +} 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 deleted file mode 100644 index 36f697083..000000000 --- a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSuccessContentTest.kt +++ /dev/null @@ -1,99 +0,0 @@ -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")) - } -} 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 d4f00ef92..da3c19c63 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 @@ -1,12 +1,17 @@ package cx.aswin.boxlore.feature.settings.feedback import android.content.Context +import android.net.Uri import androidx.test.core.app.ApplicationProvider -import cx.aswin.boxlore.core.catalog.PodcastRepository +import cx.aswin.boxlore.core.analytics.AnalyticsHelper import cx.aswin.boxlore.core.prefs.BoxcastPrefs import cx.aswin.boxlore.core.prefs.FeedbackDraft +import cx.aswin.boxlore.core.support.OutgoingFeedback +import cx.aswin.boxlore.core.support.SendResult +import cx.aswin.boxlore.core.support.SupportMeta import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain @@ -21,7 +26,6 @@ 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 @@ -33,19 +37,36 @@ class FeedbackViewModelTest { private val testDispatcher = StandardTestDispatcher() private lateinit var context: Context private lateinit var prefs: BoxcastPrefs - private lateinit var dummyPodcastRepository: PodcastRepository + + /** Records what the screen sends; [result] and [photoIds] decide the answers. */ + private class FakeSupport(meta: SupportMeta = SupportMeta.Offline) { + val meta = MutableStateFlow(meta) + val sent = mutableListOf() + val uploads = mutableListOf() + var result: SendResult = SendResult.Sent("t1") + var photoIds: (Int) -> String? = { "p$it" } + var refreshes = 0 + + fun asSupport() = FeedbackSupport( + meta = meta, + refresh = { refreshes++ }, + uploadPhoto = { bytes, _ -> + uploads += bytes.size + photoIds(uploads.size) + }, + send = { feedback -> + sent += feedback + result + }, + ) + } @Before fun setUp() { Dispatchers.setMain(testDispatcher) context = ApplicationProvider.getApplicationContext() - context - .getSharedPreferences(BoxcastPrefs.PREFS_NAME, Context.MODE_PRIVATE) - .edit() - .clear() - .commit() + context.getSharedPreferences(BoxcastPrefs.PREFS_NAME, Context.MODE_PRIVATE).edit().clear().commit() prefs = BoxcastPrefs(context) - dummyPodcastRepository = mock(PodcastRepository::class.java) } @After @@ -53,8 +74,11 @@ class FeedbackViewModelTest { Dispatchers.resetMain() } + private fun viewModel(support: FakeSupport, readPhoto: (Uri) -> ByteArray? = { ByteArray(10) }) = + FeedbackViewModel(support.asSupport(), prefs, context, ioDispatcher = testDispatcher, readPhoto = readPhoto) + @Test - fun initial_state_restores_draft_from_BoxcastPrefs_when_present() { + fun an_old_draft_comes_back_with_its_steps_joined_to_the_message() { prefs.saveFeedbackDraft( FeedbackDraft( category = "bug", @@ -64,231 +88,231 @@ class FeedbackViewModelTest { attachDiagnostics = true, ), ) - - val vm = FeedbackViewModel(dummyPodcastRepository, prefs, context) - val state = vm.uiState.value + val state = viewModel(FakeSupport()).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) + assertEquals("Player stops after 3 minutes\n\n1. Play\n2. Wait", state.message) assertTrue(state.attachDiagnostics) assertTrue(state.isDraftRestored) } @Test - fun editing_fields_updates_state_and_persists_draft() { - val vm = FeedbackViewModel(dummyPodcastRepository, prefs, context) + fun typing_saves_the_draft_and_an_empty_message_clears_it() { + val vm = viewModel(FakeSupport()) + vm.onMessageChanged("Half a thought") + assertEquals("Half a thought", prefs.getFeedbackDraft()?.message) + assertEquals("", prefs.getFeedbackDraft()?.email) - vm.onCategorySelected(FeedbackCategory.FEATURE) - vm.onMessageChanged("Add dark AMOLED theme") - vm.onEmailChanged("fan@example.com") - vm.onAttachDiagnosticsChanged(false) + vm.onMessageChanged("") + assertNull(prefs.getFeedbackDraft()) + } - 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 the_message_is_cut_at_the_servers_limit() = runTest(testDispatcher) { + val support = FakeSupport(SupportMeta.Offline.copy(maxBody = 5)) + val vm = viewModel(support) + advanceUntilIdle() + vm.onMessageChanged("1234567890") + assertEquals("12345", vm.uiState.value.message) + assertEquals(1, support.refreshes) } @Test - fun onDiscardDraft_resets_state_and_clears_draft_from_BoxcastPrefs() { - prefs.saveFeedbackDraft( - FeedbackDraft( - category = "other", - message = "Something random", - stepsToReproduce = "step 1", - email = "user@example.com", - ), - ) + fun a_conversation_send_clears_the_draft_and_promises_a_reply_in_the_app() = runTest(testDispatcher) { + val events = mutableListOf>>() + val restore = AnalyticsHelper.installRecordingSink(events) + try { + val support = FakeSupport() + val vm = viewModel(support) + vm.onCategorySelected(FeedbackCategory.AUDIO) + vm.onMessageChanged(" Stream cuts off at end of track ") - val vm = FeedbackViewModel(dummyPodcastRepository, prefs, context) - assertTrue(vm.uiState.value.isDraftRestored) - assertEquals("user@example.com", vm.uiState.value.email) + vm.onSubmit() + advanceUntilIdle() - vm.onDiscardDraft() + val state = vm.uiState.value + assertTrue(state.isSuccess) + assertTrue(state.repliesInApp) + assertNull(prefs.getFeedbackDraft()) + val sent = support.sent.single() + assertEquals("audio", sent.category) + assertEquals("Stream cuts off at end of track", sent.body) + assertTrue(sent.diagnostics!!.contains("System Diagnostics")) + assertNotNull(sent.device) + val event = events.single { it.first == "feedback_sent" }.second + assertEquals("bug", event["category"]) + assertEquals(false, event["with_email"]) + assertTrue(event.values.none { it == "Stream cuts off at end of track" }) + } finally { + restore() + } + } - val state = vm.uiState.value - assertEquals("", state.message) - assertEquals("", state.stepsToReproduce) - assertEquals("", state.email) - assertFalse(state.isDraftRestored) - assertNull(prefs.getFeedbackDraft()) + @Test + fun a_send_through_the_old_form_does_not_promise_a_reply_in_the_app() = runTest(testDispatcher) { + val support = FakeSupport().apply { result = SendResult.SentByEmailForm } + val vm = viewModel(support) + vm.onMessageChanged("An idea") + + vm.onSubmit() + advanceUntilIdle() + + assertTrue(vm.uiState.value.isSuccess) + assertFalse(vm.uiState.value.repliesInApp) } @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, - ), - ) + fun a_refusal_shows_the_servers_words_and_keeps_the_draft() = runTest(testDispatcher) { + val events = mutableListOf>>() + val restore = AnalyticsHelper.installRecordingSink(events) + try { + val support = FakeSupport().apply { result = SendResult.Refused("You've sent a lot today. Try again tomorrow.") } + val vm = viewModel(support) + vm.onMessageChanged("Another one") - val vm = FeedbackViewModel(dummyPodcastRepository, prefs, context) - val state = vm.uiState.value + vm.onSubmit() + advanceUntilIdle() - 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) + val state = vm.uiState.value + assertFalse(state.isSuccess) + assertFalse(state.isSubmitting) + assertEquals("You've sent a lot today. Try again tomorrow.", state.errorMessage) + assertEquals("Another one", prefs.getFeedbackDraft()?.message) + assertTrue(events.none { it.first == "feedback_sent" }) + } finally { + restore() + } } @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(" ") + fun a_failed_send_keeps_the_message_and_can_be_retried() = runTest(testDispatcher) { + val support = FakeSupport().apply { result = SendResult.Failed } + val vm = viewModel(support) + vm.onMessageChanged("Try me twice") vm.onSubmit() + advanceUntilIdle() + assertEquals("Couldn't send your feedback. Check your connection and try again.", vm.uiState.value.errorMessage) + assertEquals("Try me twice", vm.uiState.value.message) - val state = vm.uiState.value - assertNotNull(state.errorMessage) - assertFalse(state.isSubmitting) - assertFalse(called) + support.result = SendResult.Sent("t2") + vm.onSubmit() + advanceUntilIdle() + assertTrue(vm.uiState.value.isSuccess) + assertEquals(2, support.sent.size) } @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") + fun a_blank_message_is_never_sent() = runTest(testDispatcher) { + val support = FakeSupport() + val vm = viewModel(support) + vm.onMessageChanged(" ") 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()) + assertTrue(support.sent.isEmpty()) + assertEquals("Write a message first.", vm.uiState.value.errorMessage) } @Test - fun onSubmit_sends_feedback_sent_only_when_the_server_accepts_it() = runTest(testDispatcher) { - val events = mutableListOf>>() - val restore = cx.aswin.boxlore.core.analytics.AnalyticsHelper.installRecordingSink(events) - try { - var accepted = false - val vm = FeedbackViewModel( - dummyPodcastRepository, - prefs, - context, - ioDispatcher = testDispatcher, - submitFeedbackAction = { _, _, _, _, _, _ -> accepted }, - ) - vm.onCategorySelected(FeedbackCategory.AUDIO) - vm.onMessageChanged("Stream cuts off at end of track") - vm.onEmailChanged("dev@example.com") + fun photos_are_ignored_while_the_server_has_them_off() = runTest(testDispatcher) { + val vm = viewModel(FakeSupport()) + advanceUntilIdle() + vm.onPhotosPicked(listOf(Uri.parse("content://a"))) + assertTrue(vm.uiState.value.photos.isEmpty()) + assertFalse(vm.uiState.value.canAddPhotos) + } - vm.onSubmit() - advanceUntilIdle() - assertTrue(events.none { it.first == "feedback_sent" }) + @Test + fun photos_upload_in_order_before_the_message_and_stop_at_the_limit() = runTest(testDispatcher) { + val support = FakeSupport(SupportMeta.Offline.copy(photosEnabled = true, maxPhotos = 2, maxPhotoBytes = 100)) + val vm = viewModel(support) + advanceUntilIdle() + val a = Uri.parse("content://a") + vm.onPhotosPicked(listOf(a, a, Uri.parse("content://b"), Uri.parse("content://c"))) + assertEquals(listOf("content://a", "content://b"), vm.uiState.value.photos.map { it.uri.toString() }) + assertFalse(vm.uiState.value.canAddPhotos) - accepted = true - vm.onSubmit() - advanceUntilIdle() - val sent = events.single { it.first == "feedback_sent" }.second - assertEquals("bug", sent["category"]) - assertEquals(true, sent["with_email"]) - assertTrue(sent.values.none { it == "Stream cuts off at end of track" || it == "dev@example.com" }) - } finally { - restore() - } + vm.onMessageChanged("See the screenshot") + vm.onSubmit() + advanceUntilIdle() + + assertEquals(listOf("p1", "p2"), support.sent.single().photoIds) + assertTrue(vm.uiState.value.photos.isEmpty()) } @Test - fun onSubmit_with_audio_category_maps_to_bug_on_wire() = runTest(testDispatcher) { - var capturedCategory: String? = null - - val vm = FeedbackViewModel( - dummyPodcastRepository, - prefs, - context, - ioDispatcher = testDispatcher, - submitFeedbackAction = { cat, _, _, _, _, _ -> - capturedCategory = cat - true - }, - ) - vm.onCategorySelected(FeedbackCategory.AUDIO) - vm.onMessageChanged("Stream cuts off at end of track") + fun a_failed_photo_upload_sends_nothing() = runTest(testDispatcher) { + val support = FakeSupport(SupportMeta.Offline.copy(photosEnabled = true, maxPhotos = 3, maxPhotoBytes = 100)) + support.photoIds = { index -> if (index == 2) null else "p$index" } + val vm = viewModel(support) + advanceUntilIdle() + vm.onPhotosPicked(listOf(Uri.parse("content://a"), Uri.parse("content://b"))) + vm.onMessageChanged("With photos") vm.onSubmit() advanceUntilIdle() - val state = vm.uiState.value - assertTrue(state.isSuccess) - assertEquals("bug", capturedCategory) + assertTrue(support.sent.isEmpty()) + assertFalse(vm.uiState.value.isSuccess) + assertEquals("Couldn't attach your photos. Check your connection and try again.", vm.uiState.value.errorMessage) + assertEquals(2, vm.uiState.value.photos.size) } @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") + fun a_photo_over_the_size_limit_sends_nothing() = runTest(testDispatcher) { + val support = FakeSupport(SupportMeta.Offline.copy(photosEnabled = true, maxPhotos = 3, maxPhotoBytes = 5)) + val vm = viewModel(support) { ByteArray(6) } + advanceUntilIdle() + vm.onPhotosPicked(listOf(Uri.parse("content://a"))) + vm.onMessageChanged("Big photo") vm.onSubmit() advanceUntilIdle() + assertTrue(support.uploads.isEmpty()) + assertTrue(support.sent.isEmpty()) + } + + @Test + fun paused_sending_disables_send_and_explains_why() = runTest(testDispatcher) { + val support = FakeSupport(SupportMeta.Offline.copy(canStartNew = false)) + val vm = viewModel(support) + advanceUntilIdle() + vm.onMessageChanged("Waiting") + + assertFalse(vm.uiState.value.canSend) + assertEquals(PAUSED_NOTICE, vm.uiState.value.shownNotice) + + support.meta.value = SupportMeta.Offline.copy(canStartNew = false, notice = "Back on Monday.") + advanceUntilIdle() + assertEquals("Back on Monday.", vm.uiState.value.shownNotice) + } + + @Test + fun discarding_the_draft_resets_everything() = runTest(testDispatcher) { + val support = FakeSupport(SupportMeta.Offline.copy(photosEnabled = true, maxPhotos = 3, maxPhotoBytes = 100)) + val vm = viewModel(support) + advanceUntilIdle() + vm.onCategorySelected(FeedbackCategory.BUG) + vm.onMessageChanged("Never mind") + vm.onPhotosPicked(listOf(Uri.parse("content://a"))) + + vm.onDiscardDraft() + val state = vm.uiState.value - assertTrue(state.isSuccess) - assertNotNull(capturedDiag) - assertTrue(capturedDiag!!.contains("System Diagnostics")) + assertEquals(FeedbackCategory.FEATURE, state.category) + assertEquals("", state.message) + assertTrue(state.photos.isEmpty()) + assertNull(prefs.getFeedbackDraft()) } @Test fun buildGitHubIssueUrl_creates_valid_URL_with_encoded_title() { - val vm = FeedbackViewModel(dummyPodcastRepository, prefs, context) + val vm = viewModel(FakeSupport()) vm.onCategorySelected(FeedbackCategory.BUG) vm.onMessageChanged("Crash when clicking download button") - vm.onStepsChanged("1. Tap download\n2. Observe crash") val url = buildFeedbackGitHubIssueUrl(vm.uiState.value) assertTrue(url.startsWith("https://github.com/boxcreate/boxlore/issues/new?title=")) @@ -298,29 +322,20 @@ class FeedbackViewModelTest { @Test fun category_selection_toggles_attachDiagnostics_default_for_bug_reports() { - val vm = FeedbackViewModel(dummyPodcastRepository, prefs, context) - // Default category is FEATURE -> attachDiagnostics should be false + val vm = viewModel(FakeSupport()) 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/release-notes/upcoming.md b/release-notes/upcoming.md index 595979e76..7c249cec7 100644 --- a/release-notes/upcoming.md +++ b/release-notes/upcoming.md @@ -34,6 +34,7 @@ Announcement title: - On New Episodes, swipe right to add an episode to your queue or left to mark it played, and pull down to check for new episodes. (#1198) - Listening in German, French, Spanish, Portuguese, Russian or Dutch? You can now turn English off in Library settings › Languages, and boxlore then adds English shows to recommendations, search and suggestions only when there aren't enough in your languages. (#1187, #1191) - Free up space with Clear saved shows in Library settings: it removes the episode lists saved for shows you opened but don't follow. (#1192) +- Feedback is now a conversation: pick a type, write your message and send, no email needed. The boxlore team's reply shows up in Feedback, and a notification tells you when it arrives if you turn them on. ## Improved diff --git a/settings.gradle.kts b/settings.gradle.kts index 01e0cfc8a..e8d8b4306 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -132,3 +132,4 @@ include(":feature:onboarding") include(":feature:briefing") include(":feature:widgets") include(":core:auth") +include(":core:support") From 651cf4ae34d7261e320f545c71eeedabaa281176 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Mon, 2 Nov 2026 02:14:30 +0530 Subject: [PATCH 2/8] feat(feedback): sign support requests and send retry keys Support requests carry X-Request-Proof, the device key's signature over the session, method and path, which the support API now requires. Each send carries a retry key that a resend of the same message reuses, and meta lists every push topic to subscribe to. --- core/network/README.md | 2 + .../boxlore/core/network/NetworkModule.kt | 13 +++- .../aswin/boxlore/core/network/SupportApi.kt | 2 +- .../core/network/model/SupportModels.kt | 5 +- .../core/network/session/ApiSessionManager.kt | 3 + .../session/RequestProofInterceptor.kt | 24 ++++++ .../network/session/RequestProofSigner.kt | 22 ++++++ .../core/network/session/RequestProofTest.kt | 73 +++++++++++++++++++ core/support/README.md | 16 +++- .../boxlore/core/support/SupportModels.kt | 7 +- .../boxlore/core/support/SupportRepository.kt | 7 +- .../core/support/SupportRepositoryTest.kt | 10 ++- feature/settings/README.md | 4 +- .../settings/feedback/FeedbackViewModel.kt | 22 ++++++ .../feedback/FeedbackViewModelTest.kt | 36 +++++++++ 15 files changed, 229 insertions(+), 17 deletions(-) create mode 100644 core/network/src/main/java/cx/aswin/boxlore/core/network/session/RequestProofInterceptor.kt create mode 100644 core/network/src/main/java/cx/aswin/boxlore/core/network/session/RequestProofSigner.kt create mode 100644 core/network/src/test/java/cx/aswin/boxlore/core/network/session/RequestProofTest.kt diff --git a/core/network/README.md b/core/network/README.md index deb111d69..37152d00e 100644 --- a/core/network/README.md +++ b/core/network/README.md @@ -21,6 +21,7 @@ Owns the Retrofit API boundary, OkHttp/Retrofit construction, request and respon - `NetworkModule` creates OkHttp, Retrofit, and related network clients. `createCachedClient(context)` builds the shared client with the 50 MiB HTTP cache; build `BoxLoreApi` and `BoxLoreApiV2` on that one client (`createBoxLoreApi(baseUrl, client)`, `createBoxLoreApiV2(baseUrl, client)`), because two caches must never share a directory. There is no context-based `createBoxLoreApi` shortcut, so nothing can open a second cache on the same directory; tests pass their own client. The clients come with automatic header redaction for `Authorization`, `X-App-Key`, `X-Firebase-AppCheck`, `X-Boxlore-Session`, `X-Session-Renew` and `X-Session-Proof`. Every request carries `X-App-Version` and `X-App-Channel` (`play` or `github`) when the app sets `appVersion` / `appChannel`. `createSessionExchangeClient()` is the plain client for `POST /auth/session` (no session interceptor, no Firebase authenticator, no cache). - **English off (D9, 29 Oct).** `EnglishOffInterceptor` (first on the API client) adds `englishOff` to any request whose languages leave English out, which happens only when the listener switched it off: `englishOff=1` on a `languages` query, `"englishOff": true` in a JSON body with a `languages` array. Requests with English, without languages or that already say pass through byte for byte (`EnglishOffInterceptorTest`). - **API sessions (`session/`, 2026-10-17).** This build authenticates with API session tickets only and **never sends the bundled app key**: `ApiSessionInterceptor` strips `X-App-Key` from every request. `NetworkModule.sessionManager` is set by the app at startup. +- **Request proofs (`session/RequestProofInterceptor`, 2026-11-02).** A session can be asked for with any install id, so routes holding an install's private data need proof the request comes from the phone that registered the session's device key. `RequestProofSigner` (`ApiSessionManager.requestProofs`) signs `bls-request....` (API clock) with the device key, and `RequestProofInterceptor`, a network interceptor that `NetworkModule.createSupportApi` adds to the support client only, sends it as `X-Request-Proof` (redacted in logs). No device key or no session means no header, and the API answers 401. - `ApiSessionManager` owns the install's session. One shared `POST /auth/session` exchange runs at a time; `start()` begins it at launch when no usable session is stored, and requests wait for it (at most 15 s) only then: a fresh install, or after the 30-day attestation window. With a stored session, requests attach it without waiting. Failed exchanges back off (2 s, 5 s, 15 s, 30 s, 1 min, then every 5 min, or the server's `Retry-After`); inside a backoff, requests go out without a session at once. There is no app-key fallback. - Exchanges send `{installId, platform: "android", channel, devicePublicKey}` (plus `turnstileToken` for a step-up) with `X-Firebase-AppCheck`. App Check is fetched only here, bounded to 6 s; a phone that has failed App Check gets its next session without asking App Check on the critical path. Background upgrades and refreshes give it 30 s instead (`backgroundAppCheckTimeoutMs`): they run while a usable session is held, so no request waits, and slow Play Integrity has time to finish. - Renewal: once a token is past `exp` (30 s early, for requests in flight), requests add `X-Session-Proof: .`, a SHA256withECDSA signature of `bls-renew..` by the device key (`AndroidKeystoreDeviceKey`: non-exportable EC P-256 in StrongBox when available, else the TEE). One proof is reused for up to 2 minutes. `X-Session-Renew` from a network response (never a cached one) replaces the token when it extends the same session. The API clock is learned from each token's `iat`, so a wrong phone clock does not break renewals. @@ -94,6 +95,7 @@ src/main/java/cx/aswin/boxlore/core/network/ - Prefer MockWebServer over live backend calls. - `session/ApiSessionInterceptorTest` (MockWebServer): a stored session goes out at once while an exchange hangs and no request carries `X-App-Key`; one exchange serves concurrent first requests; `X-Session-Renew` is stored and foreign or older renewals are ignored; the proof appears only from 30 s before `exp`; a missing device key sends no proof and reports it once; a refused session is replaced once; a brand-new session refused again backs off; failed exchanges back off without an app-key fallback; App Check failure gives a limited session and the next launch skips App Check; the step-up retries with a `challenge` session, a failed step-up returns the 401 and cools down, and it stays off until enabled; `daily_ai_limit` passes through; other hosts get no session. - `session/ApiSessionSchedulerTest` (virtual time): limited → full upgrade on the 1 min / hourly / daily schedule, a slow App Check that times out at launch but passes on the background upgrade, nothing in the background or offline, an overdue retry when the network returns, and the pure next-step plan. `ApiSessionPolicyTest` covers token parsing, proof timing, usability, replacement and backoffs. `OkHttpSessionExchangerTest` covers the `/auth/session` body, headers and failure reasons. `FirebaseAuthAuthenticatorTest` checks shield 401s are left alone. +- `session/RequestProofTest`: the signed message and its clock, no proof without a device key or a readable session, and the interceptor signing only requests that carry a session. - `BoxLoreApiV2ContractTest` (MockWebServer) checks every `/v2` path (also under a base URL with a path prefix) and that no request carries `X-App-Key`; the exact request JSON of shows/feeds, feed reports, registration, recommendations (with `contractVersion: 2`), bootstrap (with and without its current-algorithm recommendations part), similar episodes (a recipe ID `id`, `limit` left at its default is not sent) and because-you-like (with and without `excludePodcastId` and `country`) (defaults are not encoded, so only explicitly set fields go out); decoding of show addresses (including `fetchUrl: null` and proxied shows), that a proxied show is read with `getFeed` on the base URL and never at the server's `transportUrl`, `needsFeed` with and without an address, a known episode with its identity, the fallback list, a 404 and a 304; that feed documents never enter the HTTP cache; and briefing URLs. `model/V2ModelsTest` covers the range checks (including the episode-route show limit and `/v2/episodes` paging bounds), strict show ID parsing, request validation (including the 2,048-character catalog key) and read targets. - `BoxLoreApiSyncContractTest` (MockWebServer) checks the push and pull JSON: each row's `recipeId` and `catalogKey` are sent only when set, the rich removals are sent (an empty list too) and left out only when unset, a pull sends `richRemovals: true` only when asked and sends nothing for the defaults, and a pull answer recorded in the sync API's shape decodes (recipe fields as numbers or null, the rich removals present or absent), plus the range checks of `SyncEpisodeIdentityFields`. - `OnboardingGuidedContractTest` checks legacy decoding, request-specific opt-in and structured recovery action decoding, including unknown additive fields, and the finish-hint opt-in (features, country, languages omitted by default; `finish` decoded), plus show deck cards with and without a preview and opt-in reactions. diff --git a/core/network/src/main/java/cx/aswin/boxlore/core/network/NetworkModule.kt b/core/network/src/main/java/cx/aswin/boxlore/core/network/NetworkModule.kt index 8bdd2f0ba..5551b34f3 100644 --- a/core/network/src/main/java/cx/aswin/boxlore/core/network/NetworkModule.kt +++ b/core/network/src/main/java/cx/aswin/boxlore/core/network/NetworkModule.kt @@ -3,6 +3,7 @@ package cx.aswin.boxlore.core.network import android.util.Log import cx.aswin.boxlore.core.network.session.ApiSessionInterceptor import cx.aswin.boxlore.core.network.session.ApiSessionManager +import cx.aswin.boxlore.core.network.session.RequestProofInterceptor import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient @@ -43,6 +44,9 @@ object NetworkModule { private val sessionInterceptor = ApiSessionInterceptor { sessionManager } + /** Signs support requests with the device key (see [RequestProofInterceptor]). */ + private val requestProofInterceptor = RequestProofInterceptor { token, method, path -> sessionManager?.requestProofs?.proof(token, method, path) } + /** * Optional Authenticator (e.g. FirebaseAuthAuthenticator) for handling HTTP 401 * token refreshes. Set by application wiring at startup. @@ -64,6 +68,7 @@ object NetworkModule { redactHeader(ApiSessionInterceptor.SESSION_HEADER) redactHeader(ApiSessionInterceptor.RENEW_HEADER) redactHeader(ApiSessionInterceptor.PROOF_HEADER) + redactHeader(RequestProofInterceptor.PROOF_HEADER) } private val okHttpClient = OkHttpClient.Builder().apply { @@ -123,8 +128,12 @@ object NetworkModule { */ fun createBoxLoreApiV2(baseUrl: String, client: OkHttpClient): BoxLoreApiV2 = retrofit(baseUrl, client).create(BoxLoreApiV2::class.java) - /** Builds [SupportApi] on the same cached client as [BoxLoreApi] ([createCachedClient]). */ - fun createSupportApi(baseUrl: String, client: OkHttpClient): SupportApi = retrofit(baseUrl, client).create(SupportApi::class.java) + /** + * Builds [SupportApi] on [client] (production: the cached client from [createCachedClient]), + * with every request signed by the device key, which the support routes require. + */ + fun createSupportApi(baseUrl: String, client: OkHttpClient): SupportApi = + retrofit(baseUrl, client.newBuilder().addNetworkInterceptor(requestProofInterceptor).build()).create(SupportApi::class.java) private fun retrofit(baseUrl: String, client: OkHttpClient): Retrofit = Retrofit.Builder() .baseUrl(baseUrl) diff --git a/core/network/src/main/java/cx/aswin/boxlore/core/network/SupportApi.kt b/core/network/src/main/java/cx/aswin/boxlore/core/network/SupportApi.kt index 12af6c32e..7f1fd3784 100644 --- a/core/network/src/main/java/cx/aswin/boxlore/core/network/SupportApi.kt +++ b/core/network/src/main/java/cx/aswin/boxlore/core/network/SupportApi.kt @@ -17,7 +17,7 @@ import retrofit2.http.POST * Build it with [NetworkModule.createSupportApi] on the same cached client as [BoxLoreApi]. */ interface SupportApi { - /** `meta` (switches, limits, push topic) and this install's conversations. */ + /** `meta` (switches, limits, push topics) and this install's conversations. */ @GET("support/threads") fun threads(): Call diff --git a/core/network/src/main/java/cx/aswin/boxlore/core/network/model/SupportModels.kt b/core/network/src/main/java/cx/aswin/boxlore/core/network/model/SupportModels.kt index f9000354b..0e71ffe59 100644 --- a/core/network/src/main/java/cx/aswin/boxlore/core/network/model/SupportModels.kt +++ b/core/network/src/main/java/cx/aswin/boxlore/core/network/model/SupportModels.kt @@ -15,7 +15,8 @@ data class SupportMetaDto( val canStartNew: Boolean = true, val photos: SupportPhotoLimitsDto = SupportPhotoLimitsDto(), val maxBody: Int = 2000, - val pushTopic: String? = null, + /** Every topic to subscribe to for reply pushes: this install's, and the account's when signed in. */ + val pushTopics: List = emptyList(), val replyAuthor: String? = null, ) @@ -60,6 +61,8 @@ data class SupportNewThreadRequest( val device: String? = null, val diagnostics: String? = null, val logs: String? = null, + /** Same key for a retry of the same message, so the API saves it once. */ + val requestId: String? = null, ) /** Error bodies (`paused`, `limit`, `photos_off`, ...) with an optional message to show as is. */ diff --git a/core/network/src/main/java/cx/aswin/boxlore/core/network/session/ApiSessionManager.kt b/core/network/src/main/java/cx/aswin/boxlore/core/network/session/ApiSessionManager.kt index b79f0839e..c14cf5510 100644 --- a/core/network/src/main/java/cx/aswin/boxlore/core/network/session/ApiSessionManager.kt +++ b/core/network/src/main/java/cx/aswin/boxlore/core/network/session/ApiSessionManager.kt @@ -160,6 +160,9 @@ class ApiSessionManager( fun stepUpForAiRoute(): SessionHeaders? = runBlocking { withTimeoutOrNull(timing.stepUpRequestWaitMs) { stepUp.run(joinRunning = false) } }?.let { current() } + /** Signs requests to routes that hold this install's private data (see [RequestProofSigner]). */ + val requestProofs = RequestProofSigner(deviceKey) { synchronized(lock) { book.serverNow } } + fun onForegroundChanged(foreground: Boolean) = scheduler.setForeground(foreground) fun onNetworkChanged(online: Boolean) = scheduler.setOnline(online) diff --git a/core/network/src/main/java/cx/aswin/boxlore/core/network/session/RequestProofInterceptor.kt b/core/network/src/main/java/cx/aswin/boxlore/core/network/session/RequestProofInterceptor.kt new file mode 100644 index 000000000..8fbd819ed --- /dev/null +++ b/core/network/src/main/java/cx/aswin/boxlore/core/network/session/RequestProofInterceptor.kt @@ -0,0 +1,24 @@ +package cx.aswin.boxlore.core.network.session + +import okhttp3.Interceptor +import okhttp3.Response + +/** + * Adds [PROOF_HEADER] to requests that carry a session: the device key's signature over the + * session, method and path ([RequestProofSigner]). A session can be asked for with + * any install id, so routes holding an install's private data (support) also need this proof that + * the request comes from the phone that registered the key. Install it as a network interceptor, + * so it signs the session header [ApiSessionInterceptor] attached, retries included. + */ +class RequestProofInterceptor(private val sign: (token: String, method: String, path: String) -> String?) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val token = request.header(ApiSessionInterceptor.SESSION_HEADER) ?: return chain.proceed(request) + val proof = runCatching { sign(token, request.method, request.url.encodedPath) }.getOrNull() ?: return chain.proceed(request) + return chain.proceed(request.newBuilder().header(PROOF_HEADER, proof).build()) + } + + companion object { + const val PROOF_HEADER = "X-Request-Proof" + } +} diff --git a/core/network/src/main/java/cx/aswin/boxlore/core/network/session/RequestProofSigner.kt b/core/network/src/main/java/cx/aswin/boxlore/core/network/session/RequestProofSigner.kt new file mode 100644 index 000000000..9756a47a1 --- /dev/null +++ b/core/network/src/main/java/cx/aswin/boxlore/core/network/session/RequestProofSigner.kt @@ -0,0 +1,22 @@ +package cx.aswin.boxlore.core.network.session + +/** + * Signs one request with the device key, for routes that hold this install's private data + * (support): `.` over [message] for the session in the request, on the + * API's clock ([serverNow], unix seconds). [ApiSessionManager.requestProofs] is the app's one. + */ +class RequestProofSigner(private val deviceKey: DeviceKeySigner, private val serverNow: () -> Long) { + /** Null when the token can't be read, has no device key, or the key can't sign; the API then refuses the route. */ + fun proof(rawToken: String, method: String, path: String): String? { + val token = ApiSessionToken.parse(rawToken)?.takeIf { it.hasDeviceKey } ?: return null + val now = serverNow() + val signature = runCatching { deviceKey.sign(message(token.sid, method, path, now)) }.getOrNull() ?: return null + return "$now.$signature" + } + + companion object { + /** The string the device key signs: `bls-request....`. */ + fun message(sid: String, method: String, path: String, unixSeconds: Long): String = + "bls-request.$sid.${method.uppercase()}.$path.$unixSeconds" + } +} diff --git a/core/network/src/test/java/cx/aswin/boxlore/core/network/session/RequestProofTest.kt b/core/network/src/test/java/cx/aswin/boxlore/core/network/session/RequestProofTest.kt new file mode 100644 index 000000000..fac9ef48a --- /dev/null +++ b/core/network/src/test/java/cx/aswin/boxlore/core/network/session/RequestProofTest.kt @@ -0,0 +1,73 @@ +package cx.aswin.boxlore.core.network.session + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +/** Support requests carry the device key's signature over the session, method and path. */ +class RequestProofTest { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val deviceKey = FakeDeviceKey() + + @AfterEach + fun tearDown() = scope.cancel() + + private fun manager() = ApiSessionManager( + apiBaseUrl = "https://api.example.com/", + exchanger = FakeExchanger { issued(sessionToken()) }, + store = FakeStore(), + deviceKey = deviceKey, + appCheck = { AppCheckResult.Failed("unused") }, + scope = scope, + clock = { BASE_MS }, + ) + + @Test + fun `signs the session, method and path on the API's clock`() { + val proof = manager().requestProofs.proof(sessionToken(sid = "sid-9"), "post", "/support/threads") + val message = RequestProofSigner.message("sid-9", "POST", "/support/threads", BASE_S) + assertEquals("bls-request.sid-9.POST./support/threads.$BASE_S", message) + assertEquals("$BASE_S.${deviceKey.signatureOf(message)}", proof) + } + + @Test + fun `no proof without a device key or a readable session`() { + assertNull(manager().requestProofs.proof(sessionToken(deviceKey = false), "GET", "/support/threads")) + assertNull(manager().requestProofs.proof("not-a-token", "GET", "/support/threads")) + deviceKey.available = false + assertNull(manager().requestProofs.proof(sessionToken(), "GET", "/support/threads")) + } + + @Test + fun `the interceptor signs the session the request carries, and leaves other requests alone`() { + val server = MockWebServer().apply { start() } + try { + val signed = mutableListOf>() + val client = OkHttpClient.Builder() + .addNetworkInterceptor( + RequestProofInterceptor { token, method, path -> + signed += Triple(token, method, path) + "proof-$method" + }, + ).build() + server.enqueue(MockResponse()) + server.enqueue(MockResponse()) + client.newCall(Request.Builder().url(server.url("/support/threads")).header(ApiSessionInterceptor.SESSION_HEADER, "token-1").build()).execute().close() + client.newCall(Request.Builder().url(server.url("/support/threads")).build()).execute().close() + assertEquals("proof-GET", server.takeRequest().getHeader(RequestProofInterceptor.PROOF_HEADER)) + assertNull(server.takeRequest().getHeader(RequestProofInterceptor.PROOF_HEADER)) + assertEquals(listOf(Triple("token-1", "GET", "/support/threads")), signed) + } finally { + server.shutdown() + } + } +} diff --git a/core/support/README.md b/core/support/README.md index 444de9541..6bccf8f68 100644 --- a/core/support/README.md +++ b/core/support/README.md @@ -3,7 +3,7 @@ ## Purpose Listener feedback as support conversations with the boxlore API (`/support/...`): reading what the -server allows (switches, limits, the push topic), sending a new message, and uploading photos. +server allows (switches, limits, the push topics), sending a new message, and uploading photos. Wording, statuses and limits come from the server, so they change without an app update. It does not own any UI (the screens live in `:feature:settings`) and does not talk to Telegram or @@ -40,7 +40,13 @@ Features use it; it never depends on a feature, the design system or the catalog ## Persistence & identity - Nothing stored on the phone. The API knows the install from the API session the shared client - attaches (`X-Boxlore-Session`); the app never sends an id of its own. + attaches (`X-Boxlore-Session`) together with the device key that session registered: every + support request carries `X-Request-Proof`, the device key's signature over the session, method + and path (`RequestProofInterceptor` in `:core:network`, added by `NetworkModule.createSupportApi`). + The app never sends an id of its own. Without a device key or proof the API answers 401 and the + message goes through the old form. +- `OutgoingFeedback.requestId` is the retry key: the screen sends the same key when the same message + is sent again, so a send whose answer was lost isn't saved twice. - Fallback: when the support API answers 404, 401 or 503 `support_unavailable`, the message goes through the old `POST /feedback` form (`legacySubmit`), so feedback sent before the server side is live (or without a session) is never lost. The old form has no `audio` category (sent as `bug`) @@ -48,8 +54,10 @@ Features use it; it never depends on a feature, the design system or the catalog ## Testing notes -- `SupportRepositoryTest` runs the real `SupportApi` against MockWebServer: meta parsing, sending, - the fallback cases, refusals with the server's message, no connection, photos off, `PhotoPrep.targetSize`. +- `SupportRepositoryTest` runs the real `SupportApi` against MockWebServer: meta parsing (push topics + filtered to valid names), sending with the retry key, the fallback cases (401 for a missing proof + included), refusals with the server's message, no connection, photos off, `PhotoPrep.targetSize`. + `RequestProofTest` in `:core:network` covers the signed message and the interceptor. ```bash ./gradlew :core:support:testDebugUnitTest diff --git a/core/support/src/main/java/cx/aswin/boxlore/core/support/SupportModels.kt b/core/support/src/main/java/cx/aswin/boxlore/core/support/SupportModels.kt index fed580052..714a0e4f4 100644 --- a/core/support/src/main/java/cx/aswin/boxlore/core/support/SupportModels.kt +++ b/core/support/src/main/java/cx/aswin/boxlore/core/support/SupportModels.kt @@ -8,11 +8,12 @@ data class SupportMeta( val maxPhotos: Int, val maxPhotoBytes: Long, val maxBody: Int, - val pushTopic: String?, + /** Topics to subscribe to for reply pushes (this install's, and the account's when signed in). */ + val pushTopics: List, ) { companion object { /** Before the first answer, or when the API is unreachable: text only, nothing switched on. */ - val Offline = SupportMeta(canStartNew = true, notice = null, photosEnabled = false, maxPhotos = 0, maxPhotoBytes = 0, maxBody = 2000, pushTopic = null) + val Offline = SupportMeta(canStartNew = true, notice = null, photosEnabled = false, maxPhotos = 0, maxPhotoBytes = 0, maxBody = 2000, pushTopics = emptyList()) } } @@ -25,6 +26,8 @@ data class OutgoingFeedback( val diagnostics: String?, val logs: String?, val photoIds: List = emptyList(), + /** The same key for a retry of the same message, so the API saves it once. */ + val requestId: String? = null, ) sealed interface SendResult { diff --git a/core/support/src/main/java/cx/aswin/boxlore/core/support/SupportRepository.kt b/core/support/src/main/java/cx/aswin/boxlore/core/support/SupportRepository.kt index 01cce57d0..ecf213d88 100644 --- a/core/support/src/main/java/cx/aswin/boxlore/core/support/SupportRepository.kt +++ b/core/support/src/main/java/cx/aswin/boxlore/core/support/SupportRepository.kt @@ -58,6 +58,7 @@ class SupportRepository( device = feedback.device, diagnostics = feedback.diagnostics, logs = feedback.logs, + requestId = feedback.requestId, ) val response = call { api.startThread(request).execute() } ?: return@withContext SendResult.Failed when { @@ -122,9 +123,13 @@ internal fun SupportListResponse.toMeta(): SupportMeta? { maxPhotos = m.photos.max.coerceIn(0, MAX_PHOTOS_CEILING), maxPhotoBytes = m.photos.maxBytes, maxBody = m.maxBody.coerceIn(1, MAX_BODY_CEILING), - pushTopic = m.pushTopic, + pushTopics = m.pushTopics.filter { TOPIC.matches(it) }.distinct().take(MAX_TOPICS), ) } +private const val MAX_TOPICS = 5 + +/** FCM topic names. */ +private val TOPIC = Regex("[A-Za-z0-9\\-_.~%]{1,900}") private const val MAX_PHOTOS_CEILING = 10 private const val MAX_BODY_CEILING = 10_000 diff --git a/core/support/src/test/java/cx/aswin/boxlore/core/support/SupportRepositoryTest.kt b/core/support/src/test/java/cx/aswin/boxlore/core/support/SupportRepositoryTest.kt index 635c3bb63..35ef0b865 100644 --- a/core/support/src/test/java/cx/aswin/boxlore/core/support/SupportRepositoryTest.kt +++ b/core/support/src/test/java/cx/aswin/boxlore/core/support/SupportRepositoryTest.kt @@ -54,12 +54,12 @@ class SupportRepositoryTest { fun `reads the switches and limits from the server`() = runTest { server.enqueue( MockResponse().setBody( - """{"meta":{"notice":"Slow this week","canStartNew":true,"photos":{"enabled":true,"max":5,"maxBytes":5242880},"maxBody":2000,"pushTopic":"support_ab","replyAuthor":"boxlore team","extra":1},"threads":[]}""", + """{"meta":{"notice":"Slow this week","canStartNew":true,"photos":{"enabled":true,"max":5,"maxBytes":5242880},"maxBody":2000,"pushTopics":["support_ab","support_cd","bad topic"],"replyAuthor":"boxlore team","extra":1},"threads":[]}""", ), ) val repo = repository() val meta = repo.refresh() - assertEquals(SupportMeta(true, "Slow this week", true, 5, 5_242_880, 2000, "support_ab"), meta) + assertEquals(SupportMeta(true, "Slow this week", true, 5, 5_242_880, 2000, listOf("support_ab", "support_cd")), meta) assertEquals(meta, repo.meta.value) assertEquals("/support/threads", server.takeRequest().path) } @@ -67,9 +67,10 @@ class SupportRepositoryTest { @Test fun `sends a conversation and keeps the audio category`() = runTest { server.enqueue(MockResponse().setResponseCode(201).setBody("""{"thread":{"id":"a1b2c3d4e5f6a1b2c3d4e5f6","status":{"key":"waiting"}}}""")) - val result = repository().send(feedback) + val result = repository().send(feedback.copy(requestId = "retry-key-0001")) assertEquals(SendResult.Sent("a1b2c3d4e5f6a1b2c3d4e5f6"), result) val body = server.takeRequest().body.readUtf8() + assertTrue(body.contains("\"requestId\":\"retry-key-0001\"")) assertTrue(body.contains("\"category\":\"audio\"")) assertTrue(body.contains("\"device\":\"Pixel 9\"")) assertTrue(legacyCalls.isEmpty()) @@ -80,12 +81,13 @@ class SupportRepositoryTest { for (reply in listOf( MockResponse().setResponseCode(404).setBody("""{"error":"Not Found"}"""), MockResponse().setResponseCode(401).setBody("""{"error":"session_required"}"""), + MockResponse().setResponseCode(401).setBody("""{"error":"proof_required"}"""), MockResponse().setResponseCode(503).setBody("""{"error":"support_unavailable"}"""), )) { server.enqueue(reply) assertEquals(SendResult.SentByEmailForm, repository().send(feedback)) } - assertEquals(3, legacyCalls.size) + assertEquals(4, legacyCalls.size) assertEquals(listOf("bug", "Static after ten minutes", "0.0.30", "Android 17", "log line"), legacyCalls.first()) } diff --git a/feature/settings/README.md b/feature/settings/README.md index 479b35aee..7578ed27b 100644 --- a/feature/settings/README.md +++ b/feature/settings/README.md @@ -21,7 +21,7 @@ Owns the unified Settings hub, category pages (Account, Sync & Backups, Appearan - Shared `SettingsScaffold` headers use a smaller 36sp expanded title and naturally wrap to two lines when needed, including Cloud Sync & Backups. The expanded title smoothly reduces to 22sp on scroll; the separate compact row always uses 22sp and retains two-line support for narrow screens and larger system fonts. Material's two-row app bar measures the title height, so a hidden expanded title cannot inflate the navigation row or force a single-line ellipsis. - `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. `FeedbackScreen(supportRepository, boxcastPrefs, onBack, bottomContentPadding)` takes `SupportRepository` from `:core:support` (app wiring passes the mini player clearance). -- **Feedback, redesigned 2026-11-02 (support conversations, phase 2).** "Share your thoughts" opens straight on the form: bold circular type pills (Feature idea, Bug report, Audio / Stream, General; `FeedbackTypePills`), a roomy message card that takes the free height, photos (only while the server's `meta.photos` switch is on; picked with the system photo picker, no storage permission, downscaled to 1600 px JPEG by `PhotoPrep`), an Attach diagnostics row, **Send feedback** at the bottom and a **How we handle feedback** link to the privacy page. Windows shorter than 600dp scroll with a 220dp message card. While typing (message focused and the keyboard up) the pills fold into one pill in the header and a slim bar with photos, diagnostics and **Send** rides on the keyboard, which replaces the bottom chrome padding (`isTypingLayout`, `feedbackBottomPadding`). The top-bar menu has Preview diagnostics, Rate on Play, Report on GitHub and Discard draft. The email and steps fields are gone; an older draft's steps join its message. Send is disabled while the server pauses new messages (`canStartNew`), with the server's notice or `PAUSED_NOTICE`; a refusal (today's limit) shows the server's words and keeps the draft. After sending, `FeedbackSentScreen` shows Echo's `feedback-sent` sequence (magnifier, then rest), "Feedback sent" and, when the message became a conversation and notifications are off, a **Know when the team replies** card (`canAskForNotifications`: Android's prompt while it will still show, otherwise the app's notification settings; re-checked on resume, **Not now** hides it). A message that fell back to the old one-way form (`SendResult.SentByEmailForm`) gets neither the reply promise nor the card (`feedbackSentBody`). +- **Feedback, redesigned 2026-11-02 (support conversations, phase 2).** "Share your thoughts" opens straight on the form: bold circular type pills (Feature idea, Bug report, Audio / Stream, General; `FeedbackTypePills`), a roomy message card that takes the free height, photos (only while the server's `meta.photos` switch is on; picked with the system photo picker, no storage permission, downscaled to 1600 px JPEG by `PhotoPrep`), an Attach diagnostics row, **Send feedback** at the bottom and a **How we handle feedback** link to the privacy page. Windows shorter than 600dp scroll with a 220dp message card. While typing (message focused and the keyboard up) the pills fold into one pill in the header and a slim bar with photos, diagnostics and **Send** rides on the keyboard, which replaces the bottom chrome padding (`isTypingLayout`, `feedbackBottomPadding`). The top-bar menu has Preview diagnostics, Rate on Play, Report on GitHub and Discard draft. The email and steps fields are gone; an older draft's steps join its message. Send is disabled while the server pauses new messages (`canStartNew`), with the server's notice or `PAUSED_NOTICE`; a refusal (today's limit) shows the server's words and keeps the draft. Each send carries a retry key (`RetryKeys`): sending the same message again reuses it, an edited message gets a new one, so a send whose answer was lost isn't saved twice. After sending, `FeedbackSentScreen` shows Echo's `feedback-sent` sequence (magnifier, then rest), "Feedback sent" and, when the message became a conversation and notifications are off, a **Know when the team replies** card (`canAskForNotifications`: Android's prompt while it will still show, otherwise the app's notification settings; re-checked on resume, **Not now** hides it). A message that fell back to the old one-way form (`SendResult.SentByEmailForm`) gets neither the reply promise nor the card (`feedbackSentBody`). - `AutoDownloadSettingsScreen` and `SmartDownloadsSettingsScreen` under `downloads/` for download management. - `AccountSettingsPage`, `SyncAndBackupsPage`, `AppearanceSettingsPage`, `PlaybackSettingsPage`, `PrivacySettingsPage`, `LibrarySettingsPage`, `DownloadsSettingsPage`, `AboutSettingsPage`, `SupportDevelopmentPage`. - Sync & Backups export copy names only what the JSON backup writes: subscriptions, folders, history, likes and settings (`FULL_LIBRARY_EXPORT_SUMMARY`, covered by `SyncAndBackupsPageTest`). Settings copy spells the product name boxlore in lowercase, including sentence starts. @@ -178,7 +178,7 @@ src/main/java/cx/aswin/boxlore/feature/settings/ - `SupportPageLayoutTest` covers the arrangement thresholds (full stacked page, side by side, mission card cut-off, larger text) and renders the control and every world in 392×800 and 360×600 portrait and 800×392 landscape windows with no scrollable node, the pay button and tier info fully on screen, and the mission card shown only when it fits. - `SupportEntryImpressionTest` covers one impression per view, none for a configuration restore, a new one after returning, and the real Settings hub's top-bar surface. - Not covered by JVM tests: rendering quality on real devices and fold postures. Those need a phone, tablet or foldable check. -- Feedback: `FeedbackViewModelTest` covers draft restore (old steps joined), the server's message limit, a conversation send (body trimmed, diagnostics, analytics without the text), the old-form fallback without the reply promise, refusal and failure keeping the draft with a retry, blank messages, photos ignored while switched off, de-duplicated picks up to the limit uploaded in order before the message, a failed or oversized photo sending nothing, paused sending, and Discard draft. `FeedbackLayoutPolicyTest` covers the typing-layout and keyboard-clearance rules, when Android's notification prompt can still show, and the sent-screen copy. `FeedbackAdaptiveTransitionTest` (Robolectric) keeps one live view model and screen while the window crosses the 600dp width, 480dp height and 840dp split gates in both directions, portrait/landscape tablets, a short 800×360 window that scrolls, and a fold going flat → tabletop → flat, then recreates it (`StateRestorationTester`): the typed message, the picked type and a reachable, enabled Send survive, Send goes out once, and the sent screen keeps Done and the notifications card (or its dismissal) through the same walk; an old-form send never shows the card. Not covered by JVM tests: the typing layout with a real keyboard (Robolectric has no IME insets), the photo picker, and Echo's motion; those need a phone and tablet check. Manual acceptance: on a phone, a tablet and a foldable, write a message, rotate and fold with the keyboard up and down, then send; the draft stays, Send stays reachable above the keyboard, and the sent screen offers notifications only while they are off. +- Feedback: `FeedbackViewModelTest` covers draft restore (old steps joined), the server's message limit, a conversation send (body trimmed, diagnostics, analytics without the text), the old-form fallback without the reply promise, refusal and failure keeping the draft with a retry, blank messages, photos ignored while switched off, de-duplicated picks up to the limit uploaded in order before the message, a failed or oversized photo sending nothing, paused sending, retry keys (kept for a resend, new after an edit or a success), and Discard draft. `FeedbackLayoutPolicyTest` covers the typing-layout and keyboard-clearance rules, when Android's notification prompt can still show, and the sent-screen copy. `FeedbackAdaptiveTransitionTest` (Robolectric) keeps one live view model and screen while the window crosses the 600dp width, 480dp height and 840dp split gates in both directions, portrait/landscape tablets, a short 800×360 window that scrolls, and a fold going flat → tabletop → flat, then recreates it (`StateRestorationTester`): the typed message, the picked type and a reachable, enabled Send survive, Send goes out once, and the sent screen keeps Done and the notifications card (or its dismissal) through the same walk; an old-form send never shows the card. Not covered by JVM tests: the typing layout with a real keyboard (Robolectric has no IME insets), the photo picker, and Echo's motion; those need a phone and tablet check. Manual acceptance: on a phone, a tablet and a foldable, write a message, rotate and fold with the keyboard up and down, then send; the draft stays, Send stays reachable above the keyboard, and the sent screen offers notifications only while they are off. - Unit tests live under `feature/settings/src/test`. - `AccountPagePresentationTest` covers the onboarding and Settings layouts, picking each sign-in method and getting back to the cards, the check-email and confirm-email screens (resend cooldown), every sync state with its action, the Sync now label at 200% text in RTL, password-only Reset password, Echo press feedback, and that every Echo pose the page uses ships in the Echo manifest with a stage reach. `AccountPageAdaptiveTest` keeps one live page while the window moves phone → 599/600dp → tablet split → 479/480dp tall → portrait tablet → phone and checks that the chosen method, the typed email and the password mode survive, in both directions, and across activity recreation (`StateRestorationTester`). It also keeps a live syncing onboarding page across the same windows: the No need to wait note stays on screen above the tiles with Continue reachable, and finishing sync mid-resize turns all three tiles done and removes the note. Tests hold Echo on its resting pose with `LocalEchoMotion provides false` so the screen can go idle. App navigation tests cover hiding restored playback during Account and onboarding and restoring normal player presentation afterward. - `AutoDownloadSettingsActionsTest` exercises the same action callbacks and background presentation used by the screen: all four episode limits, invalid-limit rejection, help opening/dismissal, both background-control visibility states, and retaining all network/charging combinations when checks are disabled and re-enabled. No device or screenshot automation is required. 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 2271f931b..3531622f8 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 @@ -15,6 +15,7 @@ import cx.aswin.boxlore.core.support.SendResult import cx.aswin.boxlore.core.support.SupportMeta import cx.aswin.boxlore.core.support.SupportRepository import java.net.URLEncoder +import java.util.UUID import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -66,6 +67,23 @@ data class FeedbackUiState( internal const val PAUSED_NOTICE = "Sending is paused for now. Your message stays saved here." +/** + * One retry key per message: sending the same content again (after a failure whose answer may + * have been lost) reuses the key, so the API saves it once; changed content gets a new key. + */ +internal class RetryKeys(private val newKey: () -> String = { UUID.randomUUID().toString() }) { + private var last: Pair? = null + + fun keyFor(content: Any): String { + last?.let { (seen, key) -> if (seen == content) return key } + return newKey().also { last = content to it } + } + + fun clear() { + last = null + } +} + /** What the screen needs from support; the factory wires [SupportRepository], tests pass fakes. */ class FeedbackSupport( val meta: StateFlow, @@ -82,6 +100,7 @@ class FeedbackViewModel( private val readPhoto: (Uri) -> ByteArray? = { uri -> PhotoPrep.prepare(context.contentResolver, uri) }, ) : ViewModel() { + private val retryKeys = RetryKeys() private val _uiState = MutableStateFlow(FeedbackUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -194,6 +213,7 @@ class FeedbackViewModel( return } _uiState.update { it.copy(isSubmitting = true, errorMessage = null) } + val requestId = retryKeys.keyFor(listOf(state.category, body, state.photos, state.attachDiagnostics)) viewModelScope.launch { val diagnosticsInfo = state.diagnosticInfo ?: DiagnosticCollector.collect(context) @@ -212,6 +232,7 @@ class FeedbackViewModel( diagnostics = diagnosticsReport, logs = sanitizedLogs, photoIds = photoIds, + requestId = requestId, ), ) handleResult(state, result) @@ -248,6 +269,7 @@ class FeedbackViewModel( withDiagnostics = state.attachDiagnostics, ) boxcastPrefs.clearFeedbackDraft() + retryKeys.clear() _uiState.update { it.copy(isSubmitting = false, isSuccess = true, repliesInApp = repliesInApp, isDraftRestored = false, photos = emptyList()) } 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 da3c19c63..b113adbcb 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 @@ -202,6 +202,42 @@ class FeedbackViewModelTest { assertEquals(2, support.sent.size) } + @Test + fun a_resend_of_the_same_message_keeps_its_retry_key_and_an_edit_gets_a_new_one() = runTest(testDispatcher) { + val support = FakeSupport().apply { result = SendResult.Failed } + val vm = viewModel(support) + vm.onMessageChanged("Try me") + + vm.onSubmit() + advanceUntilIdle() + vm.onSubmit() + advanceUntilIdle() + vm.onMessageChanged("Try me, edited") + vm.onSubmit() + advanceUntilIdle() + support.result = SendResult.Sent("t1") + vm.onSubmit() + advanceUntilIdle() + + val keys = support.sent.map { it.requestId } + assertEquals(4, keys.size) + assertTrue(keys.all { !it.isNullOrBlank() }) + assertEquals(keys[0], keys[1]) + assertTrue(keys[1] != keys[2]) + assertEquals(keys[2], keys[3]) + } + + @Test + fun retry_keys_change_with_content_and_start_over_once_cleared() { + var n = 0 + val keys = RetryKeys { "key-${++n}" } + assertEquals("key-1", keys.keyFor(listOf("bug", "a"))) + assertEquals("key-1", keys.keyFor(listOf("bug", "a"))) + assertEquals("key-2", keys.keyFor(listOf("bug", "b"))) + keys.clear() + assertEquals("key-3", keys.keyFor(listOf("bug", "b"))) + } + @Test fun a_blank_message_is_never_sent() = runTest(testDispatcher) { val support = FakeSupport() From 371498a99756bf8355dd97c5b340b4c7248d9de2 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Mon, 2 Nov 2026 02:27:34 +0530 Subject: [PATCH 3/8] fix(feedback): keep the message field through keyboard and window changes The compose screen is one layout, so the keyboard or a height change no longer replaces the message field and loses its focus. The draft is locked while sending, an older draft past the limit is kept whole until shortened, the server's message limit and categories apply as sent, the phone model goes only with diagnostics, and photos are left out (and said so) when support can't take them. The old form is used only when it can deliver, a stale pause is cleared once support is gone, support calls are cancellable and never cached, and the photo row scrolls on narrow phones. --- .../aswin/boxlore/core/network/SupportApi.kt | 24 +- .../core/network/model/SupportModels.kt | 5 + core/support/README.md | 22 +- .../boxlore/core/support/SupportModels.kt | 21 +- .../boxlore/core/support/SupportRepository.kt | 67 ++++-- .../core/support/SupportRepositoryTest.kt | 63 ++++- feature/settings/README.md | 4 +- .../settings/feedback/FeedbackComposeParts.kt | 144 ++++++----- .../settings/feedback/FeedbackScreen.kt | 227 +++++++++--------- .../settings/feedback/FeedbackSentScreen.kt | 19 +- .../settings/feedback/FeedbackViewModel.kt | 142 ++++++++--- .../FeedbackAdaptiveTransitionTest.kt | 63 ++++- .../feedback/FeedbackLayoutPolicyTest.kt | 10 +- .../feedback/FeedbackViewModelTest.kt | 156 +++++++++++- 14 files changed, 688 insertions(+), 279 deletions(-) diff --git a/core/network/src/main/java/cx/aswin/boxlore/core/network/SupportApi.kt b/core/network/src/main/java/cx/aswin/boxlore/core/network/SupportApi.kt index 7f1fd3784..c2c784a06 100644 --- a/core/network/src/main/java/cx/aswin/boxlore/core/network/SupportApi.kt +++ b/core/network/src/main/java/cx/aswin/boxlore/core/network/SupportApi.kt @@ -5,26 +5,32 @@ import cx.aswin.boxlore.core.network.model.SupportNewThreadRequest import cx.aswin.boxlore.core.network.model.SupportPhotoResponse import cx.aswin.boxlore.core.network.model.SupportThreadResponse import okhttp3.RequestBody -import retrofit2.Call +import retrofit2.Response import retrofit2.http.Body import retrofit2.http.GET +import retrofit2.http.Headers import retrofit2.http.POST +private const val NO_STORE = "Cache-Control: no-store" + /** - * Support conversations (`/support/...`). Requests carry the API session from the shared client, - * which is how the API knows which install a conversation belongs to. Every label, status and - * limit comes from the responses, so they change without an app update. - * Build it with [NetworkModule.createSupportApi] on the same cached client as [BoxLoreApi]. + * Support conversations (`/support/...`). Requests carry the API session from the shared client + * and a device-key proof, which is how the API knows which install a conversation belongs to. + * Every label, status and limit comes from the responses, so they change without an app update. + * Nothing is kept in the HTTP cache. Build it with [NetworkModule.createSupportApi]. */ interface SupportApi { - /** `meta` (switches, limits, push topics) and this install's conversations. */ + /** `meta` (switches, limits, categories, push topics) and this install's conversations. */ + @Headers(NO_STORE) @GET("support/threads") - fun threads(): Call + suspend fun threads(): Response + @Headers(NO_STORE) @POST("support/threads") - fun startThread(@Body request: SupportNewThreadRequest): Call + suspend fun startThread(@Body request: SupportNewThreadRequest): Response /** Raw JPEG, PNG or WebP bytes; answers 403 while photos are switched off. */ + @Headers(NO_STORE) @POST("support/photos") - fun uploadPhoto(@Body photo: RequestBody): Call + suspend fun uploadPhoto(@Body photo: RequestBody): Response } diff --git a/core/network/src/main/java/cx/aswin/boxlore/core/network/model/SupportModels.kt b/core/network/src/main/java/cx/aswin/boxlore/core/network/model/SupportModels.kt index 0e71ffe59..7919e76fd 100644 --- a/core/network/src/main/java/cx/aswin/boxlore/core/network/model/SupportModels.kt +++ b/core/network/src/main/java/cx/aswin/boxlore/core/network/model/SupportModels.kt @@ -18,8 +18,13 @@ data class SupportMetaDto( /** Every topic to subscribe to for reply pushes: this install's, and the account's when signed in. */ val pushTopics: List = emptyList(), val replyAuthor: String? = null, + /** What a message can be about, in the order to show them. */ + val categories: List = emptyList(), ) +@Serializable +data class SupportCategoryDto(val id: String = "", val label: String = "") + @Serializable data class SupportPhotoLimitsDto( val enabled: Boolean = false, diff --git a/core/support/README.md b/core/support/README.md index 6bccf8f68..c8e575489 100644 --- a/core/support/README.md +++ b/core/support/README.md @@ -47,16 +47,26 @@ Features use it; it never depends on a feature, the design system or the catalog message goes through the old form. - `OutgoingFeedback.requestId` is the retry key: the screen sends the same key when the same message is sent again, so a send whose answer was lost isn't saved twice. -- Fallback: when the support API answers 404, 401 or 503 `support_unavailable`, the message goes - through the old `POST /feedback` form (`legacySubmit`), so feedback sent before the server side is - live (or without a session) is never lost. The old form has no `audio` category (sent as `bug`) - and no photos. +- Fallback: when support isn't there but the old `POST /feedback` form can still deliver (404, 503 + `support_unavailable`, or 401 `device_key_required` / `proof_required`), the message goes through it + (`legacySubmit`). The old form needs the API session too, so a 401 for a missing or refused session + is a failure: the screen keeps the draft to send later. The old form knows only feature, bug and + other (audio goes as bug, anything newer as other) and has no photos. A refresh that finds support + gone resets `meta` to `SupportMeta.Offline`, so a pause the server set earlier can't keep Send off. +- `uploadPhoto` answers `PhotoUpload.Uploaded`, `Unavailable` (photos off, or support not there: the + message can still go without them) or `Failed`. +- `meta.categories` are the server's choices (ids `[a-z0-9_]`, labels trimmed, at most 12); empty means + the app's own list. Support calls are Retrofit `suspend` functions, so leaving the screen cancels + them, and they ask the HTTP cache not to store anything (`Cache-Control: no-store`). ## Testing notes - `SupportRepositoryTest` runs the real `SupportApi` against MockWebServer: meta parsing (push topics - filtered to valid names), sending with the retry key, the fallback cases (401 for a missing proof - included), refusals with the server's message, no connection, photos off, `PhotoPrep.targetSize`. + and categories filtered to valid ones, a limit above 2,000 kept), sending with the retry key, the + fallback cases (401 for a missing proof included, a new category sent as other), no fallback without + a session, a pause forgotten once support is gone (but kept over a plain server error), refusals with + the server's message, no connection, photo upload results and the no-store header, photos off, + `PhotoPrep.targetSize`. `RequestProofTest` in `:core:network` covers the signed message and the interceptor. ```bash diff --git a/core/support/src/main/java/cx/aswin/boxlore/core/support/SupportModels.kt b/core/support/src/main/java/cx/aswin/boxlore/core/support/SupportModels.kt index 714a0e4f4..54bbcc77a 100644 --- a/core/support/src/main/java/cx/aswin/boxlore/core/support/SupportModels.kt +++ b/core/support/src/main/java/cx/aswin/boxlore/core/support/SupportModels.kt @@ -10,13 +10,30 @@ data class SupportMeta( val maxBody: Int, /** Topics to subscribe to for reply pushes (this install's, and the account's when signed in). */ val pushTopics: List, + /** What a message can be about, from the server; empty means the app's own list. */ + val categories: List = emptyList(), ) { companion object { - /** Before the first answer, or when the API is unreachable: text only, nothing switched on. */ + /** + * Before the first answer, when the API is unreachable, or when support isn't available + * (messages then go through the old form): text only, nothing switched on. + */ val Offline = SupportMeta(canStartNew = true, notice = null, photosEnabled = false, maxPhotos = 0, maxPhotoBytes = 0, maxBody = 2000, pushTopics = emptyList()) } } +/** One choice of what a message is about; [label] is shown as is. */ +data class SupportCategory(val id: String, val label: String) + +sealed interface PhotoUpload { + data class Uploaded(val id: String) : PhotoUpload + + /** Support isn't available (or photos are off): the message can still go, without photos. */ + data object Unavailable : PhotoUpload + + data object Failed : PhotoUpload +} + /** One message to start a conversation. [photoIds] come from [SupportRepository.uploadPhoto]. */ data class OutgoingFeedback( val category: String, @@ -34,7 +51,7 @@ sealed interface SendResult { /** Saved as a conversation; replies will show up in the app. */ data class Sent(val threadId: String) : SendResult - /** The support API isn't available (not live yet, or no session): sent through the old form instead. */ + /** The support API isn't available (not live yet, or this phone can't prove its device key): sent through the old form. */ data object SentByEmailForm : SendResult /** New conversations are paused, or this phone hit today's limit; [message] comes from the server. */ diff --git a/core/support/src/main/java/cx/aswin/boxlore/core/support/SupportRepository.kt b/core/support/src/main/java/cx/aswin/boxlore/core/support/SupportRepository.kt index ecf213d88..84dd5f3b5 100644 --- a/core/support/src/main/java/cx/aswin/boxlore/core/support/SupportRepository.kt +++ b/core/support/src/main/java/cx/aswin/boxlore/core/support/SupportRepository.kt @@ -18,9 +18,10 @@ import okhttp3.RequestBody.Companion.toRequestBody import retrofit2.Response /** - * Talks to the support API (`/support/...`). Sending never loses a message: when the support API - * isn't available yet (an older server, or no API session) it falls back to the old feedback form - * through [legacySubmit]. + * Talks to the support API (`/support/...`). When the support API isn't available (an older + * server, support switched off, or a session without a device key or proof) a message goes + * through the old feedback form ([legacySubmit]) instead. Without any API session neither can + * deliver, so that is a failure and the screen keeps the draft. */ class SupportRepository( private val api: SupportApi, @@ -34,19 +35,28 @@ class SupportRepository( /** Reads the switches (and the conversation list, used from the next app update on). */ suspend fun refresh(): SupportMeta? = withContext(ioDispatcher) { - val response = call { api.threads().execute() } ?: return@withContext null - if (!response.isSuccessful) return@withContext null + val response = call { api.threads() } ?: return@withContext null + if (!response.isSuccessful) { + // Support went away (or this phone can't use it): forget switches like a pause, so Send works and falls back. + if (usesOldForm(response)) _meta.value = SupportMeta.Offline + return@withContext null + } val meta = response.body()?.toMeta() ?: return@withContext null _meta.value = meta meta } - /** Uploads one prepared photo; null when photos are off or the upload fails. */ - suspend fun uploadPhoto(bytes: ByteArray, mimeType: String): String? = withContext(ioDispatcher) { - if (!_meta.value.photosEnabled) return@withContext null + /** Uploads one prepared photo. [PhotoUpload.Unavailable] when photos are off or support isn't there. */ + suspend fun uploadPhoto(bytes: ByteArray, mimeType: String): PhotoUpload = withContext(ioDispatcher) { + if (!_meta.value.photosEnabled) return@withContext PhotoUpload.Unavailable val body = bytes.toRequestBody(mimeType.toMediaType()) - val response = call { api.uploadPhoto(body).execute() } ?: return@withContext null - response.body()?.id?.takeIf { response.isSuccessful } + val response = call { api.uploadPhoto(body) } ?: return@withContext PhotoUpload.Failed + val id = response.body()?.id + when { + response.isSuccessful && id != null -> PhotoUpload.Uploaded(id) + usesOldForm(response) || response.code() == HTTP_FORBIDDEN -> PhotoUpload.Unavailable + else -> PhotoUpload.Failed + } } suspend fun send(feedback: OutgoingFeedback): SendResult = withContext(ioDispatcher) { @@ -60,7 +70,7 @@ class SupportRepository( logs = feedback.logs, requestId = feedback.requestId, ) - val response = call { api.startThread(request).execute() } ?: return@withContext SendResult.Failed + val response = call { api.startThread(request) } ?: return@withContext SendResult.Failed when { response.isSuccessful -> response.body()?.thread?.id?.let { SendResult.Sent(it) } ?: SendResult.Failed usesOldForm(response) -> sendThroughOldForm(feedback) @@ -72,8 +82,12 @@ class SupportRepository( } private suspend fun sendThroughOldForm(feedback: OutgoingFeedback): SendResult { - // The old form has no audio category and no photo field. - val category = if (feedback.category == "audio") "bug" else feedback.category + // The old form knows only these categories and has no photo field. + val category = when (feedback.category) { + "feature", "bug", "other" -> feedback.category + "audio" -> "bug" + else -> "other" + } val ok = legacySubmit(category, feedback.body, feedback.appVersion ?: "unknown", feedback.diagnostics, feedback.logs) return if (ok) SendResult.SentByEmailForm else SendResult.Failed } @@ -90,6 +104,7 @@ class SupportRepository( companion object { private const val HTTP_UNAUTHORIZED = 401 + private const val HTTP_FORBIDDEN = 403 private const val HTTP_NOT_FOUND = 404 private const val HTTP_CONFLICT = 409 private const val HTTP_TOO_MANY = 429 @@ -97,11 +112,19 @@ class SupportRepository( private const val DEFAULT_REFUSAL = "This can't be sent right now. Please try again later." private val errorJson = Json { ignoreUnknownKeys = true } - /** Not live yet (404), no API session (401) or switched off (503 `support_unavailable`). */ - internal fun usesOldForm(response: Response<*>): Boolean { - val code = response.code() - if (code == HTTP_NOT_FOUND || code == HTTP_UNAUTHORIZED) return true - return code == HTTP_UNAVAILABLE && response.peekError()?.error == "support_unavailable" + /** Errors meaning a session without a device key or proof: the old form, which needs only the session, still works. */ + private val PROOF_ERRORS = setOf("device_key_required", "proof_required") + + /** + * Support isn't there but the old form can still deliver: not live yet (404), switched off + * (503 `support_unavailable`), or a session without a device key or proof (401). A 401 for a + * missing or refused session isn't one: the old form needs that session too. + */ + internal fun usesOldForm(response: Response<*>): Boolean = when (response.code()) { + HTTP_NOT_FOUND -> true + HTTP_UNAUTHORIZED -> response.peekError()?.error in PROOF_ERRORS + HTTP_UNAVAILABLE -> response.peekError()?.error == "support_unavailable" + else -> false } private fun Response<*>.peekError(): SupportErrorResponse? = errorBody()?.let { body -> @@ -124,10 +147,18 @@ internal fun SupportListResponse.toMeta(): SupportMeta? { maxPhotoBytes = m.photos.maxBytes, maxBody = m.maxBody.coerceIn(1, MAX_BODY_CEILING), pushTopics = m.pushTopics.filter { TOPIC.matches(it) }.distinct().take(MAX_TOPICS), + categories = m.categories + .filter { CATEGORY_ID.matches(it.id) && it.label.isNotBlank() } + .distinctBy { it.id } + .take(MAX_CATEGORIES) + .map { SupportCategory(it.id, it.label.trim().take(MAX_CATEGORY_LABEL)) }, ) } private const val MAX_TOPICS = 5 +private const val MAX_CATEGORIES = 12 +private const val MAX_CATEGORY_LABEL = 40 +private val CATEGORY_ID = Regex("[a-z0-9_]{1,32}") /** FCM topic names. */ private val TOPIC = Regex("[A-Za-z0-9\\-_.~%]{1,900}") diff --git a/core/support/src/test/java/cx/aswin/boxlore/core/support/SupportRepositoryTest.kt b/core/support/src/test/java/cx/aswin/boxlore/core/support/SupportRepositoryTest.kt index 35ef0b865..07f36f183 100644 --- a/core/support/src/test/java/cx/aswin/boxlore/core/support/SupportRepositoryTest.kt +++ b/core/support/src/test/java/cx/aswin/boxlore/core/support/SupportRepositoryTest.kt @@ -54,12 +54,18 @@ class SupportRepositoryTest { fun `reads the switches and limits from the server`() = runTest { server.enqueue( MockResponse().setBody( - """{"meta":{"notice":"Slow this week","canStartNew":true,"photos":{"enabled":true,"max":5,"maxBytes":5242880},"maxBody":2000,"pushTopics":["support_ab","support_cd","bad topic"],"replyAuthor":"boxlore team","extra":1},"threads":[]}""", + """{"meta":{"notice":"Slow this week","canStartNew":true,"photos":{"enabled":true,"max":5,"maxBytes":5242880},"maxBody":4000,"pushTopics":["support_ab","support_cd","bad topic"],"replyAuthor":"boxlore team","categories":[{"id":"bug","label":"Bug report"},{"id":"account","label":"Account and sync"},{"id":"Bad Id","label":"x"},{"id":"empty","label":" "}],"extra":1},"threads":[]}""", ), ) val repo = repository() val meta = repo.refresh() - assertEquals(SupportMeta(true, "Slow this week", true, 5, 5_242_880, 2000, listOf("support_ab", "support_cd")), meta) + assertEquals( + SupportMeta( + true, "Slow this week", true, 5, 5_242_880, 4000, listOf("support_ab", "support_cd"), + listOf(SupportCategory("bug", "Bug report"), SupportCategory("account", "Account and sync")), + ), + meta, + ) assertEquals(meta, repo.meta.value) assertEquals("/support/threads", server.takeRequest().path) } @@ -77,10 +83,10 @@ class SupportRepositoryTest { } @Test - fun `falls back to the old form when the support API is not there or has no session`() = runTest { + fun `falls back to the old form when support is not there or the phone can't prove its key`() = runTest { for (reply in listOf( MockResponse().setResponseCode(404).setBody("""{"error":"Not Found"}"""), - MockResponse().setResponseCode(401).setBody("""{"error":"session_required"}"""), + MockResponse().setResponseCode(401).setBody("""{"error":"device_key_required"}"""), MockResponse().setResponseCode(401).setBody("""{"error":"proof_required"}"""), MockResponse().setResponseCode(503).setBody("""{"error":"support_unavailable"}"""), )) { @@ -89,6 +95,36 @@ class SupportRepositoryTest { } assertEquals(4, legacyCalls.size) assertEquals(listOf("bug", "Static after ten minutes", "0.0.30", "Android 17", "log line"), legacyCalls.first()) + // A category the old form doesn't know goes as "other". + server.enqueue(MockResponse().setResponseCode(404)) + repository().send(feedback.copy(category = "account")) + assertEquals("other", legacyCalls.last().first()) + } + + @Test + fun `without any session the old form can't deliver either, so it is a failure`() = runTest { + server.enqueue(MockResponse().setResponseCode(401).setBody("""{"error":"session_required"}""")) + server.enqueue(MockResponse().setResponseCode(401).setBody("""{"error":"session_invalid"}""")) + val repo = repository() + assertEquals(SendResult.Failed, repo.send(feedback)) + assertEquals(SendResult.Failed, repo.send(feedback)) + assertTrue(legacyCalls.isEmpty()) + } + + @Test + fun `a pause is forgotten once support goes away, so Send reaches the old form`() = runTest { + server.enqueue(MockResponse().setBody("""{"meta":{"canStartNew":false,"notice":"Paused"},"threads":[]}""")) + server.enqueue(MockResponse().setResponseCode(500)) + server.enqueue(MockResponse().setResponseCode(404)) + val repo = repository() + repo.refresh() + assertFalse(repo.meta.value.canStartNew) + // A plain server error keeps what was last known… + assertNull(repo.refresh()) + assertFalse(repo.meta.value.canStartNew) + // …but support being gone clears it. + assertNull(repo.refresh()) + assertEquals(SupportMeta.Offline, repo.meta.value) } @Test @@ -121,10 +157,27 @@ class SupportRepositoryTest { @Test fun `photos are not uploaded while switched off`() = runTest { - assertNull(repository().uploadPhoto(byteArrayOf(1, 2, 3), "image/jpeg")) + assertEquals(PhotoUpload.Unavailable, repository().uploadPhoto(byteArrayOf(1, 2, 3), "image/jpeg")) assertEquals(0, server.requestCount) } + @Test + fun `photo uploads tell a missing support API from a failed upload`() = runTest { + server.enqueue(MockResponse().setBody("""{"meta":{"photos":{"enabled":true,"max":5,"maxBytes":100}},"threads":[]}""")) + server.enqueue(MockResponse().setResponseCode(201).setBody("""{"id":"0123456789abcdef01234567"}""")) + server.enqueue(MockResponse().setResponseCode(503).setBody("""{"error":"support_unavailable"}""")) + server.enqueue(MockResponse().setResponseCode(403).setBody("""{"error":"photos_off"}""")) + server.enqueue(MockResponse().setResponseCode(429).setBody("""{"error":"limit"}""")) + val repo = repository() + repo.refresh() + assertEquals(PhotoUpload.Uploaded("0123456789abcdef01234567"), repo.uploadPhoto(byteArrayOf(1), "image/jpeg")) + assertEquals(PhotoUpload.Unavailable, repo.uploadPhoto(byteArrayOf(1), "image/jpeg")) + assertEquals(PhotoUpload.Unavailable, repo.uploadPhoto(byteArrayOf(1), "image/jpeg")) + assertEquals(PhotoUpload.Failed, repo.uploadPhoto(byteArrayOf(1), "image/jpeg")) + server.takeRequest() + assertEquals("no-store", server.takeRequest().getHeader("Cache-Control")) + } + @Test fun `photo target size keeps the shape and never upscales`() { assertEquals(1600 to 800, PhotoPrep.targetSize(3200, 1600)) diff --git a/feature/settings/README.md b/feature/settings/README.md index 7578ed27b..376506eb9 100644 --- a/feature/settings/README.md +++ b/feature/settings/README.md @@ -21,7 +21,7 @@ Owns the unified Settings hub, category pages (Account, Sync & Backups, Appearan - Shared `SettingsScaffold` headers use a smaller 36sp expanded title and naturally wrap to two lines when needed, including Cloud Sync & Backups. The expanded title smoothly reduces to 22sp on scroll; the separate compact row always uses 22sp and retains two-line support for narrow screens and larger system fonts. Material's two-row app bar measures the title height, so a hidden expanded title cannot inflate the navigation row or force a single-line ellipsis. - `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. `FeedbackScreen(supportRepository, boxcastPrefs, onBack, bottomContentPadding)` takes `SupportRepository` from `:core:support` (app wiring passes the mini player clearance). -- **Feedback, redesigned 2026-11-02 (support conversations, phase 2).** "Share your thoughts" opens straight on the form: bold circular type pills (Feature idea, Bug report, Audio / Stream, General; `FeedbackTypePills`), a roomy message card that takes the free height, photos (only while the server's `meta.photos` switch is on; picked with the system photo picker, no storage permission, downscaled to 1600 px JPEG by `PhotoPrep`), an Attach diagnostics row, **Send feedback** at the bottom and a **How we handle feedback** link to the privacy page. Windows shorter than 600dp scroll with a 220dp message card. While typing (message focused and the keyboard up) the pills fold into one pill in the header and a slim bar with photos, diagnostics and **Send** rides on the keyboard, which replaces the bottom chrome padding (`isTypingLayout`, `feedbackBottomPadding`). The top-bar menu has Preview diagnostics, Rate on Play, Report on GitHub and Discard draft. The email and steps fields are gone; an older draft's steps join its message. Send is disabled while the server pauses new messages (`canStartNew`), with the server's notice or `PAUSED_NOTICE`; a refusal (today's limit) shows the server's words and keeps the draft. Each send carries a retry key (`RetryKeys`): sending the same message again reuses it, an edited message gets a new one, so a send whose answer was lost isn't saved twice. After sending, `FeedbackSentScreen` shows Echo's `feedback-sent` sequence (magnifier, then rest), "Feedback sent" and, when the message became a conversation and notifications are off, a **Know when the team replies** card (`canAskForNotifications`: Android's prompt while it will still show, otherwise the app's notification settings; re-checked on resume, **Not now** hides it). A message that fell back to the old one-way form (`SendResult.SentByEmailForm`) gets neither the reply promise nor the card (`feedbackSentBody`). +- **Feedback, redesigned 2026-11-02 (support conversations, phase 2).** "Share your thoughts" opens straight on the form: bold circular type pills (`FeedbackTypePills`), a roomy message card that takes the free height, photos (only while the server's `meta.photos` switch is on; picked with the system photo picker, no storage permission, downscaled to 1600 px JPEG by `PhotoPrep`; the row scrolls sideways so every photo and its Remove stay reachable on narrow phones), an Include device diagnostics row, **Send feedback** at the bottom and a **How we handle feedback** link to the privacy page. The pills are the server's `meta.categories` (id and label, shown as sent); the app's own four (Feature idea, Bug report, Audio / Stream, General) are the offline list and give icons and hints for the ids they share (`FeedbackChoice`), and an id the server stops offering falls back to its first. The message limit is the server's `maxBody` as is; typing stops there, and an older draft past it (its steps now join the message) is kept whole and can only get shorter, with the counter in red and "This is over the …-character limit. Shorten it to send." until it fits (`limitedMessage`). The phone model goes only with diagnostics on. While a message is sending its draft is locked (field read-only, pills, switch and photos disabled), so what was sent is what gets cleared. Windows shorter than 600dp scroll with a 220dp message card. While typing (message focused and the keyboard up) the pills fold into one pill in the header and a slim bar with photos, diagnostics and **Send** rides on the keyboard, which replaces the bottom chrome padding (`isTypingLayout`, `feedbackBottomPadding`). It is one layout throughout: the keyboard or a height change only changes what sits around the message field, so the field keeps its focus, cursor and the text being composed. The top-bar menu has Preview diagnostics, Rate on Play, Report on GitHub and Discard draft. The email and steps fields are gone. Send is disabled while the server pauses new messages (`canStartNew`), with the server's notice or `PAUSED_NOTICE`; a refusal (today's limit) shows the server's words and keeps the draft. Each send carries a retry key (`RetryKeys`): sending the same message again reuses it, an edited message gets a new one, so a send whose answer was lost isn't saved twice. When support can't take photos (it is unavailable), the message goes without them. After sending, `FeedbackSentScreen` shows Echo's `feedback-sent` sequence (magnifier, then rest), "Feedback sent" and, when the message became a conversation and notifications are off, a **Know when the team replies** card (`canAskForNotifications`: Android's prompt while it will still show, otherwise the app's notification settings; re-checked on resume, **Not now** hides it). A message that fell back to the old one-way form (`SendResult.SentByEmailForm`) gets neither the reply promise nor the card, and left-out photos are mentioned (`feedbackSentBody`). - `AutoDownloadSettingsScreen` and `SmartDownloadsSettingsScreen` under `downloads/` for download management. - `AccountSettingsPage`, `SyncAndBackupsPage`, `AppearanceSettingsPage`, `PlaybackSettingsPage`, `PrivacySettingsPage`, `LibrarySettingsPage`, `DownloadsSettingsPage`, `AboutSettingsPage`, `SupportDevelopmentPage`. - Sync & Backups export copy names only what the JSON backup writes: subscriptions, folders, history, likes and settings (`FULL_LIBRARY_EXPORT_SUMMARY`, covered by `SyncAndBackupsPageTest`). Settings copy spells the product name boxlore in lowercase, including sentence starts. @@ -178,7 +178,7 @@ src/main/java/cx/aswin/boxlore/feature/settings/ - `SupportPageLayoutTest` covers the arrangement thresholds (full stacked page, side by side, mission card cut-off, larger text) and renders the control and every world in 392×800 and 360×600 portrait and 800×392 landscape windows with no scrollable node, the pay button and tier info fully on screen, and the mission card shown only when it fits. - `SupportEntryImpressionTest` covers one impression per view, none for a configuration restore, a new one after returning, and the real Settings hub's top-bar surface. - Not covered by JVM tests: rendering quality on real devices and fold postures. Those need a phone, tablet or foldable check. -- Feedback: `FeedbackViewModelTest` covers draft restore (old steps joined), the server's message limit, a conversation send (body trimmed, diagnostics, analytics without the text), the old-form fallback without the reply promise, refusal and failure keeping the draft with a retry, blank messages, photos ignored while switched off, de-duplicated picks up to the limit uploaded in order before the message, a failed or oversized photo sending nothing, paused sending, retry keys (kept for a resend, new after an edit or a success), and Discard draft. `FeedbackLayoutPolicyTest` covers the typing-layout and keyboard-clearance rules, when Android's notification prompt can still show, and the sent-screen copy. `FeedbackAdaptiveTransitionTest` (Robolectric) keeps one live view model and screen while the window crosses the 600dp width, 480dp height and 840dp split gates in both directions, portrait/landscape tablets, a short 800×360 window that scrolls, and a fold going flat → tabletop → flat, then recreates it (`StateRestorationTester`): the typed message, the picked type and a reachable, enabled Send survive, Send goes out once, and the sent screen keeps Done and the notifications card (or its dismissal) through the same walk; an old-form send never shows the card. Not covered by JVM tests: the typing layout with a real keyboard (Robolectric has no IME insets), the photo picker, and Echo's motion; those need a phone and tablet check. Manual acceptance: on a phone, a tablet and a foldable, write a message, rotate and fold with the keyboard up and down, then send; the draft stays, Send stays reachable above the keyboard, and the sent screen offers notifications only while they are off. +- Feedback: `FeedbackViewModelTest` covers draft restore (old steps joined), a draft past the limit kept whole until shortened, a server limit above 2,000, a conversation send (body trimmed, diagnostics, analytics without the text), the phone model only with diagnostics, the server's categories (an unknown id falls back, a new one is sent as is and counts as other in analytics), the old-form fallback without the reply promise, refusal and failure keeping the draft with a retry, blank messages, the draft locked while sending, photos ignored while switched off, de-duplicated picks up to the limit uploaded in order before the message, a failed or oversized photo sending nothing, photos left out when support can't take them, paused sending, retry keys (kept for a resend, new after an edit or a success), and Discard draft. `FeedbackLayoutPolicyTest` covers the typing-layout and keyboard-clearance rules, `limitedMessage`, when Android's notification prompt can still show, and the sent-screen copy. `FeedbackAdaptiveTransitionTest` (Robolectric) holds the view model outside composition, as the navigation entry does in the app, so it covers the screen's own state rather than the view model's lifetime. It keeps one live screen while the window crosses the 600dp width, 480dp height and 840dp split gates in both directions, portrait/landscape tablets, a short 800×360 window that scrolls, and a fold going flat → tabletop → flat, then recreates it (`StateRestorationTester`): the typed message, the picked type and a reachable, enabled Send survive, Send goes out once, and the sent screen keeps Done and the notifications card (or its dismissal) through the same walk; an old-form send never shows the card. With the keyboard played by passing its height in, the message field stays focused with its text as the keyboard opens and closes twice and the window goes short and tall, and typing continues where it was. On a 360dp phone all five photos and their Remove buttons are reachable. Not covered by JVM tests: a real keyboard's animation and composition, the photo picker, and Echo's motion; those need a phone and tablet check. Manual acceptance: on a phone, a tablet and a foldable, write a message, rotate and fold with the keyboard up and down, then send; the draft stays, the cursor stays in the field, Send stays reachable above the keyboard, and the sent screen offers notifications only while they are off. - Unit tests live under `feature/settings/src/test`. - `AccountPagePresentationTest` covers the onboarding and Settings layouts, picking each sign-in method and getting back to the cards, the check-email and confirm-email screens (resend cooldown), every sync state with its action, the Sync now label at 200% text in RTL, password-only Reset password, Echo press feedback, and that every Echo pose the page uses ships in the Echo manifest with a stage reach. `AccountPageAdaptiveTest` keeps one live page while the window moves phone → 599/600dp → tablet split → 479/480dp tall → portrait tablet → phone and checks that the chosen method, the typed email and the password mode survive, in both directions, and across activity recreation (`StateRestorationTester`). It also keeps a live syncing onboarding page across the same windows: the No need to wait note stays on screen above the tiles with Continue reachable, and finishing sync mid-resize turns all three tiles done and removes the note. Tests hold Echo on its resting pose with `LocalEchoMotion provides false` so the screen can go idle. App navigation tests cover hiding restored playback during Account and onboarding and restoring normal player presentation afterward. - `AutoDownloadSettingsActionsTest` exercises the same action callbacks and background presentation used by the screen: all four episode limits, invalid-limit rejection, help opening/dismissal, both background-control visibility states, and retaining all network/charging combinations when checks are disabled and re-enabled. No device or screenshot automation is required. diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackComposeParts.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackComposeParts.kt index d4c2db790..43a8a888b 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackComposeParts.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackComposeParts.kt @@ -1,6 +1,7 @@ package cx.aswin.boxlore.feature.settings.feedback import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -14,6 +15,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.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField @@ -69,22 +71,24 @@ internal const val FEEDBACK_TYPING_BAR_TAG = "feedback_typing_bar" internal const val FEEDBACK_PHOTOS_TAG = "feedback_photos" internal const val FEEDBACK_PRIVACY_URL = "https://boxlore.in/privacy#feedback" -internal val FeedbackCategory.icon: ImageVector - get() = when (this) { +/** The app's icon for the choices it knows; a speech bubble for any the server adds later. */ +internal val FeedbackChoice.icon: ImageVector + get() = when (known) { FeedbackCategory.FEATURE -> Icons.Rounded.Lightbulb FeedbackCategory.BUG -> Icons.Rounded.BugReport FeedbackCategory.AUDIO -> Icons.Rounded.Headphones - FeedbackCategory.OTHER -> Icons.Rounded.ChatBubbleOutline + FeedbackCategory.OTHER, null -> Icons.Rounded.ChatBubbleOutline } /** The genre-chip look from Explore: round, 48 dp, icon first, primary container when chosen. */ @Composable -internal fun FeedbackTypePill(category: FeedbackCategory, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { +internal fun FeedbackTypePill(choice: FeedbackChoice, selected: Boolean, enabled: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { FilterChip( selected = selected, onClick = onClick, - label = { Text(category.label, fontWeight = GoogleSansWeight.semiBold) }, - leadingIcon = { Icon(category.icon, contentDescription = null, modifier = Modifier.size(18.dp)) }, + enabled = enabled, + label = { Text(choice.label, fontWeight = GoogleSansWeight.semiBold) }, + leadingIcon = { Icon(choice.icon, contentDescription = null, modifier = Modifier.size(18.dp)) }, modifier = modifier.heightIn(min = 48.dp), shape = CircleShape, border = null, @@ -101,25 +105,32 @@ internal fun FeedbackTypePill(category: FeedbackCategory, selected: Boolean, onC @OptIn(ExperimentalLayoutApi::class) @Composable -internal fun FeedbackTypePills(selected: FeedbackCategory, onSelect: (FeedbackCategory) -> Unit, modifier: Modifier = Modifier) { +internal fun FeedbackTypePills( + choices: List, + selected: FeedbackChoice, + enabled: Boolean, + onSelect: (String) -> Unit, + modifier: Modifier = Modifier, +) { FlowRow( modifier = modifier.testTag(FEEDBACK_PILLS_TAG), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - FeedbackCategory.entries.forEach { category -> - FeedbackTypePill(category = category, selected = category == selected, onClick = { onSelect(category) }) + choices.forEach { choice -> + FeedbackTypePill(choice = choice, selected = choice.id == selected.id, enabled = enabled, onClick = { onSelect(choice.id) }) } } } -/** Compact type picker for the typing layout: the chosen pill, opening a menu of the four types. */ +/** Compact type picker for the typing layout: the chosen pill, opening a menu of every choice. */ @Composable -internal fun FeedbackTypeMenuPill(selected: FeedbackCategory, onSelect: (FeedbackCategory) -> Unit) { +internal fun FeedbackTypeMenuPill(choices: List, selected: FeedbackChoice, enabled: Boolean, onSelect: (String) -> Unit) { var open by remember { mutableStateOf(false) } Box { Surface( onClick = { open = true }, + enabled = enabled, shape = CircleShape, color = MaterialTheme.colorScheme.primaryContainer, contentColor = MaterialTheme.colorScheme.onPrimaryContainer, @@ -138,17 +149,17 @@ internal fun FeedbackTypeMenuPill(selected: FeedbackCategory, onSelect: (Feedbac } } DropdownMenu(expanded = open, onDismissRequest = { open = false }) { - FeedbackCategory.entries.forEach { category -> + choices.forEach { choice -> DropdownMenuItem( - text = { Text(category.label) }, - leadingIcon = { Icon(category.icon, contentDescription = null) }, - trailingIcon = if (category == selected) { + text = { Text(choice.label) }, + leadingIcon = { Icon(choice.icon, contentDescription = null) }, + trailingIcon = if (choice.id == selected.id) { { Icon(Icons.Rounded.Check, contentDescription = null) } } else { null }, onClick = { - onSelect(category) + onSelect(choice.id) open = false }, ) @@ -164,9 +175,11 @@ internal fun FeedbackMessageCard( onValueChange: (String) -> Unit, placeholder: String, maxChars: Int, + readOnly: Boolean, onFocusChanged: (Boolean) -> Unit, modifier: Modifier = Modifier, ) { + val over = value.length > maxChars Surface( modifier = modifier, shape = RoundedCornerShape(24.dp), @@ -180,6 +193,7 @@ internal fun FeedbackMessageCard( BasicTextField( value = value, onValueChange = onValueChange, + readOnly = readOnly, textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface), cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), modifier = Modifier @@ -192,7 +206,8 @@ internal fun FeedbackMessageCard( Text( text = "${value.length}/$maxChars", style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = if (over) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = if (over) GoogleSansWeight.semiBold else null, modifier = Modifier.align(Alignment.End), ) } @@ -203,6 +218,7 @@ internal fun FeedbackMessageCard( internal fun FeedbackPhotoRow( photos: List, canAdd: Boolean, + canRemove: Boolean, maxPhotos: Int, onAdd: () -> Unit, onRemove: (PickedPhoto) -> Unit, @@ -213,54 +229,66 @@ internal fun FeedbackPhotoRow( horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically, ) { - Surface( - onClick = onAdd, - enabled = canAdd, - shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.surfaceContainer, - contentColor = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .size(64.dp) - .semantics { contentDescription = "Add a photo or screenshot" }, + // Scrolls sideways, so every photo and its Remove stay reachable on narrow phones. + Row( + modifier = Modifier.weight(1f).horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { - Icon(Icons.Rounded.AddPhotoAlternate, contentDescription = null, modifier = Modifier.size(20.dp)) - Text("Add", style = MaterialTheme.typography.labelSmall, fontWeight = GoogleSansWeight.semiBold) - } + FeedbackPhotoTiles(photos, canAdd, canRemove, onAdd, onRemove) } - photos.forEachIndexed { index, photo -> - Box(Modifier.size(64.dp)) { - AsyncImage( - model = photo.uri, - contentDescription = "Photo ${index + 1}", - contentScale = ContentScale.Crop, - modifier = Modifier - .size(64.dp) - .clip(RoundedCornerShape(20.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerHighest), - ) - Surface( - onClick = { onRemove(photo) }, - shape = CircleShape, - color = MaterialTheme.colorScheme.inverseSurface, - contentColor = MaterialTheme.colorScheme.inverseOnSurface, - modifier = Modifier - .align(Alignment.TopEnd) - .padding(2.dp) - .size(24.dp) - .semantics { contentDescription = "Remove photo ${index + 1}" }, - ) { - Icon(Icons.Rounded.Close, contentDescription = null, modifier = Modifier.padding(5.dp)) - } + Text("Up to $maxPhotos", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +private fun FeedbackPhotoTiles(photos: List, canAdd: Boolean, canRemove: Boolean, onAdd: () -> Unit, onRemove: (PickedPhoto) -> Unit) { + Surface( + onClick = onAdd, + enabled = canAdd, + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainer, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .size(64.dp) + .semantics { contentDescription = "Add a photo or screenshot" }, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { + Icon(Icons.Rounded.AddPhotoAlternate, contentDescription = null, modifier = Modifier.size(20.dp)) + Text("Add", style = MaterialTheme.typography.labelSmall, fontWeight = GoogleSansWeight.semiBold) + } + } + photos.forEachIndexed { index, photo -> + Box(Modifier.size(64.dp)) { + AsyncImage( + model = photo.uri, + contentDescription = "Photo ${index + 1}", + contentScale = ContentScale.Crop, + modifier = Modifier + .size(64.dp) + .clip(RoundedCornerShape(20.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHighest), + ) + Surface( + onClick = { onRemove(photo) }, + enabled = canRemove, + shape = CircleShape, + color = MaterialTheme.colorScheme.inverseSurface, + contentColor = MaterialTheme.colorScheme.inverseOnSurface, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(2.dp) + .size(24.dp) + .semantics { contentDescription = "Remove photo ${index + 1}" }, + ) { + Icon(Icons.Rounded.Close, contentDescription = null, modifier = Modifier.padding(5.dp)) } } - Spacer(Modifier.weight(1f)) - Text("Up to $maxPhotos", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) } } @Composable -internal fun FeedbackDiagnosticsRow(checked: Boolean, onCheckedChange: (Boolean) -> Unit, modifier: Modifier = Modifier) { +internal fun FeedbackDiagnosticsRow(checked: Boolean, enabled: Boolean, onCheckedChange: (Boolean) -> Unit, modifier: Modifier = Modifier) { Row(modifier = modifier.fillMaxWidth().heightIn(min = 48.dp), verticalAlignment = Alignment.CenterVertically) { Text( text = "Include device diagnostics", @@ -270,6 +298,7 @@ internal fun FeedbackDiagnosticsRow(checked: Boolean, onCheckedChange: (Boolean) Switch( checked = checked, onCheckedChange = onCheckedChange, + enabled = enabled, modifier = Modifier.semantics { contentDescription = "Include device diagnostics" }, ) } @@ -343,6 +372,7 @@ internal fun FeedbackTypingBar( FilterChip( selected = state.attachDiagnostics, onClick = onToggleDiagnostics, + enabled = !state.isSubmitting, label = { Text("Diagnostics", fontWeight = GoogleSansWeight.semiBold) }, leadingIcon = if (state.attachDiagnostics) { { Icon(Icons.Rounded.Check, contentDescription = null, modifier = Modifier.size(18.dp)) } 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 2e9f57fe1..e215b8268 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 @@ -112,7 +112,7 @@ fun FeedbackScreen( internal class FeedbackComposeActions( val onBack: () -> Unit, - val onCategorySelected: (FeedbackCategory) -> Unit, + val onCategorySelected: (String) -> Unit, val onMessageChanged: (String) -> Unit, val onAttachDiagnosticsChanged: (Boolean) -> Unit, val onPhotosPicked: (List) -> Unit, @@ -122,7 +122,10 @@ internal class FeedbackComposeActions( val onDiscardDraft: () -> Unit, ) -/** The compose screen until the message is sent, then [FeedbackSentScreen]; Done leaves like Back. */ +/** + * The compose screen until the message is sent, then [FeedbackSentScreen]; Done leaves like Back. + * [imeBottom] is the keyboard's height (tests pass their own). + */ @Composable internal fun FeedbackContent( state: FeedbackUiState, @@ -130,17 +133,19 @@ internal fun FeedbackContent( boxcastPrefs: BoxcastPrefs, bottomContentPadding: Dp, modifier: Modifier = Modifier, + imeBottom: Dp = WindowInsets.ime.asPaddingValues().calculateBottomPadding(), ) { if (state.isSuccess) { FeedbackSentScreen( boxcastPrefs = boxcastPrefs, repliesInApp = state.repliesInApp, + photosDropped = state.photosDropped, onDone = actions.onBack, bottomContentPadding = bottomContentPadding, modifier = modifier, ) } else { - FeedbackComposeScreen(state = state, actions = actions, bottomContentPadding = bottomContentPadding, modifier = modifier) + FeedbackComposeScreen(state = state, actions = actions, bottomContentPadding = bottomContentPadding, imeBottom = imeBottom, modifier = modifier) } } @@ -150,21 +155,29 @@ internal fun isTypingLayout(messageFocused: Boolean, imeBottom: Dp): Boolean = m /** Space under the content: the keyboard while it's up, otherwise the app's bottom chrome and the system bar. */ internal fun feedbackBottomPadding(imeBottom: Dp, chrome: Dp, systemBar: Dp): Dp = if (imeBottom > 0.dp) imeBottom else chrome + systemBar +/** + * One layout for every state, so the message field is never replaced: the keyboard coming up + * (the typing layout) or the window getting short (scrolling) only changes what sits around it, + * and the field keeps its focus, cursor and the text being composed. + */ @Composable internal fun FeedbackComposeScreen( state: FeedbackUiState, actions: FeedbackComposeActions, bottomContentPadding: Dp, + imeBottom: Dp, modifier: Modifier = Modifier, ) { var messageFocused by rememberSaveable { mutableStateOf(false) } - val imeBottom = WindowInsets.ime.asPaddingValues().calculateBottomPadding() val systemBar = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() val typing = isTypingLayout(messageFocused, imeBottom) val picker = rememberLauncherForActivityResult( ActivityResultContracts.PickMultipleVisualMedia(maxItems = state.meta.maxPhotos.coerceAtLeast(2)), ) { uris -> actions.onPhotosPicked(uris) } val pickPhotos = { picker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) } + val uriHandler = LocalUriHandler.current + val scroll = rememberScrollState() + val editable = !state.isSubmitting Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) { Box( @@ -175,113 +188,120 @@ internal fun FeedbackComposeScreen( contentAlignment = Alignment.TopCenter, ) { Column(Modifier.fillMaxSize().boxloreCardWidth(BoxloreCardFamily.Utility)) { + if (typing) TypingHeader(state, actions) else TopRow(state, actions) + BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) { + // While typing the field takes the room the keyboard leaves; a short window otherwise scrolls. + val stretch = typing || maxHeight >= FEEDBACK_TALL_ENOUGH + Column(Modifier.fillMaxSize().then(if (stretch) Modifier else Modifier.verticalScroll(scroll))) { + if (!typing) Intro(state) + if (!typing) { + FeedbackTypePills( + choices = state.choices, + selected = state.choice, + enabled = editable, + onSelect = actions.onCategorySelected, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 20.dp), + ) + } + FeedbackMessageCard( + value = state.message, + onValueChange = actions.onMessageChanged, + placeholder = state.choice.placeholder, + maxChars = state.meta.maxBody, + readOnly = !editable, + onFocusChanged = { messageFocused = it }, + modifier = Modifier + .padding(start = 16.dp, end = 16.dp, top = if (typing) 8.dp else 16.dp) + .fillMaxWidth() + .then(if (stretch) Modifier.weight(1f) else Modifier.height(FEEDBACK_SHORT_MESSAGE_HEIGHT)), + ) + if (!typing) AttachRows(state, onAddPhoto = pickPhotos, actions = actions) + (state.errorMessage ?: state.limitMessage)?.let { error -> ErrorLine(error, small = typing) } + if (!typing) SendFooter(state, onSubmit = actions.onSubmit, onPrivacy = { uriHandler.openUri(FEEDBACK_PRIVACY_URL) }) + } + } if (typing) { - TypingLayout(state, actions, onFocusChanged = { messageFocused = it }, onAddPhoto = pickPhotos) - } else { - FullLayout(state, actions, onFocusChanged = { messageFocused = it }, onAddPhoto = pickPhotos) + FeedbackTypingBar( + state = state, + onAddPhoto = pickPhotos, + onToggleDiagnostics = { actions.onAttachDiagnosticsChanged(!state.attachDiagnostics) }, + onSend = actions.onSubmit, + ) } } } } } +/** Photos (while the server allows them) and the diagnostics switch, under the message. */ @Composable -private fun ColumnScope.FullLayout( - state: FeedbackUiState, - actions: FeedbackComposeActions, - onFocusChanged: (Boolean) -> Unit, - onAddPhoto: () -> Unit, -) { - val uriHandler = LocalUriHandler.current - TopRow(state, actions) - BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) { - val tall = maxHeight >= FEEDBACK_TALL_ENOUGH - val content: @Composable ColumnScope.() -> Unit = { - Column(Modifier.padding(horizontal = 20.dp)) { - Text("Share your thoughts", style = MaterialTheme.typography.headlineMedium) - Spacer(Modifier.height(4.dp)) - Text( - "Bugs, ideas, anything at all. The boxlore team reads every message.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - state.shownNotice?.let { notice -> - Spacer(Modifier.height(12.dp)) - Surface(shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.secondaryContainer) { - Text( - notice, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSecondaryContainer, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), - ) - } - } - } - FeedbackTypePills( - selected = state.category, - onSelect = actions.onCategorySelected, - modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 20.dp), - ) - FeedbackMessageCard( - value = state.message, - onValueChange = actions.onMessageChanged, - placeholder = state.category.placeholder, - maxChars = state.meta.maxBody.coerceAtMost(FeedbackViewModel.MAX_MESSAGE), - onFocusChanged = onFocusChanged, - modifier = Modifier - .padding(start = 16.dp, end = 16.dp, top = 16.dp) - .fillMaxWidth() - .then(if (tall) Modifier.weight(1f) else Modifier.height(FEEDBACK_SHORT_MESSAGE_HEIGHT)), - ) - if (state.meta.photosEnabled) { - FeedbackPhotoRow( - photos = state.photos, - canAdd = state.canAddPhotos, - maxPhotos = state.meta.maxPhotos, - onAdd = onAddPhoto, - onRemove = actions.onRemovePhoto, - modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 12.dp), - ) - } - FeedbackDiagnosticsRow( - checked = state.attachDiagnostics, - onCheckedChange = actions.onAttachDiagnosticsChanged, - modifier = Modifier.padding(start = 20.dp, end = 16.dp, top = 8.dp), - ) - state.errorMessage?.let { error -> +private fun AttachRows(state: FeedbackUiState, onAddPhoto: () -> Unit, actions: FeedbackComposeActions) { + if (state.meta.photosEnabled) { + FeedbackPhotoRow( + photos = state.photos, + canAdd = state.canAddPhotos, + canRemove = !state.isSubmitting, + maxPhotos = state.meta.maxPhotos, + onAdd = onAddPhoto, + onRemove = actions.onRemovePhoto, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 12.dp), + ) + } + FeedbackDiagnosticsRow( + checked = state.attachDiagnostics, + enabled = !state.isSubmitting, + onCheckedChange = actions.onAttachDiagnosticsChanged, + modifier = Modifier.padding(start = 20.dp, end = 16.dp, top = 8.dp), + ) +} + +@Composable +private fun ErrorLine(text: String, small: Boolean) { + Text( + text, + style = if (small) MaterialTheme.typography.bodySmall else MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp), + ) +} + +@Composable +private fun ColumnScope.SendFooter(state: FeedbackUiState, onSubmit: () -> Unit, onPrivacy: () -> Unit) { + FeedbackSendButton( + enabled = state.canSend, + sending = state.isSubmitting, + onClick = onSubmit, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp).fillMaxWidth().height(64.dp), + ) + FeedbackPrivacyLink(onOpen = onPrivacy, modifier = Modifier.align(Alignment.CenterHorizontally).padding(bottom = 4.dp)) +} + +@Composable +private fun Intro(state: FeedbackUiState) { + Column(Modifier.padding(horizontal = 20.dp)) { + Text("Share your thoughts", style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(4.dp)) + Text( + "Bugs, ideas, anything at all. The boxlore team reads every message.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + state.shownNotice?.let { notice -> + Spacer(Modifier.height(12.dp)) + Surface(shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.secondaryContainer) { Text( - error, + notice, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp), + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), ) } - FeedbackSendButton( - enabled = state.canSend, - sending = state.isSubmitting, - onClick = actions.onSubmit, - modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp).fillMaxWidth().height(64.dp), - ) - FeedbackPrivacyLink( - onOpen = { uriHandler.openUri(FEEDBACK_PRIVACY_URL) }, - modifier = Modifier.align(Alignment.CenterHorizontally).padding(bottom = 4.dp), - ) - } - if (tall) { - Column(Modifier.fillMaxSize(), content = content) - } else { - Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()), content = content) } } } @Composable -private fun ColumnScope.TypingLayout( - state: FeedbackUiState, - actions: FeedbackComposeActions, - onFocusChanged: (Boolean) -> Unit, - onAddPhoto: () -> Unit, -) { +private fun TypingHeader(state: FeedbackUiState, actions: FeedbackComposeActions) { Row( modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically, @@ -289,26 +309,9 @@ private fun ColumnScope.TypingLayout( FeedbackIconButton(Icons.AutoMirrored.Rounded.ArrowBack, "Navigate back", actions.onBack) Text("Share your thoughts", style = MaterialTheme.typography.titleLarge, modifier = Modifier.weight(1f)) Box(Modifier.padding(end = 8.dp)) { - FeedbackTypeMenuPill(selected = state.category, onSelect = actions.onCategorySelected) + FeedbackTypeMenuPill(choices = state.choices, selected = state.choice, enabled = !state.isSubmitting, onSelect = actions.onCategorySelected) } } - FeedbackMessageCard( - value = state.message, - onValueChange = actions.onMessageChanged, - placeholder = state.category.placeholder, - maxChars = state.meta.maxBody.coerceAtMost(FeedbackViewModel.MAX_MESSAGE), - onFocusChanged = onFocusChanged, - modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp).fillMaxWidth().weight(1f), - ) - state.errorMessage?.let { error -> - Text(error, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp)) - } - FeedbackTypingBar( - state = state, - onAddPhoto = onAddPhoto, - onToggleDiagnostics = { actions.onAttachDiagnosticsChanged(!state.attachDiagnostics) }, - onSend = actions.onSubmit, - ) } @Composable diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSentScreen.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSentScreen.kt index 383e2e52a..304b4bc94 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSentScreen.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSentScreen.kt @@ -59,11 +59,17 @@ internal const val FEEDBACK_NOTIFY_CARD_TAG = "feedback_notify_card" /** Echo looks through the magnifier once, then rests. */ internal const val FEEDBACK_SENT_ECHO = "feedback-sent" -/** The reply promise only when the message became a conversation; the old form has no replies in the app. */ -internal fun feedbackSentBody(repliesInApp: Boolean): String = if (repliesInApp) { - "Thanks for telling us. The boxlore team reads every message, and the reply will show up in Feedback." -} else { - "Thanks for telling us. The boxlore team reads every message." +/** + * The reply promise only when the message became a conversation (the old form has no replies in + * the app), and a plain note when its photos couldn't go along. + */ +internal fun feedbackSentBody(repliesInApp: Boolean, photosDropped: Boolean = false): String { + val thanks = if (repliesInApp) { + "Thanks for telling us. The boxlore team reads every message, and the reply will show up in Feedback." + } else { + "Thanks for telling us. The boxlore team reads every message." + } + return if (photosDropped) "$thanks Your photos couldn't be sent this time." else thanks } /** @@ -74,6 +80,7 @@ internal fun feedbackSentBody(repliesInApp: Boolean): String = if (repliesInApp) internal fun FeedbackSentScreen( boxcastPrefs: BoxcastPrefs, repliesInApp: Boolean, + photosDropped: Boolean, onDone: () -> Unit, bottomContentPadding: Dp, modifier: Modifier = Modifier, @@ -116,7 +123,7 @@ internal fun FeedbackSentScreen( Text("Feedback sent", style = MaterialTheme.typography.headlineMedium, textAlign = TextAlign.Center) Spacer(Modifier.height(8.dp)) Text( - feedbackSentBody(repliesInApp), + feedbackSentBody(repliesInApp, photosDropped), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, 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 3531622f8..89d82358b 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 @@ -11,6 +11,7 @@ import cx.aswin.boxlore.core.prefs.BoxcastPrefs import cx.aswin.boxlore.core.prefs.FeedbackDraft import cx.aswin.boxlore.core.support.OutgoingFeedback import cx.aswin.boxlore.core.support.PhotoPrep +import cx.aswin.boxlore.core.support.PhotoUpload import cx.aswin.boxlore.core.support.SendResult import cx.aswin.boxlore.core.support.SupportMeta import cx.aswin.boxlore.core.support.SupportRepository @@ -25,6 +26,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +/** The app's own list: shown when the server sends none, and the icons and hints for the ids it knows. */ enum class FeedbackCategory( val id: String, val label: String, @@ -39,11 +41,23 @@ enum class FeedbackCategory( val FeedbackCategory.isBugReport: Boolean get() = this == FeedbackCategory.BUG || this == FeedbackCategory.AUDIO +/** One choice of what a message is about: the server's id and label, with the app's hint when it knows the id. */ +data class FeedbackChoice(val id: String, val label: String) { + val known: FeedbackCategory? get() = FeedbackCategory.entries.find { it.id == id } + val placeholder: String get() = known?.placeholder ?: FeedbackCategory.OTHER.placeholder + + /** Bug reports start with diagnostics on. */ + val isBugReport: Boolean get() = known?.isBugReport == true +} + +internal val DEFAULT_CHOICES = FeedbackCategory.entries.map { FeedbackChoice(it.id, it.label) } + /** A photo or screenshot picked for the message; uploaded only when the message is sent. */ data class PickedPhoto(val uri: Uri) data class FeedbackUiState( - val category: FeedbackCategory = FeedbackCategory.FEATURE, + /** The picked choice's id; [choice] falls back to the first one when the server no longer lists it. */ + val categoryId: String = FeedbackCategory.FEATURE.id, val message: String = "", val attachDiagnostics: Boolean = false, val photos: List = emptyList(), @@ -53,13 +67,21 @@ data class FeedbackUiState( val isSuccess: Boolean = false, /** True when the message became a conversation, so the reply will show up in the app. */ val repliesInApp: Boolean = false, + /** Support wasn't available, so the message went without its photos. */ + val photosDropped: Boolean = false, val errorMessage: String? = null, val diagnosticInfo: DiagnosticInfo? = null, val logsPreview: String? = null, val isLoadingLogs: Boolean = false, ) { - val canAddPhotos: Boolean get() = meta.photosEnabled && photos.size < meta.maxPhotos - val canSend: Boolean get() = message.isNotBlank() && !isSubmitting && meta.canStartNew + val choices: List get() = meta.categories.map { FeedbackChoice(it.id, it.label) }.ifEmpty { DEFAULT_CHOICES } + val choice: FeedbackChoice get() = choices.firstOrNull { it.id == categoryId } ?: choices.first() + val canAddPhotos: Boolean get() = meta.photosEnabled && photos.size < meta.maxPhotos && !isSubmitting + + /** An older draft can be longer than the limit; it must be shortened before it can be sent. */ + val overLimit: Boolean get() = message.length > meta.maxBody + val canSend: Boolean get() = message.isNotBlank() && !overLimit && !isSubmitting && meta.canStartNew + val limitMessage: String? get() = if (overLimit) "This is over the ${"%,d".format(meta.maxBody)}-character limit. Shorten it to send." else null /** The server's notice, or a plain one when new messages are paused without one. */ val shownNotice: String? get() = meta.notice ?: PAUSED_NOTICE.takeUnless { meta.canStartNew } @@ -67,6 +89,17 @@ data class FeedbackUiState( internal const val PAUSED_NOTICE = "Sending is paused for now. Your message stays saved here." +/** + * The message after an edit: typing stops at [limit], and text already past it (an older, longer + * draft) can only get shorter, so nothing of it is cut off without the listener choosing to. + */ +internal fun limitedMessage(current: String, next: String, limit: Int): String = when { + next.length <= limit -> next + next.length < current.length -> next + current.length > limit -> current + else -> next.take(limit) +} + /** * One retry key per message: sending the same content again (after a failure whose answer may * have been lost) reuses the key, so the API saves it once; changed content gets a new key. @@ -88,10 +121,19 @@ internal class RetryKeys(private val newKey: () -> String = { UUID.randomUUID(). class FeedbackSupport( val meta: StateFlow, val refresh: suspend () -> Unit, - val uploadPhoto: suspend (bytes: ByteArray, mimeType: String) -> String?, + val uploadPhoto: suspend (bytes: ByteArray, mimeType: String) -> PhotoUpload, val send: suspend (OutgoingFeedback) -> SendResult, ) +private sealed interface PhotoBatch { + data class Uploaded(val ids: List) : PhotoBatch + + /** Support isn't there for photos; the message goes without them. */ + data object Unavailable : PhotoBatch + + data object Failed : PhotoBatch +} + class FeedbackViewModel( private val support: FeedbackSupport, private val boxcastPrefs: BoxcastPrefs, @@ -104,6 +146,9 @@ class FeedbackViewModel( private val _uiState = MutableStateFlow(FeedbackUiState()) val uiState: StateFlow = _uiState.asStateFlow() + /** While a message is going out its draft is fixed, so what was sent is what gets cleared. */ + private val sending: Boolean get() = _uiState.value.isSubmitting + init { loadInitialState() viewModelScope.launch { support.meta.collect { meta -> _uiState.update { it.copy(meta = meta) } } } @@ -113,15 +158,15 @@ class FeedbackViewModel( private fun loadInitialState() { val diagnostics = DiagnosticCollector.collect(context) val draft = boxcastPrefs.getFeedbackDraft() - // Older drafts kept "steps to reproduce" apart; they now join the message. + // Older drafts kept "steps to reproduce" apart; they now join the message, in full. val draftMessage = draft?.let { listOf(it.message.trim(), it.stepsToReproduce.trim()).filter(String::isNotBlank).joinToString("\n\n") }.orEmpty() if (draft != null && draftMessage.isNotBlank()) { _uiState.update { it.copy( - category = FeedbackCategory.entries.find { c -> c.id == draft.category } ?: FeedbackCategory.FEATURE, - message = draftMessage.take(MAX_MESSAGE), + categoryId = draft.category.ifBlank { FeedbackCategory.FEATURE.id }, + message = draftMessage, attachDiagnostics = draft.attachDiagnostics, isDraftRestored = true, diagnosticInfo = diagnostics, @@ -132,23 +177,29 @@ class FeedbackViewModel( } } - fun onCategorySelected(category: FeedbackCategory) { - _uiState.update { it.copy(category = category, attachDiagnostics = category.isBugReport, errorMessage = null) } + fun onCategorySelected(id: String) { + if (sending) return + _uiState.update { state -> + val choice = state.choices.firstOrNull { it.id == id } ?: return@update state + state.copy(categoryId = choice.id, attachDiagnostics = choice.isBugReport, errorMessage = null) + } saveCurrentDraft() } fun onMessageChanged(message: String) { - val limit = _uiState.value.meta.maxBody.coerceAtMost(MAX_MESSAGE) - _uiState.update { it.copy(message = message.take(limit), errorMessage = null) } + if (sending) return + _uiState.update { it.copy(message = limitedMessage(it.message, message, it.meta.maxBody), errorMessage = null) } saveCurrentDraft() } fun onAttachDiagnosticsChanged(attach: Boolean) { + if (sending) return _uiState.update { it.copy(attachDiagnostics = attach) } saveCurrentDraft() } fun onPhotosPicked(uris: List) { + if (sending) return _uiState.update { state -> if (!state.meta.photosEnabled) return@update state val room = (state.meta.maxPhotos - state.photos.size).coerceAtLeast(0) @@ -158,14 +209,16 @@ class FeedbackViewModel( } fun onRemovePhoto(photo: PickedPhoto) { + if (sending) return _uiState.update { it.copy(photos = it.photos - photo) } } fun onDiscardDraft() { + if (sending) return boxcastPrefs.clearFeedbackDraft() _uiState.update { it.copy( - category = FeedbackCategory.FEATURE, + categoryId = FeedbackCategory.FEATURE.id, message = "", attachDiagnostics = false, photos = emptyList(), @@ -183,7 +236,7 @@ class FeedbackViewModel( } boxcastPrefs.saveFeedbackDraft( FeedbackDraft( - category = current.category.id, + category = current.categoryId, message = current.message, email = "", stepsToReproduce = "", @@ -206,49 +259,56 @@ class FeedbackViewModel( fun onSubmit() { val state = _uiState.value - if (state.isSubmitting) return + if (state.isSubmitting || state.overLimit) return val body = state.message.trim() if (body.isBlank()) { _uiState.update { it.copy(errorMessage = "Write a message first.") } return } + val choice = state.choice _uiState.update { it.copy(isSubmitting = true, errorMessage = null) } - val requestId = retryKeys.keyFor(listOf(state.category, body, state.photos, state.attachDiagnostics)) + val requestId = retryKeys.keyFor(listOf(choice.id, body, state.photos, state.attachDiagnostics)) viewModelScope.launch { val diagnosticsInfo = state.diagnosticInfo ?: DiagnosticCollector.collect(context) val (diagnosticsReport, sanitizedLogs) = prepareDiagnosticPayload(state, diagnosticsInfo) - val photoIds = uploadPhotos(state) - if (photoIds == null) { + val photos = uploadPhotos(state) + if (photos is PhotoBatch.Failed) { _uiState.update { it.copy(isSubmitting = false, errorMessage = "Couldn't attach your photos. Check your connection and try again.") } return@launch } val result = support.send( OutgoingFeedback( - category = state.category.id, + category = choice.id, body = body, appVersion = diagnosticsInfo.appVersion, - device = diagnosticsInfo.deviceLabel(), + // The phone model is part of the diagnostics the listener can turn off. + device = diagnosticsInfo.deviceLabel().takeIf { state.attachDiagnostics }, diagnostics = diagnosticsReport, logs = sanitizedLogs, - photoIds = photoIds, + photoIds = (photos as? PhotoBatch.Uploaded)?.ids.orEmpty(), requestId = requestId, ), ) - handleResult(state, result) + handleResult(choice, state, result, photosDropped = photos is PhotoBatch.Unavailable) } } - /** Photo ids in order, or null when any upload failed (nothing is sent half-attached). */ - private suspend fun uploadPhotos(state: FeedbackUiState): List? { - if (state.photos.isEmpty() || !state.meta.photosEnabled) return emptyList() + /** Photo ids in order; nothing is sent half-attached, and photos are left out only when support can't take them. */ + private suspend fun uploadPhotos(state: FeedbackUiState): PhotoBatch { + if (state.photos.isEmpty()) return PhotoBatch.Uploaded(emptyList()) + if (!state.meta.photosEnabled) return PhotoBatch.Unavailable val ids = mutableListOf() for (photo in state.photos) { - val bytes = withContext(ioDispatcher) { readPhoto(photo.uri) } ?: return null - if (state.meta.maxPhotoBytes > 0 && bytes.size > state.meta.maxPhotoBytes) return null - ids += support.uploadPhoto(bytes, "image/jpeg") ?: return null + val bytes = withContext(ioDispatcher) { readPhoto(photo.uri) } ?: return PhotoBatch.Failed + if (state.meta.maxPhotoBytes > 0 && bytes.size > state.meta.maxPhotoBytes) return PhotoBatch.Failed + when (val upload = support.uploadPhoto(bytes, "image/jpeg")) { + is PhotoUpload.Uploaded -> ids += upload.id + PhotoUpload.Unavailable -> return PhotoBatch.Unavailable + PhotoUpload.Failed -> return PhotoBatch.Failed + } } - return ids + return PhotoBatch.Uploaded(ids) } private suspend fun prepareDiagnosticPayload(state: FeedbackUiState, diagnosticsInfo: DiagnosticInfo): Pair { @@ -259,19 +319,25 @@ class FeedbackViewModel( return diagnosticsInfo.toMarkdownReport() to sanitizedLogs } - private fun handleResult(state: FeedbackUiState, result: SendResult) { + private fun handleResult(choice: FeedbackChoice, state: FeedbackUiState, result: SendResult, photosDropped: Boolean) { when (result) { is SendResult.Sent, SendResult.SentByEmailForm -> { - val repliesInApp = result is SendResult.Sent AnalyticsHelper.trackFeedbackSent( - category = analyticsCategory(state.category), + category = analyticsCategory(choice), withEmail = false, withDiagnostics = state.attachDiagnostics, ) boxcastPrefs.clearFeedbackDraft() retryKeys.clear() _uiState.update { - it.copy(isSubmitting = false, isSuccess = true, repliesInApp = repliesInApp, isDraftRestored = false, photos = emptyList()) + it.copy( + isSubmitting = false, + isSuccess = true, + repliesInApp = result is SendResult.Sent, + photosDropped = photosDropped, + isDraftRestored = false, + photos = emptyList(), + ) } } is SendResult.Refused -> _uiState.update { it.copy(isSubmitting = false, errorMessage = result.message) } @@ -298,21 +364,17 @@ class FeedbackViewModel( context = context.applicationContext, ) as T } - - companion object { - const val MAX_MESSAGE = 2000 - } } -/** Analytics keeps the categories it always had: audio counts as a bug report. */ -private fun analyticsCategory(category: FeedbackCategory): String = when (category) { +/** Analytics keeps the categories it always had: audio counts as a bug report, anything new as other. */ +private fun analyticsCategory(choice: FeedbackChoice): String = when (choice.known) { FeedbackCategory.FEATURE -> "feature" FeedbackCategory.BUG, FeedbackCategory.AUDIO -> "bug" - FeedbackCategory.OTHER -> "other" + FeedbackCategory.OTHER, null -> "other" } fun buildFeedbackGitHubIssueUrl(state: FeedbackUiState): String { - val title = "[${state.category.label}]: " + state.message.take(60).replace("\n", " ").trim() + val title = "[${state.choice.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." }) diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackAdaptiveTransitionTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackAdaptiveTransitionTest.kt index 3240da967..1a5a632ee 100644 --- a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackAdaptiveTransitionTest.kt +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackAdaptiveTransitionTest.kt @@ -14,12 +14,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.test.SemanticsNodeInteraction import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.hasScrollAction import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.StateRestorationTester +import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick @@ -38,6 +40,7 @@ import cx.aswin.boxlore.core.designsystem.layout.LocalBoxloreWindowLayout import cx.aswin.boxlore.core.designsystem.theme.BoxLoreTheme import cx.aswin.boxlore.core.prefs.BoxcastPrefs import cx.aswin.boxlore.core.support.OutgoingFeedback +import cx.aswin.boxlore.core.support.PhotoUpload import cx.aswin.boxlore.core.support.SendResult import cx.aswin.boxlore.core.support.SupportMeta import cx.aswin.boxlore.core.testing.PhoneGoldenConfiguration @@ -45,7 +48,6 @@ import cx.aswin.boxlore.core.testing.PhoneGoldenRule import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import org.junit.Assert.assertEquals -import org.junit.Assert.assertSame import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -56,7 +58,10 @@ import org.robolectric.annotation.GraphicsMode /** * Drives the live feedback screen across the shared window gates in both directions and through a * recreation: the typed message, the picked type and the reachable Send survive, the message goes - * out once, and the sent screen keeps its Done and notification card through the same changes. + * out once, and the sent screen keeps its Done and notification card through the same changes. The + * view model is built outside composition, as the navigation entry holds it in the app, so these + * tests cover the screen's own state, not the view model's lifetime. The keyboard is played by + * passing its height in. */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [33], application = Application::class) @@ -65,6 +70,8 @@ class FeedbackAdaptiveTransitionTest { @get:Rule val fixture = PhoneGoldenRule(PhoneGoldenConfiguration(1_280, 960, false)) private val window = mutableStateOf(COMPACT) + private val ime = mutableStateOf(0.dp) + private val meta = MutableStateFlow(SupportMeta.Offline) private val sent = mutableListOf() private var result: SendResult = SendResult.Sent("t1") private var backs = 0 @@ -77,12 +84,8 @@ class FeedbackAdaptiveTransitionTest { fixture.compose.onNodeWithText("Bug report").performClick() fixture.compose.onNodeWithTag(FEEDBACK_MESSAGE_TAG).performTextInput(MESSAGE) fixture.compose.waitForIdle() - val owner = viewModel - acrossWindowChanges { - composeStateKept() - assertSame("The same view model keeps the draft", owner, viewModel) - } + acrossWindowChanges { composeStateKept() } recreate(FOLD_TABLETOP) composeStateKept() @@ -123,6 +126,47 @@ class FeedbackAdaptiveTransitionTest { } } + @Test + fun `the message field keeps its focus and text as the keyboard comes and goes and the window changes height`() { + render() + fixture.compose.onNodeWithTag(FEEDBACK_MESSAGE_TAG).performClick() + fixture.compose.onNodeWithTag(FEEDBACK_MESSAGE_TAG).performTextInput(MESSAGE) + fixture.compose.waitForIdle() + fixture.compose.onNodeWithTag(FEEDBACK_MESSAGE_TAG).assertIsFocused() + + for (step in listOf(300.dp, 0.dp, 280.dp, 0.dp)) { + fixture.compose.runOnIdle { ime.value = step } + fixture.compose.waitForIdle() + val typing = step > 0.dp + fixture.compose.onNodeWithTag(FEEDBACK_MESSAGE_TAG).assertIsFocused() + fixture.compose.onNodeWithText(MESSAGE).assertExists() + fixture.compose.onNodeWithTag(FEEDBACK_TYPING_BAR_TAG).apply { if (typing) assertExists() else assertDoesNotExist() } + fixture.compose.onNodeWithTag(FEEDBACK_PILLS_TAG).apply { if (typing) assertDoesNotExist() else assertExists() } + } + // Short (scrolling) and tall (stretching) windows keep the same field. + for (size in listOf(BoxloreWindowLayout(840f, 479f), BoxloreWindowLayout(840f, 700f), BoxloreWindowLayout(800f, 360f), COMPACT)) { + fixture.compose.runOnIdle { window.value = size } + fixture.compose.waitForIdle() + fixture.compose.onNodeWithTag(FEEDBACK_MESSAGE_TAG).assertIsFocused() + fixture.compose.onNodeWithText(MESSAGE).assertExists() + } + fixture.compose.onNodeWithText(MESSAGE).performTextInput(" more") + assertEquals("$MESSAGE more", viewModel.uiState.value.message) + } + + @Test + fun `five photos and their Remove buttons stay reachable on a narrow phone`() { + meta.value = SupportMeta.Offline.copy(photosEnabled = true, maxPhotos = 5, maxPhotoBytes = 1_000_000) + window.value = BoxloreWindowLayout(360f, 800f) + render() + fixture.compose.runOnIdle { viewModel.onPhotosPicked((1..5).map { android.net.Uri.parse("content://photo/$it") }) } + fixture.compose.waitForIdle() + fixture.compose.onNodeWithContentDescription("Remove photo 5").performScrollTo().assertIsDisplayed().performClick() + fixture.compose.waitForIdle() + assertEquals(4, viewModel.uiState.value.photos.size) + fixture.compose.onNodeWithContentDescription("Add a photo or screenshot").performScrollTo().assertIsDisplayed() + } + private fun composeStateKept() { fixture.compose.onNodeWithText(MESSAGE).assertExists() fixture.compose.onNodeWithText("Bug report").assertIsSelected() @@ -164,9 +208,9 @@ class FeedbackAdaptiveTransitionTest { context.getSharedPreferences(BoxcastPrefs.PREFS_NAME, Context.MODE_PRIVATE).edit().clear().commit() val prefs = BoxcastPrefs(context) val support = FeedbackSupport( - meta = MutableStateFlow(SupportMeta.Offline), + meta = meta, refresh = {}, - uploadPhoto = { _, _ -> null }, + uploadPhoto = { _, _ -> PhotoUpload.Unavailable }, send = { feedback -> sent += feedback result @@ -202,6 +246,7 @@ class FeedbackAdaptiveTransitionTest { ), boxcastPrefs = prefs, bottomContentPadding = 96.dp, + imeBottom = ime.value, ) } } diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackLayoutPolicyTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackLayoutPolicyTest.kt index 9e1e80ed2..08a61ac3f 100644 --- a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackLayoutPolicyTest.kt +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackLayoutPolicyTest.kt @@ -27,8 +27,16 @@ class FeedbackLayoutPolicyTest { assertFalse("No runtime prompt before Android 13", canAskForNotifications(32, granted = false, askedBefore = false, shouldShowRationale = false)) } - @Test fun `only a conversation promises a reply in Feedback`() { + @Test fun `only a conversation promises a reply in Feedback, and dropped photos are mentioned`() { assertTrue(feedbackSentBody(repliesInApp = true).contains("reply will show up in Feedback")) assertFalse(feedbackSentBody(repliesInApp = false).contains("reply")) + assertTrue(feedbackSentBody(repliesInApp = false, photosDropped = true).endsWith("Your photos couldn't be sent this time.")) + } + + @Test fun `typing stops at the limit, and text already past it can only shrink`() { + assertEquals("abc", limitedMessage(current = "ab", next = "abc", limit = 5)) + assertEquals("abcde", limitedMessage(current = "ab", next = "abcdefg", limit = 5)) + assertEquals("abcdefg", limitedMessage(current = "abcdefgh", next = "abcdefg", limit = 5)) + assertEquals("abcdefgh", limitedMessage(current = "abcdefgh", next = "abcdefghi", limit = 5)) } } 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 b113adbcb..40792e0bf 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 @@ -7,8 +7,11 @@ import cx.aswin.boxlore.core.analytics.AnalyticsHelper import cx.aswin.boxlore.core.prefs.BoxcastPrefs import cx.aswin.boxlore.core.prefs.FeedbackDraft import cx.aswin.boxlore.core.support.OutgoingFeedback +import cx.aswin.boxlore.core.support.PhotoUpload +import cx.aswin.boxlore.core.support.SupportCategory import cx.aswin.boxlore.core.support.SendResult import cx.aswin.boxlore.core.support.SupportMeta +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -44,7 +47,9 @@ class FeedbackViewModelTest { val sent = mutableListOf() val uploads = mutableListOf() var result: SendResult = SendResult.Sent("t1") - var photoIds: (Int) -> String? = { "p$it" } + var photoIds: (Int) -> PhotoUpload = { PhotoUpload.Uploaded("p$it") } + /** While set, send waits for it. */ + var sendGate: CompletableDeferred? = null var refreshes = 0 fun asSupport() = FeedbackSupport( @@ -55,6 +60,7 @@ class FeedbackViewModelTest { photoIds(uploads.size) }, send = { feedback -> + sendGate?.await() sent += feedback result }, @@ -90,7 +96,7 @@ class FeedbackViewModelTest { ) val state = viewModel(FakeSupport()).uiState.value - assertEquals(FeedbackCategory.BUG, state.category) + assertEquals("bug", state.choice.id) assertEquals("Player stops after 3 minutes\n\n1. Play\n2. Wait", state.message) assertTrue(state.attachDiagnostics) assertTrue(state.isDraftRestored) @@ -124,7 +130,7 @@ class FeedbackViewModelTest { try { val support = FakeSupport() val vm = viewModel(support) - vm.onCategorySelected(FeedbackCategory.AUDIO) + vm.onCategorySelected(FeedbackCategory.AUDIO.id) vm.onMessageChanged(" Stream cuts off at end of track ") vm.onSubmit() @@ -281,7 +287,7 @@ class FeedbackViewModelTest { @Test fun a_failed_photo_upload_sends_nothing() = runTest(testDispatcher) { val support = FakeSupport(SupportMeta.Offline.copy(photosEnabled = true, maxPhotos = 3, maxPhotoBytes = 100)) - support.photoIds = { index -> if (index == 2) null else "p$index" } + support.photoIds = { index -> if (index == 2) PhotoUpload.Failed else PhotoUpload.Uploaded("p$index") } val vm = viewModel(support) advanceUntilIdle() vm.onPhotosPicked(listOf(Uri.parse("content://a"), Uri.parse("content://b"))) @@ -331,23 +337,149 @@ class FeedbackViewModelTest { val support = FakeSupport(SupportMeta.Offline.copy(photosEnabled = true, maxPhotos = 3, maxPhotoBytes = 100)) val vm = viewModel(support) advanceUntilIdle() - vm.onCategorySelected(FeedbackCategory.BUG) + vm.onCategorySelected(FeedbackCategory.BUG.id) vm.onMessageChanged("Never mind") vm.onPhotosPicked(listOf(Uri.parse("content://a"))) vm.onDiscardDraft() val state = vm.uiState.value - assertEquals(FeedbackCategory.FEATURE, state.category) + assertEquals("feature", state.choice.id) assertEquals("", state.message) assertTrue(state.photos.isEmpty()) assertNull(prefs.getFeedbackDraft()) } + @Test + fun an_old_draft_longer_than_the_limit_is_kept_whole_and_must_be_shortened_to_send() = runTest(testDispatcher) { + val message = "m".repeat(2000) + val steps = "s".repeat(1000) + prefs.saveFeedbackDraft(FeedbackDraft(category = "bug", message = message, email = "", stepsToReproduce = steps, attachDiagnostics = true)) + val support = FakeSupport() + val vm = viewModel(support) + advanceUntilIdle() + val joined = "$message\n\n$steps" + assertEquals(joined, vm.uiState.value.message) + assertTrue(vm.uiState.value.overLimit) + assertFalse(vm.uiState.value.canSend) + assertEquals("This is over the 2,000-character limit. Shorten it to send.", vm.uiState.value.limitMessage) + + // Nothing is cut off behind the listener's back: a category change saves it whole, typing can't grow it. + vm.onCategorySelected(FeedbackCategory.AUDIO.id) + assertEquals(joined, prefs.getFeedbackDraft()?.message) + vm.onMessageChanged(joined + "x") + assertEquals(joined, vm.uiState.value.message) + vm.onSubmit() + advanceUntilIdle() + assertTrue(support.sent.isEmpty()) + + // Shortening works, and once under the limit it can be sent. + vm.onMessageChanged(joined.dropLast(10)) + assertEquals(joined.length - 10, vm.uiState.value.message.length) + vm.onMessageChanged(message) + assertTrue(vm.uiState.value.canSend) + } + + @Test + fun a_higher_server_limit_applies_as_is() = runTest(testDispatcher) { + val vm = viewModel(FakeSupport(SupportMeta.Offline.copy(maxBody = 4000))) + advanceUntilIdle() + vm.onMessageChanged("x".repeat(3500)) + assertEquals(3500, vm.uiState.value.message.length) + assertTrue(vm.uiState.value.canSend) + vm.onMessageChanged("x".repeat(4500)) + assertEquals(4000, vm.uiState.value.message.length) + } + + @Test + fun the_draft_is_fixed_while_it_is_being_sent() = runTest(testDispatcher) { + val support = FakeSupport().apply { sendGate = CompletableDeferred() } + val vm = viewModel(support) + vm.onMessageChanged("What I meant") + vm.onSubmit() + advanceUntilIdle() + assertTrue(vm.uiState.value.isSubmitting) + + vm.onMessageChanged("What I meant, and more") + vm.onCategorySelected(FeedbackCategory.BUG.id) + vm.onAttachDiagnosticsChanged(true) + vm.onDiscardDraft() + assertEquals("What I meant", vm.uiState.value.message) + assertEquals("feature", vm.uiState.value.choice.id) + assertFalse(vm.uiState.value.attachDiagnostics) + + support.sendGate!!.complete(Unit) + advanceUntilIdle() + assertEquals("What I meant", support.sent.single().body) + assertTrue(vm.uiState.value.isSuccess) + assertNull(prefs.getFeedbackDraft()) + } + + @Test + fun the_phone_model_goes_only_with_diagnostics() = runTest(testDispatcher) { + val support = FakeSupport() + val vm = viewModel(support) + vm.onMessageChanged("An idea") + vm.onSubmit() + advanceUntilIdle() + assertNull(support.sent.single().device) + assertNull(support.sent.single().diagnostics) + + val withDiagnostics = viewModel(support) + withDiagnostics.onMessageChanged("A bug") + withDiagnostics.onAttachDiagnosticsChanged(true) + withDiagnostics.onSubmit() + advanceUntilIdle() + assertNotNull(support.sent.last().device) + } + + @Test + fun the_servers_categories_are_the_choices() = runTest(testDispatcher) { + val events = mutableListOf>>() + val restore = AnalyticsHelper.installRecordingSink(events) + try { + val support = FakeSupport(SupportMeta.Offline.copy(categories = listOf(SupportCategory("bug", "Something broke"), SupportCategory("account", "Account and sync")))) + val vm = viewModel(support) + advanceUntilIdle() + val state = vm.uiState.value + assertEquals(listOf("Something broke", "Account and sync"), state.choices.map { it.label }) + // The default "feature" isn't offered any more, so the first choice stands in. + assertEquals("bug", state.choice.id) + + vm.onCategorySelected("account") + vm.onCategorySelected("not-offered") + assertEquals("account", vm.uiState.value.choice.id) + assertEquals(FeedbackCategory.OTHER.placeholder, vm.uiState.value.choice.placeholder) + vm.onMessageChanged("Sync stopped") + vm.onSubmit() + advanceUntilIdle() + assertEquals("account", support.sent.single().category) + assertEquals("other", events.single { it.first == "feedback_sent" }.second["category"]) + } finally { + restore() + } + } + + @Test + fun photos_are_left_out_when_support_cant_take_them_and_the_listener_is_told() = runTest(testDispatcher) { + val support = FakeSupport(SupportMeta.Offline.copy(photosEnabled = true, maxPhotos = 3, maxPhotoBytes = 100)) + support.photoIds = { PhotoUpload.Unavailable } + support.result = SendResult.SentByEmailForm + val vm = viewModel(support) + advanceUntilIdle() + vm.onPhotosPicked(listOf(Uri.parse("content://a"))) + vm.onMessageChanged("See the screenshot") + vm.onSubmit() + advanceUntilIdle() + assertEquals(emptyList(), support.sent.single().photoIds) + assertTrue(vm.uiState.value.isSuccess) + assertTrue(vm.uiState.value.photosDropped) + } + @Test fun buildGitHubIssueUrl_creates_valid_URL_with_encoded_title() { val vm = viewModel(FakeSupport()) - vm.onCategorySelected(FeedbackCategory.BUG) + vm.onCategorySelected(FeedbackCategory.BUG.id) vm.onMessageChanged("Crash when clicking download button") val url = buildFeedbackGitHubIssueUrl(vm.uiState.value) @@ -359,19 +491,19 @@ class FeedbackViewModelTest { @Test fun category_selection_toggles_attachDiagnostics_default_for_bug_reports() { val vm = viewModel(FakeSupport()) - assertEquals(FeedbackCategory.FEATURE, vm.uiState.value.category) + assertEquals("feature", vm.uiState.value.choice.id) assertFalse(vm.uiState.value.attachDiagnostics) - vm.onCategorySelected(FeedbackCategory.BUG) + vm.onCategorySelected(FeedbackCategory.BUG.id) assertTrue(vm.uiState.value.attachDiagnostics) - vm.onCategorySelected(FeedbackCategory.AUDIO) + vm.onCategorySelected(FeedbackCategory.AUDIO.id) assertTrue(vm.uiState.value.attachDiagnostics) - vm.onCategorySelected(FeedbackCategory.FEATURE) + vm.onCategorySelected(FeedbackCategory.FEATURE.id) assertFalse(vm.uiState.value.attachDiagnostics) - vm.onCategorySelected(FeedbackCategory.OTHER) + vm.onCategorySelected(FeedbackCategory.OTHER.id) assertFalse(vm.uiState.value.attachDiagnostics) } } From e26d6188fab85558d3414226947719f2af4b841d Mon Sep 17 00:00:00 2001 From: Aswin C Date: Mon, 2 Nov 2026 02:46:29 +0530 Subject: [PATCH 4/8] feat(feedback): show conversations and reply notifications Feedback lists the listener's conversations, opens each with the team's replies and how it ended, and takes replies; landscape tablets show the list and the conversation side by side. A reply push lights Home's Feedback button and posts a Feedback replies notification that opens the conversation, and the app joins the push topics the server lists. Feedback keeps the mini player small, as Lore does. --- app/README.md | 1 + .../java/cx/aswin/boxlore/AppContainer.kt | 25 +- .../cx/aswin/boxlore/fcm/BoxLoreFcmService.kt | 11 + .../cx/aswin/boxlore/fcm/SupportReplies.kt | 102 ++++++ .../NavGraphSettingsDestinations.kt | 7 +- .../navigation/PushTargetRouteAllowlist.kt | 1 + .../aswin/boxlore/ui/AdaptivePlayerChrome.kt | 7 +- .../cx/aswin/boxlore/ui/BoxLoreAppRoot.kt | 4 +- .../PushTargetRouteAllowlistTest.kt | 8 + .../ui/AdaptivePlayerChromeControllerTest.kt | 13 + core/designsystem/README.md | 1 + .../components/TopBarUtilityActions.kt | 56 ++- .../aswin/boxlore/core/network/SupportApi.kt | 29 ++ .../core/network/model/SupportModels.kt | 53 +++ core/support/README.md | 14 + .../core/support/SupportConversations.kt | 110 ++++++ .../boxlore/core/support/SupportRepository.kt | 136 +++++++- .../core/support/SupportConversationsTest.kt | 166 +++++++++ .../components/TopBarUtilityActionsTest.kt | 21 +- feature/settings/README.md | 6 + feature/settings/build.gradle.kts | 1 + .../settings/feedback/FeedbackListScreen.kt | 225 ++++++++++++ .../settings/feedback/FeedbackPaneRoutes.kt | 156 +++++++++ .../settings/feedback/FeedbackPanes.kt | 95 +++++ .../settings/feedback/FeedbackScreen.kt | 91 +++-- .../settings/feedback/FeedbackThreadScreen.kt | 328 ++++++++++++++++++ .../feedback/FeedbackThreadViewModel.kt | 156 +++++++++ .../settings/feedback/FeedbackViewModel.kt | 2 + .../FeedbackConversationsAdaptiveTest.kt | 201 +++++++++++ .../settings/feedback/FeedbackPanesTest.kt | 63 ++++ .../feedback/FeedbackThreadViewModelTest.kt | 166 +++++++++ .../feedback/FeedbackViewModelTest.kt | 1 + release-notes/upcoming.md | 2 +- 33 files changed, 2200 insertions(+), 58 deletions(-) create mode 100644 app/src/main/java/cx/aswin/boxlore/fcm/SupportReplies.kt create mode 100644 core/support/src/main/java/cx/aswin/boxlore/core/support/SupportConversations.kt create mode 100644 core/support/src/test/java/cx/aswin/boxlore/core/support/SupportConversationsTest.kt create mode 100644 feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackListScreen.kt create mode 100644 feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackPaneRoutes.kt create mode 100644 feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackPanes.kt create mode 100644 feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackThreadScreen.kt create mode 100644 feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackThreadViewModel.kt create mode 100644 feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackConversationsAdaptiveTest.kt create mode 100644 feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackPanesTest.kt create mode 100644 feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackThreadViewModelTest.kt diff --git a/app/README.md b/app/README.md index e030b6a4e..8b98b8573 100644 --- a/app/README.md +++ b/app/README.md @@ -50,6 +50,7 @@ The application module owns the Android app shell: `BoxLoreApplication`, `MainAc - Authentication & Deep Links: `MainActivity` handles incoming App Link intents for `https://boxlore.in/auth` (and `https://aswin.cx/boxlore/auth` from links sent by older builds) to complete passwordless email link sign-in via `authRepository.signInWithEmailLink`. It prevents one-time link replay across activity recreation by nullifying intent data and tracking consumed links, and prompts for the user's email via `CrossDeviceEmailDialog` if a magic link is opened cross-device without local pending auth state. Sign-in goes through `AppContainer.emailLinkSignIn` instead of toasts: each new link raises an Account page request, and `BoxLoreAppRoot` opens Settings on Account & sync (`ACCOUNT_PAGE_ROUTE`, unless `isAccountPage` says it is already showing; before onboarding finishes Settings shows the onboarding version), where the page shows Signing you in, then the first sync, or why the link failed. `accountPageRequestAction` decides each request (ignore one already handled, including across recreation, since the handled value is saved with `rememberSaveable`; only mark it handled when Account & sync is already showing; otherwise open it), and `AccountPageRouteTest` covers it and the route check. `BoxLoreApplication` configures `NetworkModule.authenticator` with `FirebaseAuthAuthenticator` at startup to automatically refresh STS tokens on HTTP 401. - Install attribution: `AppContainer` wires `InstallReferrerManager.onInstallReferrerResolved` → analytics person properties (`install_channel`). Catalog stays free of `:core:analytics`. - FCM (`BoxLoreFcmService` + `NewEpisodeFcmLogic`) owns notification_received / tap extras (`notification_type`, podcast/episode ids for snake+camel keys). Push and episode notifications use bounded slot allocation (16 bounded slots for new episodes, 4 for announcements) with normalized intents (`Intent.ACTION_VIEW`, component set to `MainActivity`, `data = null`, and routes passed in `target_route`) so `Intent.filterEquals()` returns `true` across notifications. This ensures Android's `system_server` reuses `PendingIntentRecord`s via `FLAG_UPDATE_CURRENT` instead of leaking up to the 1,000 UID quota. PendingIntent creations are guarded with `try-catch (e: SecurityException)` falling back to posting notifications without `contentIntent` if quota was exhausted prior to reboot. Download scheduling and notification display fail independently. Payloads without a usable episode ID initially open the podcast page; durable hydration replaces that route with the exact local episode when matched. +- **Feedback replies (support conversations).** `AppContainer.supportRepository` keeps its local state in SharedPreferences `boxlore_support` (`fcm/SupportPrefsStore`: whether the phone has conversations, a reply waiting, the push topics it joined, the last check). After every successful refresh `SupportPushTopicSync` joins and leaves FCM topics to match `meta.pushTopics` (none without conversations), recording only what Firebase confirmed. A `support_reply` data push marks a reply waiting (Home's Feedback button lights up through `LocalFeedbackReplyWaiting`) and posts a notification in the **Feedback replies** channel (`boxlore_feedback_replies_v1`, one of 50 slots per conversation) whose tap opens `feedback?thread=` (allowed by `PushTargetRouteAllowlist`; an id that isn't 24 hex characters opens Feedback). On every app start `checkSupportReplies` refreshes at most every 30 minutes, and only for phones with conversations. The Feedback route keeps the mini player in its small form, as Lore does (`keepsCompactPlayer`). - Library backup/import analytics (`trackBackupRestoreResult`, import failed) use allowlisted error codes from `LibraryBackupAnalyticsErrors` — never raw exception text. - `MainActivity` / `BoxLoreAppRoot` own deep-link and session-restore analytics at the shell layer. - `BoxLoreAppRoot` collects the default-off miniplayer seek preference from `:core:prefs` and supplies the same value to Appearance settings and `PlayerSheetLayout`. Appearance callbacks persist it without a feature-to-feature dependency. diff --git a/app/src/main/java/cx/aswin/boxlore/AppContainer.kt b/app/src/main/java/cx/aswin/boxlore/AppContainer.kt index 2ee17d75b..eb5ec0afb 100644 --- a/app/src/main/java/cx/aswin/boxlore/AppContainer.kt +++ b/app/src/main/java/cx/aswin/boxlore/AppContainer.kt @@ -77,8 +77,12 @@ import cx.aswin.boxlore.core.rss.RenumberedShows import cx.aswin.boxlore.core.rss.RssPodcastRepository import cx.aswin.boxlore.core.rss.SharedPreferencesRenumberedShows import cx.aswin.boxlore.core.rss.ports.DownloadCacheRelinker +import cx.aswin.boxlore.core.support.SupportLocalStore +import cx.aswin.boxlore.core.support.SupportPushTopics import cx.aswin.boxlore.core.support.SupportRepository import cx.aswin.boxlore.fcm.PodcastNotificationRegistrationWorker +import cx.aswin.boxlore.fcm.SupportPrefsStore +import cx.aswin.boxlore.fcm.SupportPushTopicSync import cx.aswin.boxlore.share.EpisodeShareRegistrationWorker import cx.aswin.boxlore.sync.AnalyticsFeedTelemetry import cx.aswin.boxlore.sync.CloudSyncTriggerCoordinator @@ -89,6 +93,7 @@ import cx.aswin.boxlore.sync.SavedKillSwitch import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch /** @@ -368,14 +373,28 @@ class AppContainer( val boxcastPrefs: BoxcastPrefs by lazy { BoxcastPrefs(appContext) } - /** Feedback conversations; falls back to the old one-way form while `/support` is unavailable. */ + private val supportLocalStore: SupportLocalStore by lazy { SupportPrefsStore(appContext) } + + /** + * Feedback conversations; falls back to the old one-way form while `/support` is unavailable. + * After every successful refresh the reply-push topics are brought in line with the server's. + */ val supportRepository: SupportRepository by lazy { SupportRepository( api = podcastRepository.supportApi, legacySubmit = { category, message, version, diagnostics, logs -> podcastRepository.submitFeedback(category, message, version, null, diagnostics, logs) }, - ) + local = supportLocalStore, + ).also { repository -> + val topicSync = SupportPushTopicSync(repository, SupportPushTopics(supportLocalStore)) + syncScope.launch { repository.threads.filterNotNull().collect { topicSync.sync() } } + } + } + + /** On each app start: look for replies a push missed, for phones with conversations, at most every 30 minutes. */ + fun checkSupportReplies() { + syncScope.launch { runCatching { supportRepository.refreshIfDue(SUPPORT_CHECK_INTERVAL_MS) } } } override val deviceIdentityPort: DeviceIdentityPort by lazy { @@ -781,3 +800,5 @@ class AppContainer( ) } } + +private const val SUPPORT_CHECK_INTERVAL_MS = 30 * 60_000L diff --git a/app/src/main/java/cx/aswin/boxlore/fcm/BoxLoreFcmService.kt b/app/src/main/java/cx/aswin/boxlore/fcm/BoxLoreFcmService.kt index 0d25b2912..c4c11fdfa 100644 --- a/app/src/main/java/cx/aswin/boxlore/fcm/BoxLoreFcmService.kt +++ b/app/src/main/java/cx/aswin/boxlore/fcm/BoxLoreFcmService.kt @@ -52,6 +52,10 @@ class BoxLoreFcmService : FirebaseMessagingService() { handleNewEpisodeMessage(data, message.priority == RemoteMessage.PRIORITY_HIGH) return } + if (type == "support_reply") { + handleSupportReply(data) + return + } val parsed = FcmPayloadParser.parse(data) if (applicationContext.shouldSuppressAnnouncement(parsed.toAnnouncement())) return @@ -74,6 +78,13 @@ class BoxLoreFcmService : FirebaseMessagingService() { } } + /** The boxlore team replied: Home shows it, and a notification opens the conversation. */ + private fun handleSupportReply(data: Map) { + runCatching { (application as BoxLoreApplication).container.supportRepository.onReplyPushed() } + .onFailure { android.util.Log.w("BoxLoreFcmService", "Reply not recorded", it) } + SupportReplyNotifications.show(this, data["threadId"], data["title"], data["body"]) + } + private fun handleNewEpisodeMessage(data: Map, highPriority: Boolean) { val podcastId = FcmPayloadParser.podcastId(data) ?: return val subscriptionRepository = try { diff --git a/app/src/main/java/cx/aswin/boxlore/fcm/SupportReplies.kt b/app/src/main/java/cx/aswin/boxlore/fcm/SupportReplies.kt new file mode 100644 index 000000000..dd1b26f0f --- /dev/null +++ b/app/src/main/java/cx/aswin/boxlore/fcm/SupportReplies.kt @@ -0,0 +1,102 @@ +package cx.aswin.boxlore.fcm + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.SharedPreferences +import android.content.pm.PackageManager +import android.os.Build +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import com.google.firebase.messaging.FirebaseMessaging +import cx.aswin.boxlore.core.support.SupportLocalStore +import cx.aswin.boxlore.core.support.SupportPushTopics +import cx.aswin.boxlore.core.support.SupportRepository + +/** [SupportLocalStore] in its own preferences file (`boxlore_support`). */ +class SupportPrefsStore(context: Context) : SupportLocalStore { + private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + override var hasConversations: Boolean + get() = prefs.getBoolean(KEY_HAS_CONVERSATIONS, false) + set(value) = prefs.edit().putBoolean(KEY_HAS_CONVERSATIONS, value).apply() + + override var replyWaiting: Boolean + get() = prefs.getBoolean(KEY_REPLY_WAITING, false) + set(value) = prefs.edit().putBoolean(KEY_REPLY_WAITING, value).apply() + + override var subscribedTopics: Set + get() = prefs.getStringSet(KEY_TOPICS, emptySet()).orEmpty().toSet() + set(value) = prefs.edit().putStringSet(KEY_TOPICS, value).apply() + + override var lastCheckedAt: Long + get() = prefs.getLong(KEY_LAST_CHECKED, 0L) + set(value) = prefs.edit().putLong(KEY_LAST_CHECKED, value).apply() + + companion object { + const val PREFS_NAME = "boxlore_support" + private const val KEY_HAS_CONVERSATIONS = "has_conversations" + private const val KEY_REPLY_WAITING = "reply_waiting" + private const val KEY_TOPICS = "push_topics" + private const val KEY_LAST_CHECKED = "last_checked_at" + } +} + +/** The team's reply as a notification in its own channel; a tap opens that conversation. */ +object SupportReplyNotifications { + const val CHANNEL_ID = "boxlore_feedback_replies_v1" + private const val NOTIFICATION_ID_BASE = 7_300 + private const val SLOTS = 50 + private val THREAD_ID = Regex("[0-9a-f]{24}") + + /** Where a tap goes: Feedback, on the conversation when the id is one. */ + fun routeFor(threadId: String?): String = if (threadId != null && THREAD_ID.matches(threadId)) "feedback?thread=$threadId" else "feedback" + + fun show(context: Context, threadId: String?, title: String?, body: String?) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) { + return + } + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.createNotificationChannel( + NotificationChannel(CHANNEL_ID, "Feedback replies", NotificationManager.IMPORTANCE_DEFAULT).apply { + description = "Replies from the boxlore team to your feedback" + }, + ) + val slot = Math.floorMod((threadId ?: "").hashCode(), SLOTS) + val intent = NewEpisodeFcmLogic.createNormalizedPushIntent(context, routeFor(threadId), notificationType = "support_reply") + val tap = runCatching { + PendingIntent.getActivity(context, NOTIFICATION_ID_BASE + slot, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + }.getOrNull() + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(cx.aswin.boxlore.R.drawable.ic_notification_custom) + .setColor(android.graphics.Color.parseColor("#5B5BD6")) + .setContentTitle(title?.takeIf { it.isNotBlank() } ?: "New reply to your feedback") + .setContentText(body.orEmpty()) + .setStyle(NotificationCompat.BigTextStyle().bigText(body.orEmpty())) + .setAutoCancel(true) + .apply { if (tap != null) setContentIntent(tap) } + .build() + runCatching { NotificationManagerCompat.from(context).notify(NOTIFICATION_ID_BASE + slot, notification) } + .onFailure { Log.w("SupportReplies", "Reply notification not shown", it) } + } +} + +/** + * After each successful refresh, joins and leaves reply-push topics to match the server's list + * (none for a phone without conversations), recording only what Firebase confirmed. + */ +class SupportPushTopicSync(private val repository: SupportRepository, private val topics: SupportPushTopics) { + fun sync() { + val plan = topics.planFor(repository.meta.value, repository.hasConversations) + if (plan.isEmpty) return + val messaging = runCatching { FirebaseMessaging.getInstance() }.getOrNull() ?: return + plan.subscribe.forEach { topic -> messaging.subscribeToTopic(topic).addOnSuccessListener { topics.joined(topic) } } + plan.unsubscribe.forEach { topic -> messaging.unsubscribeFromTopic(topic).addOnSuccessListener { topics.left(topic) } } + } +} 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 3d88fdd38..ac26146ba 100644 --- a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt +++ b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt @@ -394,12 +394,17 @@ private fun androidx.navigation.NavGraphBuilder.addFeedbackRoute(w: NavGraphWiri val navController = w.navController val container = w.container - composable("feedback") { + // `feedback?thread=` opens one conversation (a reply notification's tap); plain `feedback` still works. + composable( + route = "feedback?thread={thread}", + arguments = listOf(navArgument("thread") { type = NavType.StringType; nullable = true; defaultValue = null }), + ) { entry -> cx.aswin.boxlore.feature.settings.feedback.FeedbackScreen( supportRepository = container.supportRepository, boxcastPrefs = container.boxcastPrefs, onBack = { navController.popBackStack() }, bottomContentPadding = w.session.miniPlayerPadding, + openThreadId = entry.arguments?.getString("thread"), ) } } diff --git a/app/src/main/java/cx/aswin/boxlore/navigation/PushTargetRouteAllowlist.kt b/app/src/main/java/cx/aswin/boxlore/navigation/PushTargetRouteAllowlist.kt index d756cb0ad..266c16bd6 100644 --- a/app/src/main/java/cx/aswin/boxlore/navigation/PushTargetRouteAllowlist.kt +++ b/app/src/main/java/cx/aswin/boxlore/navigation/PushTargetRouteAllowlist.kt @@ -32,6 +32,7 @@ object PushTargetRouteAllowlist { "explore?", "briefing", "library/", + "feedback?", ) private val appOrWebSchemes = listOf("http://", "https://", "boxlore://", "boxcast://") diff --git a/app/src/main/java/cx/aswin/boxlore/ui/AdaptivePlayerChrome.kt b/app/src/main/java/cx/aswin/boxlore/ui/AdaptivePlayerChrome.kt index b36b3dda4..5d38c3449 100644 --- a/app/src/main/java/cx/aswin/boxlore/ui/AdaptivePlayerChrome.kt +++ b/app/src/main/java/cx/aswin/boxlore/ui/AdaptivePlayerChrome.kt @@ -76,7 +76,7 @@ internal class AdaptivePlayerChromeController(threshold: Float) { private fun updateCompactness() { // Lore borrows the compact presentation without changing ordinary browsing state. if (!playerBusy || !enabled) { - isCompact = enabled && (route == "learn" || scroll.isCompact) + isCompact = enabled && (keepsCompactPlayer(route) || scroll.isCompact) } } @@ -94,7 +94,7 @@ internal class AdaptivePlayerChromeController(threshold: Float) { val before = preAvailable.takeIf { preSource == source } preAvailable = null preSource = null - if (route == "learn") { + if (keepsCompactPlayer(route)) { scroll.endGesture() updateCompactness() return Offset.Zero @@ -118,3 +118,6 @@ internal class AdaptivePlayerChromeController(threshold: Float) { } } } + +/** Lore, and Feedback (where the bottom buttons need the room), keep the mini player in its small form. */ +internal fun keepsCompactPlayer(route: String?): Boolean = route == "learn" || route?.startsWith("feedback") == true 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 e2b1481b1..d70944e2d 100644 --- a/app/src/main/java/cx/aswin/boxlore/ui/BoxLoreAppRoot.kt +++ b/app/src/main/java/cx/aswin/boxlore/ui/BoxLoreAppRoot.kt @@ -361,6 +361,7 @@ fun BoxLoreAppRoot( val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_START) { + container.checkSupportReplies() if (SubscriptionResumeRefreshLogic.shouldRequestRefreshOnStart(skipFirstStart)) { container.subscriptionForegroundSync.requestRefresh() } @@ -556,7 +557,8 @@ fun BoxLoreAppRoot( fontRoundness = fontRoundness, ) { cx.aswin.boxlore.updates.AppUpdatesHost(application.container.appUpdates, activity) { - CompositionLocalProvider(LocalBoxloreWindowLayout provides windowLayout, cx.aswin.boxlore.core.designsystem.components.LocalArtworkRecoveryGeneration provides artworkRecoveryGeneration, LocalNavigationStyle provides navigationStyle, cx.aswin.boxlore.core.designsystem.theme.LocalArtworkNavigationColors provides artworkNavigation) { + val feedbackReplyWaiting by application.container.supportRepository.replyWaiting.collectAsState() + CompositionLocalProvider(cx.aswin.boxlore.core.designsystem.components.LocalFeedbackReplyWaiting provides feedbackReplyWaiting, LocalBoxloreWindowLayout provides windowLayout, cx.aswin.boxlore.core.designsystem.components.LocalArtworkRecoveryGeneration provides artworkRecoveryGeneration, LocalNavigationStyle provides navigationStyle, cx.aswin.boxlore.core.designsystem.theme.LocalArtworkNavigationColors provides artworkNavigation) { // Limited API sessions step up here before AI routes; invisible unless Turnstile asks for a tap. application.apiSessions?.let { sessions -> cx.aswin.boxlore.session.turnstile.TurnstileStepUpHost(sessions.turnstile, sessions.apiBaseUrl) diff --git a/app/src/test/java/cx/aswin/boxlore/navigation/PushTargetRouteAllowlistTest.kt b/app/src/test/java/cx/aswin/boxlore/navigation/PushTargetRouteAllowlistTest.kt index 278daadd9..f40e5791d 100644 --- a/app/src/test/java/cx/aswin/boxlore/navigation/PushTargetRouteAllowlistTest.kt +++ b/app/src/test/java/cx/aswin/boxlore/navigation/PushTargetRouteAllowlistTest.kt @@ -20,6 +20,14 @@ class PushTargetRouteAllowlistTest { assertTrue(PushTargetRouteAllowlist.isAllowed("https://boxlore.in/share?type=episode&id=1")) } + @Test + fun aReplyNotificationOpensItsConversation() { + assertTrue(PushTargetRouteAllowlist.isAllowed(cx.aswin.boxlore.fcm.SupportReplyNotifications.routeFor("0123456789abcdef01234567"))) + assertEquals("feedback?thread=0123456789abcdef01234567", cx.aswin.boxlore.fcm.SupportReplyNotifications.routeFor("0123456789abcdef01234567")) + assertEquals("feedback", cx.aswin.boxlore.fcm.SupportReplyNotifications.routeFor("../../etc")) + assertEquals("feedback", cx.aswin.boxlore.fcm.SupportReplyNotifications.routeFor(null)) + } + @Test fun rejectsUnknownAndEmpty() { assertFalse(PushTargetRouteAllowlist.isAllowed("")) diff --git a/app/src/test/java/cx/aswin/boxlore/ui/AdaptivePlayerChromeControllerTest.kt b/app/src/test/java/cx/aswin/boxlore/ui/AdaptivePlayerChromeControllerTest.kt index 5ab93d567..d675961ed 100644 --- a/app/src/test/java/cx/aswin/boxlore/ui/AdaptivePlayerChromeControllerTest.kt +++ b/app/src/test/java/cx/aswin/boxlore/ui/AdaptivePlayerChromeControllerTest.kt @@ -46,6 +46,19 @@ class AdaptivePlayerChromeControllerTest { assertTrue(controller.isCompact) } + @Test + fun `Feedback keeps the small player on every pane, like Lore`() { + controller.configure(true, "episode-a", "feedback?thread={thread}") + assertTrue(controller.isCompact) + browse(-200f) + assertTrue(controller.isCompact) + controller.configure(true, "episode-a", "settings") + assertFalse(controller.isCompact) + assertTrue(keepsCompactPlayer("feedback")) + assertFalse(keepsCompactPlayer("settings?page=feedback")) + assertFalse(keepsCompactPlayer(null)) + } + @Test fun `Lore history is ordinary browsing and unavailable chrome never compacts`() { controller.configure(true, "episode-a", "learn/history") diff --git a/core/designsystem/README.md b/core/designsystem/README.md index e84e3add3..0f66322f8 100644 --- a/core/designsystem/README.md +++ b/core/designsystem/README.md @@ -24,6 +24,7 @@ Owns shared Compose visual primitives: theme, typography, shapes, motion, loader - `CustomThemeSeeds` encodes primary and optional secondary/tertiary seeds as versioned `custom:1:...` values in the existing theme-brand key. `generatePersonalColorScheme` uses Material Color Utilities HCT/tonal palettes and coordinated color roles for both modes; existing authored/hex/preset resolution remains unchanged. `artworkSurfaceStyle` retains Pure surfaces while allowing artwork-tinted detail pages; `extractArtworkPalette` is the one artwork picker shared by podcast and episode pages, the full and mini player and Home's hero cards: `selectArtworkSeed` takes Palette's 16 swatches (default filter, so near-black and near-white are already gone), ignores any under 1% of the cover, outside HCT tone 12–92 or under chroma 8, and picks the best mix of coverage and colourfulness (share × 70 + chroma, capped at 64, × 0.3); covers with no meaningful colour have no palette, so their screens keep the listener's theme. `ArtworkPalette` carries that `seed` and the cover's mean colour (`average`). `artworkColorScheme` builds a page's scheme from the seed exactly as a custom theme (`generatePersonalColorScheme`), keeping Pure grounds. `loadArtworkPalette` / `cachedArtworkPalette` share one 96-entry process cache keyed by artwork URL between detail pages and the player. `LocalArtworkColorsEnabled` carries the saved opt-out. `ArtworkNavigationColors` captures the visible source palette synchronously at navigation, retains up to 80 entry snapshots and starts forward and Back handoffs with the departing colors, and invalidates them when the saved theme or artwork opt-out changes; `rememberAnimatedArtworkColors` eases all roles on one clock (`ArtworkColorTransitionMillis`, 350ms, the page slide's length) and retargets from the visible palette. `LocalArtworkLoaderColors` carries the loader palette: detail pages provide their synchronized page palette, and regular pages use the saved app colours from the first frame. `OnScreenArtwork` records which Coil memory-cache entry shows each artwork (the largest `OptimizedImage` copy), so `loadArtworkPalette` reads the cover the listener just tapped instead of fetching another copy. `ArtworkNavigationTheme` inherits the page’s exact animated accents and restrained neutral surfaces, including Pure overrides; playback chrome remains independent. `artworkPlayerColorScheme` is the player's scheme from the same seed through `generateBrandColorScheme`; `ArtworkMiniPlayerColors.fromScheme` gives the mini bar the scheme's primary container and Play its primary. `ArtworkShade` turns a seed or the app's primary into fixed-tone colours for words over a cover (a deep shade, light text, a bright button), each pair readable on any hue. `ArtworkDuotone` redraws a cover in two tones of one colour (a shadow at tone 8 and a highlight at tone 38, chroma up to 40 and 56, golds for dark yellows; `Neutral` is black and white) through a brightness colour matrix with a slight contrast lift (`colorFilter`, no extra bitmap), so `ArtworkShade`'s light text reads on any part of it. Its vivid pairs (`fromColor` / `fromPalette` with `vivid`, and `NeutralVivid`) keep a tone-10 shadow and lift the highlight to tone 80 in the colour's own hue (chroma up to 72), for covers whose words sit on their own scrim (Home's Marquee hero), so the art keeps its brightness. `artworkDeepShade` gives a deep but clearly coloured shade of a colour (its hue at a chosen Material tone with chroma up to 36; golds instead of olives), used for Lore card grounds. - Shared components including `OptimizedImage` (optional `errorContent` for blank/failed art; `showLoadingPlaceholder = false` for decorative layers such as Marquee's blurred backdrop, so the logo placeholder never shows through), loaders, `PillFilterChip` (onboarding/Explore genre pills), `BoxLoreLogo` (optional `height` for hero vs chrome sizes), `RemoveDownloadConfirmationDialog` (Material 3 confirmation dialog before removing downloaded media), player-control primitives used by UI modules, floating 3+1 navigation chrome, bottom-content clearance helpers, sleep-timer chrome, and the non-dismissible `RepairProgressPopup` for app-shell foreground repair status. - `TopBarUtilityActions` groups Feedback and Settings as 33dp-tall solid Material 3 connected action segments, adding Update when available. Settings uses the sliders (`Tune`) icon. Feedback/Settings use 20dp icons in 43dp-wide segments with press feedback; the update symbol is 22dp. Presses retain each segment's connected corners and use the existing restrained scale response without an extra rectangular ripple. The shimmer remains clipped to the Update segment. These remain action buttons rather than mutually exclusive selection controls. Foundation expands touch bounds vertically beyond the compact visual height. Screen owners retain placement, click/long-press callbacks and shortcut visibility; this component owns no scroll behavior. Home's JVM `TopBarUtilityActionsTest` covers actual pressed-corner pixels with/without Update and the click/long-press callbacks. Custom control backgrounds belong inside the press layer so the complete coloured surface contracts together with its content; native Material controls keep their own indication. +- `LocalFeedbackReplyWaiting` (the app shell provides it from `SupportRepository.replyWaiting`) lights the Feedback segment while a team reply is unread: primary-container colours and the same shimmer as Update, a 10dp primary dot ringed in the surface at its top-end corner (outside the segment's clip, test tag `FEEDBACK_REPLY_DOT_TAG`), and the label **Feedback, new reply**. With Update showing too, Update keeps the leading corner and Feedback sits in the middle. `TopBarUtilityActionsTest` covers the dot and label with and without Update, the click, and Settings' corner. - `LocalAdaptivePlayerCompactProgress` carries the app-owned player/nav morph clock. `Modifier.adaptivePlayerOverlayOffset` reads it only during placement, moving floating feature controls down by the released player row during the shared `adaptivePlayerDockingProgress` phase. Classic and absent playback retain their placement; list/content clearance remains stable. - Regular navigation tabs in both presentations use the current theme's `primary` accent and `onPrimary` icon color through `NavigationAccentColors`, including authored presets, wallpaper palettes and exact custom accents. Classic selected labels use the same accent. Floating Lore retains its multicolour aurora and container base, blended into the single moving indicator during draw rather than adding another selection surface. Its selected icon uses `onPrimary` before the aurora arrives, `onPrimaryContainer` at rest, and the more readable of those foregrounds during the solid-base blend; foreground selection follows the same indicator position without a second delayed color animation. Unselected icons retain `onSurfaceVariant`. - `icon.GenreIcons`: Rich icon palette and resolution helpers for custom podcast genre tags, subscription folders, and filter chips. Maps icon keys to Material Rounded icons (`all`, `findIcon`, `defaultGenreIcon`, `iconOrFallback`, `defaultFolderIcon`, `folderIconOrFallback`). diff --git a/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/components/TopBarUtilityActions.kt b/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/components/TopBarUtilityActions.kt index 8fd9d40b5..69edf7d3e 100644 --- a/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/components/TopBarUtilityActions.kt +++ b/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/components/TopBarUtilityActions.kt @@ -4,12 +4,16 @@ import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Feedback @@ -21,6 +25,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -29,6 +34,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import cx.aswin.boxlore.core.designsystem.theme.BoxloreClickInteraction @@ -36,6 +42,12 @@ import cx.aswin.boxlore.core.designsystem.theme.BoxloreClickOptions import cx.aswin.boxlore.core.designsystem.theme.boxloreCombinedClickable as combinedClickable import cx.aswin.boxlore.core.designsystem.theme.tabletInputFeedback +/** + * True while a reply from the boxlore team is unread; the app shell provides it. The Feedback + * segment then takes the Update segment's look (primary container and shimmer) with a dot. + */ +val LocalFeedbackReplyWaiting = compositionLocalOf { false } + /** Connected action segments; screen owners retain navigation and shortcut callbacks. */ @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -68,16 +80,24 @@ fun TopBarUtilityActions( UpdateAvailableIcon(Modifier.size(22.dp)) } } - UtilityActionSegment( - onClick = onFeedbackClick, - onLongClick = onFeedbackLongClick, - shape = if (onUpdate == null) leading else middle, - ) { - Icon( - imageVector = Icons.Rounded.Feedback, - contentDescription = "Send Feedback", - modifier = Modifier.size(20.dp), - ) + val replyWaiting = LocalFeedbackReplyWaiting.current + val feedbackShape = if (onUpdate == null) leading else middle + Box { + UtilityActionSegment( + onClick = onFeedbackClick, + onLongClick = onFeedbackLongClick, + shape = feedbackShape, + modifier = if (replyWaiting) Modifier.clip(feedbackShape).then(updateShimmerModifier()) else Modifier, + containerColor = if (replyWaiting) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = if (replyWaiting) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface, + ) { + Icon( + imageVector = Icons.Rounded.Feedback, + contentDescription = if (replyWaiting) "Feedback, new reply" else "Send Feedback", + modifier = Modifier.size(20.dp), + ) + } + if (replyWaiting) FeedbackReplyDot(Modifier.align(Alignment.TopEnd)) } UtilityActionSegment( onClick = onSettingsClick, @@ -94,6 +114,22 @@ fun TopBarUtilityActions( } } +/** The new-reply dot: outside the segment's clip, ringed with the surface so it reads on any background. */ +@Composable +private fun FeedbackReplyDot(modifier: Modifier = Modifier) { + Box( + modifier + .offset(x = 3.dp, y = (-3).dp) + .size(10.dp) + .background(MaterialTheme.colorScheme.surface, CircleShape) + .padding(2.dp) + .background(MaterialTheme.colorScheme.primary, CircleShape) + .testTag(FEEDBACK_REPLY_DOT_TAG), + ) +} + +const val FEEDBACK_REPLY_DOT_TAG = "top_bar_feedback_reply_dot" + @OptIn(ExperimentalFoundationApi::class) @Composable private fun UtilityActionSegment( diff --git a/core/network/src/main/java/cx/aswin/boxlore/core/network/SupportApi.kt b/core/network/src/main/java/cx/aswin/boxlore/core/network/SupportApi.kt index c2c784a06..ef02ee15c 100644 --- a/core/network/src/main/java/cx/aswin/boxlore/core/network/SupportApi.kt +++ b/core/network/src/main/java/cx/aswin/boxlore/core/network/SupportApi.kt @@ -1,15 +1,21 @@ package cx.aswin.boxlore.core.network +import cx.aswin.boxlore.core.network.model.SupportConversationResponse import cx.aswin.boxlore.core.network.model.SupportListResponse import cx.aswin.boxlore.core.network.model.SupportNewThreadRequest import cx.aswin.boxlore.core.network.model.SupportPhotoResponse +import cx.aswin.boxlore.core.network.model.SupportReplyRequest +import cx.aswin.boxlore.core.network.model.SupportReplyResponse import cx.aswin.boxlore.core.network.model.SupportThreadResponse import okhttp3.RequestBody +import okhttp3.ResponseBody import retrofit2.Response import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.Headers import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query private const val NO_STORE = "Cache-Control: no-store" @@ -29,6 +35,29 @@ interface SupportApi { @POST("support/threads") suspend fun startThread(@Body request: SupportNewThreadRequest): Response + /** One conversation, its newest messages and how it ended; [before] pages back from a message id. */ + @Headers(NO_STORE) + @GET("support/threads/{id}") + suspend fun conversation(@Path("id") id: String, @Query("before") before: String? = null): Response + + /** A listener reply; 409 with `ending` once the conversation is closed. */ + @Headers(NO_STORE) + @POST("support/threads/{id}/messages") + suspend fun reply(@Path("id") id: String, @Body request: SupportReplyRequest): Response + + @Headers(NO_STORE) + @POST("support/threads/{id}/read") + suspend fun markRead(@Path("id") id: String): Response + + @Headers(NO_STORE) + @POST("support/threads/{id}/solve") + suspend fun solve(@Path("id") id: String): Response + + /** A photo in a conversation, for its owner only. */ + @Headers(NO_STORE) + @GET("support/photos/{id}") + suspend fun photo(@Path("id") id: String): Response + /** Raw JPEG, PNG or WebP bytes; answers 403 while photos are switched off. */ @Headers(NO_STORE) @POST("support/photos") diff --git a/core/network/src/main/java/cx/aswin/boxlore/core/network/model/SupportModels.kt b/core/network/src/main/java/cx/aswin/boxlore/core/network/model/SupportModels.kt index 7919e76fd..c2400d02b 100644 --- a/core/network/src/main/java/cx/aswin/boxlore/core/network/model/SupportModels.kt +++ b/core/network/src/main/java/cx/aswin/boxlore/core/network/model/SupportModels.kt @@ -36,6 +36,7 @@ data class SupportPhotoLimitsDto( data class SupportThreadDto( val id: String, val category: String = "other", + val categoryLabel: String = "", val title: String = "", val status: SupportStatusDto? = null, val updatedAt: Long = 0, @@ -54,6 +55,58 @@ data class SupportStatusDto( @Serializable data class SupportThreadResponse(val thread: SupportThreadDto? = null) +/** `GET /support/threads/{id}`: the thread, a page of messages oldest first, the cursor for older ones, and the ending. */ +@Serializable +data class SupportConversationResponse( + val thread: SupportThreadDto? = null, + val messages: List = emptyList(), + val earlier: String? = null, + val ending: SupportEndingDto? = null, +) + +@Serializable +data class SupportMessageDto( + val id: String, + /** `you` or `team`. */ + val from: String = "you", + val author: String = "", + val body: String = "", + val photos: List = emptyList(), + val diagnostics: Boolean = false, + val createdAt: Long = 0, +) + +@Serializable +data class SupportPhotoRefDto(val id: String = "") + +/** How a conversation ended (`solved` or `closed`), with the server's words and the one action it offers. */ +@Serializable +data class SupportEndingDto( + val kind: String = "", + val title: String = "", + val body: String = "", + val at: Long = 0, + val action: SupportEndingActionDto? = null, +) + +@Serializable +data class SupportEndingActionDto(val kind: String = "", val label: String = "") + +@Serializable +data class SupportReplyRequest( + val body: String, + val photos: List = emptyList(), + /** Same key for a retry of the same reply, so the API saves it once. */ + val requestId: String? = null, +) + +@Serializable +data class SupportReplyResponse( + val thread: SupportThreadDto? = null, + val message: SupportMessageDto? = null, + val ending: SupportEndingDto? = null, +) + @Serializable data class SupportPhotoResponse(val id: String) diff --git a/core/support/README.md b/core/support/README.md index c8e575489..a18491c87 100644 --- a/core/support/README.md +++ b/core/support/README.md @@ -15,6 +15,15 @@ send pushes; that is the API's job. `send(OutgoingFeedback): SendResult`. Application-scoped: create it once in `AppContainer`. - `SupportMeta`, `OutgoingFeedback`, `SendResult` (`Sent`, `SentByEmailForm`, `Refused`, `Failed`). - `PhotoPrep`: shrinks a picked photo (long edge 1600 px, JPEG) before upload; `targetSize` is pure. +- Conversations (`SupportConversations.kt`): `threads` (`StateFlow?>`, null until + a refresh succeeds), `replyWaiting`, `hasConversations`, `conversation(id, before)` (a page of + messages oldest first, `earlier` to page back, `ending`), `reply(id, body, requestId)` + (`ReplyResult.Sent`, `Closed` with the server's ending, `Refused`, `Failed`), `markRead`, `solve`, + `photo(id)` (bytes, owner only), `onReplyPushed()` and `refreshIfDue(minIntervalMs)`. +- `SupportLocalStore`: what the phone remembers between launches (has conversations, a reply + waiting, joined push topics, last check); the app backs it with its own preferences file. + `SupportPushTopics` plans which topics to join and leave (`PushTopicPlan`) from a successful + refresh's `meta`. ## Internal structure @@ -68,6 +77,11 @@ Features use it; it never depends on a feature, the design system or the catalog the server's message, no connection, photo upload results and the no-store header, photos off, `PhotoPrep.targetSize`. `RequestProofTest` in `:core:network` covers the signed message and the interceptor. +- `SupportConversationsTest` covers the list and what the phone remembers, a conversation page + (messages, photos, ending, the `before` cursor, gone and failed), replies (retry key, closed with + its ending, refused, failed), reading the last unread reply, a push marking a reply waiting until a + refresh says otherwise, the start-up check (only with conversations, not more often than asked), a + first conversation remembered at once, photo bytes, and push-topic plans. ```bash ./gradlew :core:support:testDebugUnitTest diff --git a/core/support/src/main/java/cx/aswin/boxlore/core/support/SupportConversations.kt b/core/support/src/main/java/cx/aswin/boxlore/core/support/SupportConversations.kt new file mode 100644 index 000000000..e23fd85b5 --- /dev/null +++ b/core/support/src/main/java/cx/aswin/boxlore/core/support/SupportConversations.kt @@ -0,0 +1,110 @@ +package cx.aswin.boxlore.core.support + +/** One conversation in the list; every label comes from the server. */ +data class SupportThread( + val id: String, + val category: String, + val categoryLabel: String, + val title: String, + val status: SupportStatus, + val updatedAt: Long, + val unread: Boolean, + val canReply: Boolean, +) + +/** [key] is new_reply, replied, waiting, solved or closed; anything newer shows like waiting. */ +data class SupportStatus(val key: String, val label: String, val showTime: Boolean) + +data class SupportMessage( + val id: String, + val fromTeam: Boolean, + val author: String, + val body: String, + val photoIds: List, + val diagnostics: Boolean, + val createdAt: Long, +) + +/** How a conversation ended; [actionKind] is reply or new, with the server's [actionLabel]. */ +data class SupportEnding(val kind: String, val title: String, val body: String, val at: Long, val actionKind: String?, val actionLabel: String?) + +/** One page of a conversation: messages oldest first, and [earlier] to load the page before. */ +data class SupportConversation( + val thread: SupportThread, + val messages: List, + val earlier: String?, + val ending: SupportEnding?, +) + +sealed interface ConversationResult { + data class Loaded(val conversation: SupportConversation) : ConversationResult + + /** Not this phone's (or account's) conversation, or support isn't available. */ + data object Gone : ConversationResult + + data object Failed : ConversationResult +} + +sealed interface ReplyResult { + data class Sent(val thread: SupportThread?, val message: SupportMessage?) : ReplyResult + + /** The team closed the conversation; [ending] says what to do instead. */ + data class Closed(val ending: SupportEnding?) : ReplyResult + + /** Today's limit, or similar; [message] comes from the server. */ + data class Refused(val message: String) : ReplyResult + + data object Failed : ReplyResult +} + +/** + * What this phone remembers about support between launches: whether it has conversations + * (so Feedback opens on the list and the app checks for replies at all), whether a reply is + * waiting (set by a push, so Home shows it without asking the server), and the push topics it is + * subscribed to. The app backs it with its own preferences file. + */ +interface SupportLocalStore { + var hasConversations: Boolean + var replyWaiting: Boolean + var subscribedTopics: Set + var lastCheckedAt: Long + + /** For tests and before the app wires a real one. */ + class InMemory : SupportLocalStore { + override var hasConversations = false + override var replyWaiting = false + override var subscribedTopics: Set = emptySet() + override var lastCheckedAt = 0L + } +} + +/** Which push topics to join and leave to match the server's list. */ +data class PushTopicPlan(val subscribe: Set, val unsubscribe: Set) { + val isEmpty: Boolean get() = subscribe.isEmpty() && unsubscribe.isEmpty() + + companion object { + fun between(subscribed: Set, wanted: Collection): PushTopicPlan { + val target = wanted.toSet() + return PushTopicPlan(subscribe = target - subscribed, unsubscribe = subscribed - target) + } + } +} + +/** + * Keeps this phone's reply-push subscriptions in line with the server's list. Use it only with a + * `meta` from a successful refresh: an offline `meta` has no topics and would leave them all. + * A phone without conversations needs none. + */ +class SupportPushTopics(private val local: SupportLocalStore) { + fun planFor(meta: SupportMeta, hasConversations: Boolean): PushTopicPlan = + PushTopicPlan.between(local.subscribedTopics, if (hasConversations) meta.pushTopics else emptyList()) + + /** Records what Firebase confirmed. */ + fun joined(topic: String) { + local.subscribedTopics = local.subscribedTopics + topic + } + + fun left(topic: String) { + local.subscribedTopics = local.subscribedTopics - topic + } +} diff --git a/core/support/src/main/java/cx/aswin/boxlore/core/support/SupportRepository.kt b/core/support/src/main/java/cx/aswin/boxlore/core/support/SupportRepository.kt index 84dd5f3b5..88524f2a0 100644 --- a/core/support/src/main/java/cx/aswin/boxlore/core/support/SupportRepository.kt +++ b/core/support/src/main/java/cx/aswin/boxlore/core/support/SupportRepository.kt @@ -3,7 +3,12 @@ package cx.aswin.boxlore.core.support import cx.aswin.boxlore.core.network.SupportApi import cx.aswin.boxlore.core.network.model.SupportErrorResponse import cx.aswin.boxlore.core.network.model.SupportListResponse +import cx.aswin.boxlore.core.network.model.SupportEndingDto +import cx.aswin.boxlore.core.network.model.SupportMessageDto import cx.aswin.boxlore.core.network.model.SupportNewThreadRequest +import cx.aswin.boxlore.core.network.model.SupportReplyRequest +import cx.aswin.boxlore.core.network.model.SupportReplyResponse +import cx.aswin.boxlore.core.network.model.SupportThreadDto import java.io.IOException import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher @@ -27,13 +32,26 @@ class SupportRepository( private val api: SupportApi, private val legacySubmit: suspend (category: String, message: String, appVersion: String, diagnostics: String?, logs: String?) -> Boolean, private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val local: SupportLocalStore = SupportLocalStore.InMemory(), + private val clock: () -> Long = System::currentTimeMillis, ) { private val _meta = MutableStateFlow(SupportMeta.Offline) + private val _threads = MutableStateFlow?>(null) + private val _replyWaiting = MutableStateFlow(local.replyWaiting) /** The last known switches and limits; [SupportMeta.Offline] until [refresh] succeeds. */ val meta: StateFlow = _meta.asStateFlow() - /** Reads the switches (and the conversation list, used from the next app update on). */ + /** This phone's conversations, newest first; null until [refresh] succeeds. */ + val threads: StateFlow?> = _threads.asStateFlow() + + /** A team reply hasn't been read yet: Home's Feedback button shows it. */ + val replyWaiting: StateFlow = _replyWaiting.asStateFlow() + + /** Whether this phone has (or had, last time it looked) conversations: Feedback then opens on the list. */ + val hasConversations: Boolean get() = _threads.value?.isNotEmpty() ?: local.hasConversations + + /** Reads the switches and this phone's conversations. */ suspend fun refresh(): SupportMeta? = withContext(ioDispatcher) { val response = call { api.threads() } ?: return@withContext null if (!response.isSuccessful) { @@ -41,11 +59,86 @@ class SupportRepository( if (usesOldForm(response)) _meta.value = SupportMeta.Offline return@withContext null } - val meta = response.body()?.toMeta() ?: return@withContext null + val body = response.body() ?: return@withContext null + val meta = body.toMeta() ?: return@withContext null _meta.value = meta + val threads = body.threads.map { it.toThread() } + _threads.value = threads + local.hasConversations = threads.isNotEmpty() + local.lastCheckedAt = clock() + setReplyWaiting(threads.any { it.unread }) meta } + /** + * [refresh] for a phone that has conversations, at most once per [minIntervalMs], so the app + * can look for replies a push missed without every phone asking the server on every launch. + */ + suspend fun refreshIfDue(minIntervalMs: Long): SupportMeta? { + if (!local.hasConversations || clock() - local.lastCheckedAt < minIntervalMs) return null + return refresh() + } + + /** A reply push arrived: show it on Home until the conversation is read. */ + fun onReplyPushed() { + local.hasConversations = true + setReplyWaiting(true) + } + + private fun setReplyWaiting(waiting: Boolean) { + local.replyWaiting = waiting + _replyWaiting.value = waiting + } + + /** One conversation, newest messages first in view; [before] loads the page older than that message. */ + suspend fun conversation(id: String, before: String? = null): ConversationResult = withContext(ioDispatcher) { + val response = call { api.conversation(id, before) } ?: return@withContext ConversationResult.Failed + val body = response.body() + val thread = body?.thread?.toThread() + when { + response.isSuccessful && thread != null -> ConversationResult.Loaded( + SupportConversation(thread, body.messages.map { it.toMessage() }, body.earlier, body.ending?.toEnding()), + ) + response.code() == HTTP_NOT_FOUND || usesOldForm(response) -> ConversationResult.Gone + else -> ConversationResult.Failed + } + } + + /** A reply in a conversation; [requestId] is the retry key, as for new messages. */ + suspend fun reply(id: String, body: String, requestId: String?, photoIds: List = emptyList()): ReplyResult = withContext(ioDispatcher) { + val response = call { api.reply(id, SupportReplyRequest(body = body, photos = photoIds, requestId = requestId)) } ?: return@withContext ReplyResult.Failed + val reply = response.body() + when { + response.isSuccessful -> ReplyResult.Sent(reply?.thread?.toThread(), reply?.message?.toMessage()) + response.code() == HTTP_CONFLICT -> ReplyResult.Closed(response.errorBody()?.ending()?.toEnding()) + response.code() == HTTP_TOO_MANY -> ReplyResult.Refused(response.errorBody()?.message() ?: DEFAULT_REFUSAL) + else -> ReplyResult.Failed + } + } + + /** The team's reply was seen: the list and Home stop showing it. */ + suspend fun markRead(id: String): Boolean = withContext(ioDispatcher) { + val ok = call { api.markRead(id) }?.isSuccessful == true + if (ok) { + val threads = _threads.value?.map { if (it.id == id) it.copy(unread = false) else it } + _threads.value = threads + setReplyWaiting(threads?.any { it.unread } == true) + } + ok + } + + /** The listener marks an open conversation solved. */ + suspend fun solve(id: String): Boolean = withContext(ioDispatcher) { + call { api.solve(id) }?.isSuccessful == true + } + + /** A photo's bytes from a conversation (owner only), or null. */ + suspend fun photo(id: String): ByteArray? = withContext(ioDispatcher) { + val response = call { api.photo(id) } ?: return@withContext null + if (!response.isSuccessful) return@withContext null + runCatching { response.body()?.bytes() }.getOrNull() + } + /** Uploads one prepared photo. [PhotoUpload.Unavailable] when photos are off or support isn't there. */ suspend fun uploadPhoto(bytes: ByteArray, mimeType: String): PhotoUpload = withContext(ioDispatcher) { if (!_meta.value.photosEnabled) return@withContext PhotoUpload.Unavailable @@ -72,7 +165,10 @@ class SupportRepository( ) val response = call { api.startThread(request) } ?: return@withContext SendResult.Failed when { - response.isSuccessful -> response.body()?.thread?.id?.let { SendResult.Sent(it) } ?: SendResult.Failed + response.isSuccessful -> response.body()?.thread?.id?.let { + local.hasConversations = true + SendResult.Sent(it) + } ?: SendResult.Failed usesOldForm(response) -> sendThroughOldForm(feedback) response.code() == HTTP_CONFLICT || response.code() == HTTP_TOO_MANY -> SendResult.Refused( response.errorBody()?.message() ?: DEFAULT_REFUSAL, @@ -131,6 +227,10 @@ class SupportRepository( runCatching { errorJson.decodeFromString(SupportErrorResponse.serializer(), body.source().peek().readUtf8()) }.getOrNull() } + private fun okhttp3.ResponseBody.ending(): SupportEndingDto? = runCatching { + errorJson.decodeFromString(SupportReplyResponse.serializer(), string()).ending + }.getOrNull() + private fun okhttp3.ResponseBody.message(): String? = runCatching { errorJson.decodeFromString(SupportErrorResponse.serializer(), string()).message?.takeIf { it.isNotBlank() } }.getOrNull() @@ -164,3 +264,33 @@ private val CATEGORY_ID = Regex("[a-z0-9_]{1,32}") private val TOPIC = Regex("[A-Za-z0-9\\-_.~%]{1,900}") private const val MAX_PHOTOS_CEILING = 10 private const val MAX_BODY_CEILING = 10_000 + +internal fun SupportThreadDto.toThread(): SupportThread = SupportThread( + id = id, + category = category, + categoryLabel = categoryLabel.ifBlank { "Feedback" }, + title = title.ifBlank { "Feedback" }, + status = SupportStatus(key = status?.key ?: "waiting", label = status?.label.orEmpty(), showTime = status?.showTime ?: true), + updatedAt = updatedAt, + unread = unread, + canReply = canReply, +) + +internal fun SupportMessageDto.toMessage(): SupportMessage = SupportMessage( + id = id, + fromTeam = from == "team", + author = author, + body = body, + photoIds = photos.map { it.id }.filter { it.isNotBlank() }, + diagnostics = diagnostics, + createdAt = createdAt, +) + +internal fun SupportEndingDto.toEnding(): SupportEnding = SupportEnding( + kind = kind, + title = title, + body = body, + at = at, + actionKind = action?.kind?.takeIf { it.isNotBlank() }, + actionLabel = action?.label?.takeIf { it.isNotBlank() }, +) diff --git a/core/support/src/test/java/cx/aswin/boxlore/core/support/SupportConversationsTest.kt b/core/support/src/test/java/cx/aswin/boxlore/core/support/SupportConversationsTest.kt new file mode 100644 index 000000000..220bb220f --- /dev/null +++ b/core/support/src/test/java/cx/aswin/boxlore/core/support/SupportConversationsTest.kt @@ -0,0 +1,166 @@ +package cx.aswin.boxlore.core.support + +import cx.aswin.boxlore.core.network.NetworkModule +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +/** Conversations, replies and what the phone remembers about them, against the real API shapes. */ +class SupportConversationsTest { + private lateinit var server: MockWebServer + private val local = SupportLocalStore.InMemory() + private var now = 1_800_000_000_000L + + private fun repository() = SupportRepository( + api = NetworkModule.createSupportApi(server.url("/").toString(), OkHttpClient()), + legacySubmit = { _, _, _, _, _ -> true }, + ioDispatcher = Dispatchers.Unconfined, + local = local, + clock = { now }, + ) + + @BeforeEach + fun setUp() { + server = MockWebServer().apply { start() } + } + + @AfterEach + fun tearDown() { + runCatching { server.shutdown() } + } + + private val list = """{"meta":{"pushTopics":["support_a"]},"threads":[ + {"id":"a1b2c3d4e5f6a1b2c3d4e5f6","category":"bug","categoryLabel":"Bug report","title":"It stops","status":{"key":"new_reply","label":"New reply","showTime":true},"updatedAt":5,"unread":true,"canReply":true}, + {"id":"b1b2c3d4e5f6a1b2c3d4e5f6","category":"feature","categoryLabel":"Feature idea","title":"Fade out","status":{"key":"waiting","label":"Sent · Waiting for a reply","showTime":false},"updatedAt":4,"unread":false,"canReply":true}]}""" + + @Test + fun `a refresh lists the conversations and remembers there are some, and a waiting reply`() = runTest { + server.enqueue(MockResponse().setBody(list)) + val repo = repository() + assertNull(repo.threads.value) + assertFalse(repo.hasConversations) + repo.refresh() + assertEquals(listOf("It stops", "Fade out"), repo.threads.value?.map { it.title }) + assertEquals("Bug report", repo.threads.value?.first()?.categoryLabel) + assertTrue(repo.hasConversations) + assertTrue(local.hasConversations) + assertTrue(repo.replyWaiting.value) + assertEquals(now, local.lastCheckedAt) + } + + @Test + fun `opening a conversation reads its messages, ending and the cursor for older ones`() = runTest { + server.enqueue( + MockResponse().setBody( + """{"thread":{"id":"a1b2c3d4e5f6a1b2c3d4e5f6","categoryLabel":"Bug report","title":"It stops","status":{"key":"solved","label":"Solved"},"canReply":true}, + "messages":[{"id":"m1","from":"you","author":"You","body":"It stops","photos":[{"id":"p1","path":"/support/photos/p1"}],"diagnostics":true,"createdAt":1}, + {"id":"m2","from":"team","author":"boxlore team","body":"Fixed","createdAt":2}], + "earlier":"m0","ending":{"kind":"solved","title":"Solved","body":"Still happening? Reply.","at":3,"action":{"kind":"reply","label":"Reply"}}}""", + ), + ) + server.enqueue(MockResponse().setResponseCode(404).setBody("""{"error":"not_found"}""")) + server.enqueue(MockResponse().setResponseCode(500)) + val repo = repository() + val loaded = repo.conversation("a1b2c3d4e5f6a1b2c3d4e5f6", before = "m9") as ConversationResult.Loaded + val c = loaded.conversation + assertEquals(listOf(false, true), c.messages.map { it.fromTeam }) + assertEquals(listOf("p1"), c.messages.first().photoIds) + assertEquals("m0", c.earlier) + assertEquals(SupportEnding("solved", "Solved", "Still happening? Reply.", 3, "reply", "Reply"), c.ending) + assertEquals("/support/threads/a1b2c3d4e5f6a1b2c3d4e5f6?before=m9", server.takeRequest().path) + assertEquals(ConversationResult.Gone, repo.conversation("x")) + assertEquals(ConversationResult.Failed, repo.conversation("x")) + } + + @Test + fun `replies are sent with their retry key, a closed conversation and a limit say why`() = runTest { + server.enqueue(MockResponse().setResponseCode(201).setBody("""{"message":{"id":"m3","from":"you","body":"Still"}}""")) + server.enqueue(MockResponse().setResponseCode(409).setBody("""{"error":"closed","ending":{"kind":"closed","title":"This conversation is closed","action":{"kind":"new","label":"Send new feedback"}}}""")) + server.enqueue(MockResponse().setResponseCode(429).setBody("""{"error":"limit","message":"Try again tomorrow."}""")) + server.enqueue(MockResponse().setResponseCode(503)) + val repo = repository() + val sent = repo.reply("t1", "Still", requestId = "retry-key-0001") as ReplyResult.Sent + assertEquals("m3", sent.message?.id) + assertTrue(server.takeRequest().body.readUtf8().contains("\"requestId\":\"retry-key-0001\"")) + assertEquals("Send new feedback", (repo.reply("t1", "Hello?", null) as ReplyResult.Closed).ending?.actionLabel) + assertEquals(ReplyResult.Refused("Try again tomorrow."), repo.reply("t1", "More", null)) + assertEquals(ReplyResult.Failed, repo.reply("t1", "More", null)) + } + + @Test + fun `reading the last unread reply clears the waiting flag`() = runTest { + server.enqueue(MockResponse().setBody(list)) + server.enqueue(MockResponse().setResponseCode(204)) + val repo = repository() + repo.refresh() + assertTrue(repo.markRead("a1b2c3d4e5f6a1b2c3d4e5f6")) + assertFalse(repo.threads.value!!.first().unread) + assertFalse(repo.replyWaiting.value) + assertFalse(local.replyWaiting) + } + + @Test + fun `a push marks a reply waiting until the next refresh says otherwise`() = runTest { + val repo = repository() + repo.onReplyPushed() + assertTrue(repo.replyWaiting.value) + assertTrue(local.replyWaiting && local.hasConversations) + server.enqueue(MockResponse().setBody("""{"meta":{},"threads":[]}""")) + repo.refresh() + assertFalse(repo.replyWaiting.value) + assertFalse(repo.hasConversations) + } + + @Test + fun `the start-up check runs only for phones with conversations, and not too often`() = runTest { + val repo = repository() + assertNull(repo.refreshIfDue(30 * 60_000L)) + assertEquals(0, server.requestCount) + + local.hasConversations = true + server.enqueue(MockResponse().setBody(list)) + repo.refreshIfDue(30 * 60_000L) + assertEquals(1, server.requestCount) + now += 10 * 60_000L + assertNull(repo.refreshIfDue(30 * 60_000L)) + assertEquals(1, server.requestCount) + now += 30 * 60_000L + server.enqueue(MockResponse().setBody(list)) + repo.refreshIfDue(30 * 60_000L) + assertEquals(2, server.requestCount) + } + + @Test + fun `a first conversation is remembered at once, and photos come back as bytes`() = runTest { + server.enqueue(MockResponse().setResponseCode(201).setBody("""{"thread":{"id":"a1b2c3d4e5f6a1b2c3d4e5f6"}}""")) + server.enqueue(MockResponse().setBody("jpeg-bytes")) + server.enqueue(MockResponse().setResponseCode(404)) + val repo = repository() + repo.send(OutgoingFeedback("bug", "It stops", "0.0.30", null, null, null)) + assertTrue(local.hasConversations) + assertEquals("jpeg-bytes", repo.photo("p1")?.decodeToString()) + assertNull(repo.photo("p2")) + } + + @Test + fun `push topics follow the server's list, and a phone without conversations leaves them all`() { + val topics = SupportPushTopics(local) + val meta = SupportMeta.Offline.copy(pushTopics = listOf("support_a", "support_b")) + local.subscribedTopics = setOf("support_old", "support_a") + assertEquals(PushTopicPlan(subscribe = setOf("support_b"), unsubscribe = setOf("support_old")), topics.planFor(meta, hasConversations = true)) + topics.joined("support_b") + topics.left("support_old") + assertEquals(setOf("support_a", "support_b"), local.subscribedTopics) + assertTrue(topics.planFor(meta, hasConversations = true).isEmpty) + assertEquals(setOf("support_a", "support_b"), topics.planFor(meta, hasConversations = false).unsubscribe) + } +} diff --git a/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/TopBarUtilityActionsTest.kt b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/TopBarUtilityActionsTest.kt index b26ae6749..984ebf1d6 100644 --- a/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/TopBarUtilityActionsTest.kt +++ b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/TopBarUtilityActionsTest.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -17,9 +18,12 @@ import androidx.compose.ui.test.getUnclippedBoundsInRoot import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.longClick import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.unit.dp +import cx.aswin.boxlore.core.designsystem.components.FEEDBACK_REPLY_DOT_TAG +import cx.aswin.boxlore.core.designsystem.components.LocalFeedbackReplyWaiting import cx.aswin.boxlore.core.designsystem.components.LocalUpdateAvailableAction import cx.aswin.boxlore.core.designsystem.components.TopBarUtilityActions import cx.aswin.boxlore.core.designsystem.theme.BoxLoreTheme @@ -38,6 +42,7 @@ class TopBarUtilityActionsTest { @get:Rule val composeRule = createComposeRule() private lateinit var view: View private val actions = mutableListOf() + private val replyWaiting = mutableStateOf(false) @Test fun `press retains the outer connected corners without a rectangular highlight`() { show(update = false) @@ -88,11 +93,25 @@ class TopBarUtilityActionsTest { composeRule.waitForIdle() } + @Test fun `a waiting reply lights the Feedback segment with a dot, next to an update too`() { + show(update = true) + composeRule.onNodeWithTag(FEEDBACK_REPLY_DOT_TAG).assertDoesNotExist() + composeRule.onNodeWithContentDescription("Send Feedback").assertExists() + + composeRule.runOnIdle { replyWaiting.value = true } + composeRule.waitForIdle() + composeRule.onNodeWithTag(FEEDBACK_REPLY_DOT_TAG).assertExists() + composeRule.onNodeWithContentDescription("Update available").assertExists() + composeRule.onNodeWithContentDescription("Feedback, new reply").performClick() + assertEquals(listOf("feedback"), actions) + checkCornerWhilePressed("Settings", trailing = true) + } + private fun show(update: Boolean) { val updateAction: (() -> Unit)? = if (update) ({ actions.add("update") }) else null composeRule.setContent { view = LocalView.current - CompositionLocalProvider(LocalUpdateAvailableAction provides updateAction) { + CompositionLocalProvider(LocalUpdateAvailableAction provides updateAction, LocalFeedbackReplyWaiting provides replyWaiting.value) { BoxLoreTheme(dynamicColor = false) { Box(Modifier.fillMaxSize().background(Color.Magenta).padding(24.dp)) { TopBarUtilityActions( diff --git a/feature/settings/README.md b/feature/settings/README.md index 376506eb9..19f32ab73 100644 --- a/feature/settings/README.md +++ b/feature/settings/README.md @@ -22,6 +22,7 @@ Owns the unified Settings hub, category pages (Account, Sync & Backups, Appearan - `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. `FeedbackScreen(supportRepository, boxcastPrefs, onBack, bottomContentPadding)` takes `SupportRepository` from `:core:support` (app wiring passes the mini player clearance). - **Feedback, redesigned 2026-11-02 (support conversations, phase 2).** "Share your thoughts" opens straight on the form: bold circular type pills (`FeedbackTypePills`), a roomy message card that takes the free height, photos (only while the server's `meta.photos` switch is on; picked with the system photo picker, no storage permission, downscaled to 1600 px JPEG by `PhotoPrep`; the row scrolls sideways so every photo and its Remove stay reachable on narrow phones), an Include device diagnostics row, **Send feedback** at the bottom and a **How we handle feedback** link to the privacy page. The pills are the server's `meta.categories` (id and label, shown as sent); the app's own four (Feature idea, Bug report, Audio / Stream, General) are the offline list and give icons and hints for the ids they share (`FeedbackChoice`), and an id the server stops offering falls back to its first. The message limit is the server's `maxBody` as is; typing stops there, and an older draft past it (its steps now join the message) is kept whole and can only get shorter, with the counter in red and "This is over the …-character limit. Shorten it to send." until it fits (`limitedMessage`). The phone model goes only with diagnostics on. While a message is sending its draft is locked (field read-only, pills, switch and photos disabled), so what was sent is what gets cleared. Windows shorter than 600dp scroll with a 220dp message card. While typing (message focused and the keyboard up) the pills fold into one pill in the header and a slim bar with photos, diagnostics and **Send** rides on the keyboard, which replaces the bottom chrome padding (`isTypingLayout`, `feedbackBottomPadding`). It is one layout throughout: the keyboard or a height change only changes what sits around the message field, so the field keeps its focus, cursor and the text being composed. The top-bar menu has Preview diagnostics, Rate on Play, Report on GitHub and Discard draft. The email and steps fields are gone. Send is disabled while the server pauses new messages (`canStartNew`), with the server's notice or `PAUSED_NOTICE`; a refusal (today's limit) shows the server's words and keeps the draft. Each send carries a retry key (`RetryKeys`): sending the same message again reuses it, an edited message gets a new one, so a send whose answer was lost isn't saved twice. When support can't take photos (it is unavailable), the message goes without them. After sending, `FeedbackSentScreen` shows Echo's `feedback-sent` sequence (magnifier, then rest), "Feedback sent" and, when the message became a conversation and notifications are off, a **Know when the team replies** card (`canAskForNotifications`: Android's prompt while it will still show, otherwise the app's notification settings; re-checked on resume, **Not now** hides it). A message that fell back to the old one-way form (`SendResult.SentByEmailForm`) gets neither the reply promise nor the card, and left-out photos are mentioned (`feedbackSentBody`). +- **Feedback conversations (support conversations, phase 3).** `FeedbackScreen(..., openThreadId)` opens on one conversation for a reply notification (`feedback?thread=`), on the list once the phone has conversations, and on "Share your thoughts" otherwise (`initialFeedbackPane`; the pane is saved with `FeedbackPaneSaver`). The list (`FeedbackListScreen`) shows "Your feedback" in one card, newest first: an unread dot and bold title for a new reply, the server's status label with its time ("11:02", "Yesterday", "28 Oct"; `feedbackListTime`), a check for Solved and a lock with a dimmed title for Closed. Rate on Play, Report on GitHub and **Send feedback** are pinned at the bottom with the list scrolling behind them; each **Send feedback** opens a fresh form (a new view-model key; the saved draft still comes back). After sending, the list refreshes at once (so the phone joins its reply-push topic before a reply can come), and Done goes to the list when the message became a conversation. A conversation (`FeedbackThreadScreen`, `FeedbackThreadViewModel`) shows the listener's messages in outlined cards and the team's in a primary-container card ("boxlore team"), with photos fetched once through the support API and "Diagnostics included"; **Show earlier messages** pages back. Opening an unread one marks it read, which clears the list's dot and Home's indicator. **Reply** opens a reply box that rides on the keyboard (the server's message limit, a retry key per reply, the draft kept on failure); **Mark solved** shows once the team has answered an open conversation. The server's ending card closes it: Solved (tertiary container, check, the server's words, Reply) or Closed (lock, the server's words, **Send new feedback**). Back walks conversation → list → out, and a new message goes back to the list when there is one (`feedbackBack`). On a landscape tablet with at least 840dp for Feedback (`feedbackUsesSplit`) the list (400dp) and the conversation sit side by side: the picked conversation, else the newest, else a new message; Back then leaves. The open conversation survives resizes and recreation in both directions. - `AutoDownloadSettingsScreen` and `SmartDownloadsSettingsScreen` under `downloads/` for download management. - `AccountSettingsPage`, `SyncAndBackupsPage`, `AppearanceSettingsPage`, `PlaybackSettingsPage`, `PrivacySettingsPage`, `LibrarySettingsPage`, `DownloadsSettingsPage`, `AboutSettingsPage`, `SupportDevelopmentPage`. - Sync & Backups export copy names only what the JSON backup writes: subscriptions, folders, history, likes and settings (`FULL_LIBRARY_EXPORT_SUMMARY`, covered by `SyncAndBackupsPageTest`). Settings copy spells the product name boxlore in lowercase, including sentence starts. @@ -80,9 +81,13 @@ src/main/java/cx/aswin/boxlore/feature/settings/ DiagnosticCollector.kt FeedbackDialogs.kt FeedbackComposeParts.kt # type pills, message card, photo row, typing bar + FeedbackListScreen.kt # "Your feedback" list and its pinned buttons + FeedbackPanes.kt # which pane shows, Back, the split rule, times FeedbackNotificationAsk.kt # notifications on/off, Android prompt or settings FeedbackScreen.kt # compose screen, typing layout, FeedbackContent switch FeedbackSentScreen.kt # Echo, thanks, notifications card, Done + FeedbackThreadScreen.kt # one conversation, its ending, the reply box + FeedbackThreadViewModel.kt # loads, pages, replies, marks read and solved FeedbackViewModel.kt LogcatCollector.kt pages/ @@ -179,6 +184,7 @@ src/main/java/cx/aswin/boxlore/feature/settings/ - `SupportEntryImpressionTest` covers one impression per view, none for a configuration restore, a new one after returning, and the real Settings hub's top-bar surface. - Not covered by JVM tests: rendering quality on real devices and fold postures. Those need a phone, tablet or foldable check. - Feedback: `FeedbackViewModelTest` covers draft restore (old steps joined), a draft past the limit kept whole until shortened, a server limit above 2,000, a conversation send (body trimmed, diagnostics, analytics without the text), the phone model only with diagnostics, the server's categories (an unknown id falls back, a new one is sent as is and counts as other in analytics), the old-form fallback without the reply promise, refusal and failure keeping the draft with a retry, blank messages, the draft locked while sending, photos ignored while switched off, de-duplicated picks up to the limit uploaded in order before the message, a failed or oversized photo sending nothing, photos left out when support can't take them, paused sending, retry keys (kept for a resend, new after an edit or a success), and Discard draft. `FeedbackLayoutPolicyTest` covers the typing-layout and keyboard-clearance rules, `limitedMessage`, when Android's notification prompt can still show, and the sent-screen copy. `FeedbackAdaptiveTransitionTest` (Robolectric) holds the view model outside composition, as the navigation entry does in the app, so it covers the screen's own state rather than the view model's lifetime. It keeps one live screen while the window crosses the 600dp width, 480dp height and 840dp split gates in both directions, portrait/landscape tablets, a short 800×360 window that scrolls, and a fold going flat → tabletop → flat, then recreates it (`StateRestorationTester`): the typed message, the picked type and a reachable, enabled Send survive, Send goes out once, and the sent screen keeps Done and the notifications card (or its dismissal) through the same walk; an old-form send never shows the card. With the keyboard played by passing its height in, the message field stays focused with its text as the keyboard opens and closes twice and the window goes short and tall, and typing continues where it was. On a 360dp phone all five photos and their Remove buttons are reachable. Not covered by JVM tests: a real keyboard's animation and composition, the photo picker, and Echo's motion; those need a phone and tablet check. Manual acceptance: on a phone, a tablet and a foldable, write a message, rotate and fold with the keyboard up and down, then send; the draft stays, the cursor stays in the field, Send stays reachable above the keyboard, and the sent screen offers notifications only while they are off. +- Feedback conversations: `FeedbackPanesTest` covers the opening pane, Back, the split gate (839/840dp, portrait, short), the saved pane and the times. `FeedbackThreadViewModelTest` covers marking an unread conversation read, gone and failed loads with a retry, earlier pages going first, a failed reply keeping its text and retry key, a refusal, a closed conversation reloading to its ending, Mark solved, the reply limit and photos fetched once. `FeedbackConversationsAdaptiveTest` (Robolectric, the real `FeedbackScreen` on a fake support API) keeps the open conversation through phone ↔ split both ways across the 840dp gate, a portrait tablet, a short window and a fold, and through recreation; Back goes to the list, then out; side by side the newest conversation shows until another is picked and an unread one is marked read; a notification's id opens its conversation; and a phone without conversations opens on Share your thoughts. Not covered by JVM tests: real photos, the reply keyboard on a device, and push delivery. - Unit tests live under `feature/settings/src/test`. - `AccountPagePresentationTest` covers the onboarding and Settings layouts, picking each sign-in method and getting back to the cards, the check-email and confirm-email screens (resend cooldown), every sync state with its action, the Sync now label at 200% text in RTL, password-only Reset password, Echo press feedback, and that every Echo pose the page uses ships in the Echo manifest with a stage reach. `AccountPageAdaptiveTest` keeps one live page while the window moves phone → 599/600dp → tablet split → 479/480dp tall → portrait tablet → phone and checks that the chosen method, the typed email and the password mode survive, in both directions, and across activity recreation (`StateRestorationTester`). It also keeps a live syncing onboarding page across the same windows: the No need to wait note stays on screen above the tiles with Continue reachable, and finishing sync mid-resize turns all three tiles done and removes the note. Tests hold Echo on its resting pose with `LocalEchoMotion provides false` so the screen can go idle. App navigation tests cover hiding restored playback during Account and onboarding and restoring normal player presentation afterward. - `AutoDownloadSettingsActionsTest` exercises the same action callbacks and background presentation used by the screen: all four episode limits, invalid-limit rejection, help opening/dismissal, both background-control visibility states, and retaining all network/charging combinations when checks are disabled and re-enabled. No device or screenshot automation is required. diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index fd3c141f6..c9a5b3576 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -88,6 +88,7 @@ dependencies { implementation(libs.googleid) testImplementation(projects.core.testing) + testImplementation(libs.retrofit) testImplementation(libs.junit) testImplementation(libs.junit.jupiter) testRuntimeOnly(libs.junit.vintage.engine) diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackListScreen.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackListScreen.kt new file mode 100644 index 000000000..b84047bff --- /dev/null +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackListScreen.kt @@ -0,0 +1,225 @@ +package cx.aswin.boxlore.feature.settings.feedback + +import android.text.format.DateFormat +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.OpenInNew +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material.icons.rounded.Lock +import androidx.compose.material.icons.rounded.Star +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledTonalButton +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.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import cx.aswin.boxlore.core.designsystem.layout.BoxloreCardFamily +import cx.aswin.boxlore.core.designsystem.layout.boxloreCardWidth +import cx.aswin.boxlore.core.designsystem.theme.ExpressivePressFeedback +import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight +import cx.aswin.boxlore.core.designsystem.theme.expressiveClickable +import cx.aswin.boxlore.core.support.SupportThread +import java.time.ZoneId +import java.util.Locale + +internal const val FEEDBACK_LIST_TAG = "feedback_list" +internal const val FEEDBACK_NEW_TAG = "feedback_new" + +/** The list's own room for its pinned buttons: two rows of buttons and their gaps. */ +private val LIST_ACTIONS_HEIGHT = 136.dp + +/** + * "Feedback": the listener's conversations in one card, newest first, with Rate on Play, + * Report on GitHub and Send feedback pinned at the bottom; the list scrolls behind them. + */ +internal class FeedbackListActions( + val onOpen: (String) -> Unit, + val onNew: () -> Unit, + val onRetry: () -> Unit, + val onRate: () -> Unit, + val onGitHub: () -> Unit, + val onBack: (() -> Unit)?, +) + +@Composable +internal fun FeedbackListScreen( + threads: List?, + loadFailed: Boolean, + selectedId: String?, + bottomContentPadding: Dp, + actions: FeedbackListActions, + modifier: Modifier = Modifier, +) { + val onBack = actions.onBack + val systemBar = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + val bottom = bottomContentPadding + systemBar + Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) { + Box(Modifier.fillMaxSize().statusBarsPadding(), contentAlignment = Alignment.TopCenter) { + Column(Modifier.fillMaxSize().boxloreCardWidth(BoxloreCardFamily.Utility)) { + Row(Modifier.fillMaxWidth().heightIn(min = 64.dp).padding(start = 4.dp, end = 8.dp), verticalAlignment = Alignment.CenterVertically) { + if (onBack != null) FeedbackIconButton(Icons.AutoMirrored.Rounded.ArrowBack, "Navigate back", onBack) else Spacer(Modifier.width(16.dp)) + Text("Feedback", style = MaterialTheme.typography.headlineMedium, modifier = Modifier.padding(start = 4.dp)) + } + Box(Modifier.weight(1f).fillMaxWidth()) { + when { + threads != null -> ThreadList(threads, selectedId, actions.onOpen, PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = bottom + LIST_ACTIONS_HEIGHT + 16.dp)) + loadFailed -> LoadFailed(actions.onRetry, Modifier.align(Alignment.Center).padding(bottom = bottom + LIST_ACTIONS_HEIGHT)) + else -> CircularProgressIndicator(Modifier.align(Alignment.Center).padding(bottom = bottom + LIST_ACTIONS_HEIGHT)) + } + ListActions(actions.onNew, actions.onRate, actions.onGitHub, Modifier.align(Alignment.BottomCenter).padding(start = 16.dp, end = 16.dp, bottom = bottom + 12.dp)) + } + } + } + } +} + +@Composable +private fun ThreadList(threads: List, selectedId: String?, onOpen: (String) -> Unit, contentPadding: PaddingValues) { + val context = LocalContext.current + val is24Hour = DateFormat.is24HourFormat(context) + val now = System.currentTimeMillis() + LazyColumn(Modifier.fillMaxSize().testTag(FEEDBACK_LIST_TAG), contentPadding = contentPadding) { + item(key = "heading") { + Text( + "Your feedback", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 4.dp, bottom = 8.dp), + ) + } + itemsIndexed(threads, key = { _, thread -> thread.id }) { index, thread -> + val first = index == 0 + val last = index == threads.lastIndex + val shape = RoundedCornerShape( + topStart = if (first) 20.dp else 0.dp, + topEnd = if (first) 20.dp else 0.dp, + bottomStart = if (last) 20.dp else 0.dp, + bottomEnd = if (last) 20.dp else 0.dp, + ) + val time = feedbackListTime(thread.updatedAt, now, ZoneId.systemDefault(), Locale.getDefault(), is24Hour) + Column(Modifier.clip(shape)) { + ThreadRow(thread, feedbackStatusLine(thread.status.label, thread.status.showTime, time), selected = thread.id == selectedId, shape = shape, onClick = { onOpen(thread.id) }) + if (!last) { + Surface(color = MaterialTheme.colorScheme.surfaceContainer) { + HorizontalDivider(Modifier.padding(start = 34.dp, end = 16.dp), color = MaterialTheme.colorScheme.outlineVariant) + } + } + } + } + } +} + +@Composable +private fun ThreadRow(thread: SupportThread, statusLine: String, selected: Boolean, shape: RoundedCornerShape, onClick: () -> Unit) { + val closed = thread.status.key == "closed" + val unread = thread.unread + val statusColor = when (thread.status.key) { + "new_reply" -> MaterialTheme.colorScheme.primary + "solved" -> MaterialTheme.colorScheme.tertiary + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + Surface( + color = if (selected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceContainer, + modifier = Modifier + .fillMaxWidth() + .expressiveClickable(shape = shape, feedback = ExpressivePressFeedback.Surface, onClick = onClick) + .semantics { contentDescription = "${thread.title}. $statusLine" }, + ) { + Row(Modifier.padding(start = 14.dp, end = 16.dp, top = 14.dp, bottom = 14.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Box(Modifier.padding(top = 8.dp).size(8.dp).then(if (unread) Modifier.background(MaterialTheme.colorScheme.primary, CircleShape) else Modifier)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + thread.title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (unread) GoogleSansWeight.semiBold else null, + color = if (closed) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + when (thread.status.key) { + "solved" -> Icon(Icons.Rounded.Check, contentDescription = null, tint = statusColor, modifier = Modifier.padding(end = 4.dp).size(16.dp)) + "closed" -> Icon(Icons.Rounded.Lock, contentDescription = null, tint = statusColor, modifier = Modifier.padding(end = 4.dp).size(14.dp)) + } + Text( + statusLine, + style = MaterialTheme.typography.bodyMedium, + color = statusColor, + fontWeight = if (unread) GoogleSansWeight.semiBold else null, + ) + } + } + } + } +} + +@Composable +private fun LoadFailed(onRetry: () -> Unit, modifier: Modifier = Modifier) { + Column(modifier.padding(horizontal = 32.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + "Couldn't load your feedback. Check your connection and try again.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + TextButton(onClick = onRetry) { Text("Try again", fontWeight = GoogleSansWeight.semiBold) } + } +} + +@Composable +private fun ListActions(onNew: () -> Unit, onRate: () -> Unit, onGitHub: () -> Unit, modifier: Modifier = Modifier) { + Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilledTonalButton(onClick = onRate, modifier = Modifier.weight(1f).height(48.dp)) { + Icon(Icons.Rounded.Star, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Rate on Play", fontWeight = GoogleSansWeight.semiBold) + } + FilledTonalButton(onClick = onGitHub, modifier = Modifier.weight(1f).height(48.dp)) { + Icon(Icons.AutoMirrored.Rounded.OpenInNew, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Report on GitHub", fontWeight = GoogleSansWeight.semiBold) + } + } + Button(onClick = onNew, shape = CircleShape, modifier = Modifier.fillMaxWidth().height(56.dp).testTag(FEEDBACK_NEW_TAG)) { + Icon(Icons.Rounded.Edit, contentDescription = null, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(10.dp)) + Text("Send feedback", style = MaterialTheme.typography.titleMedium, fontWeight = GoogleSansWeight.semiBold) + } + } +} diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackPaneRoutes.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackPaneRoutes.kt new file mode 100644 index 000000000..9bba4504e --- /dev/null +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackPaneRoutes.kt @@ -0,0 +1,156 @@ +package cx.aswin.boxlore.feature.settings.feedback + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import cx.aswin.boxlore.core.prefs.BoxcastPrefs +import cx.aswin.boxlore.core.support.SupportRepository +import cx.aswin.boxlore.core.support.SupportThread + +/* The panes behind FeedbackScreen: the side-by-side layout and the compose and conversation routes. */ + +internal val FEEDBACK_LIST_PANE_WIDTH = 400.dp + +/** One pane at a time on phones; side by side, the list keeps a conversation open: the picked one, else the newest, else a new message. */ +@Composable +internal fun FeedbackPanesLayout( + split: Boolean, + pane: FeedbackPane, + threads: List?, + list: @Composable (Modifier, String?) -> Unit, + detail: @Composable (FeedbackPane, Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + if (split) { + val shown = if (pane == FeedbackPane.List) threads?.firstOrNull()?.let { FeedbackPane.Thread(it.id) } ?: FeedbackPane.Compose else pane + Row(modifier.fillMaxSize()) { + list(Modifier.width(FEEDBACK_LIST_PANE_WIDTH).fillMaxHeight(), (shown as? FeedbackPane.Thread)?.id) + VerticalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Box(Modifier.weight(1f).fillMaxHeight()) { detail(shown, false) } + } + } else if (pane == FeedbackPane.List) { + list(modifier, null) + } else { + Box(modifier) { detail(pane, true) } + } +} + +internal class FeedbackDetailCallbacks( + val back: () -> Unit, + val leave: () -> Unit, + val newFeedback: () -> Unit, + val sent: (repliesInApp: Boolean) -> Unit, +) + +/** A conversation or a new message; without its own Back arrow (side by side) the compose arrow leaves Feedback. */ +@Composable +internal fun FeedbackDetailPane( + supportRepository: SupportRepository, + boxcastPrefs: BoxcastPrefs, + detail: FeedbackPane, + composeSession: Int, + bottomContentPadding: Dp, + callbacks: FeedbackDetailCallbacks, + showBack: Boolean, +) { + if (detail is FeedbackPane.Thread) { + FeedbackThreadRoute(supportRepository, detail.id, bottomContentPadding, onBack = callbacks.back.takeIf { showBack }, onNewFeedback = callbacks.newFeedback) + } else { + FeedbackComposeRoute( + supportRepository, + boxcastPrefs, + composeSession, + bottomContentPadding, + onBack = if (showBack) callbacks.back else callbacks.leave, + onDone = callbacks.sent, + ) + } +} + +internal const val FEEDBACK_GITHUB_NEW_ISSUE_URL = "https://github.com/boxcreate/boxlore/issues/new" + +@Composable +private fun FeedbackComposeRoute( + supportRepository: SupportRepository, + boxcastPrefs: BoxcastPrefs, + session: Int, + bottomContentPadding: Dp, + onBack: () -> Unit, + onDone: (repliesInApp: Boolean) -> Unit, +) { + val context = LocalContext.current + // A new key per "Send feedback": a fresh form each time (the saved draft still comes back). + val feedbackViewModel: FeedbackViewModel = viewModel( + key = "feedback-compose-$session", + factory = FeedbackViewModel.Factory(supportRepository, boxcastPrefs, context), + ) + val uiState by feedbackViewModel.uiState.collectAsStateWithLifecycle() + FeedbackContent( + state = uiState, + actions = FeedbackComposeActions( + onBack = onBack, + onCategorySelected = feedbackViewModel::onCategorySelected, + onMessageChanged = feedbackViewModel::onMessageChanged, + onAttachDiagnosticsChanged = feedbackViewModel::onAttachDiagnosticsChanged, + onPhotosPicked = feedbackViewModel::onPhotosPicked, + onRemovePhoto = feedbackViewModel::onRemovePhoto, + onSubmit = feedbackViewModel::onSubmit, + onPreviewDiagnostics = feedbackViewModel::loadLogsForPreview, + onDiscardDraft = feedbackViewModel::onDiscardDraft, + ), + boxcastPrefs = boxcastPrefs, + bottomContentPadding = bottomContentPadding, + onDone = { onDone(uiState.repliesInApp) }, + ) + if (uiState.logsPreview != null || uiState.isLoadingLogs) { + LogsPreviewDialog( + logs = uiState.logsPreview, + isLoading = uiState.isLoadingLogs, + diagnosticInfo = uiState.diagnosticInfo, + onShare = { shareFeedbackDiagnostics(context, uiState) }, + onDismissRequest = feedbackViewModel::clearLogsPreview, + ) + } +} + +@Composable +private fun FeedbackThreadRoute( + supportRepository: SupportRepository, + threadId: String, + bottomContentPadding: Dp, + onBack: (() -> Unit)?, + onNewFeedback: () -> Unit, +) { + val threadViewModel: FeedbackThreadViewModel = viewModel( + key = "feedback-thread-$threadId", + factory = FeedbackThreadViewModel.Factory(supportRepository, threadId), + ) + val state by threadViewModel.uiState.collectAsStateWithLifecycle() + FeedbackThreadScreen( + state = state, + actions = FeedbackThreadActions( + onBack = onBack, + onRetry = threadViewModel::reload, + onLoadEarlier = threadViewModel::loadEarlier, + onReplyOpen = threadViewModel::onReplyOpen, + onReplyChanged = threadViewModel::onReplyChanged, + onSendReply = threadViewModel::sendReply, + onSolve = threadViewModel::solve, + onNewFeedback = onNewFeedback, + photo = threadViewModel::photo, + ), + bottomContentPadding = bottomContentPadding, + ) +} diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackPanes.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackPanes.kt new file mode 100644 index 000000000..92cdf056d --- /dev/null +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackPanes.kt @@ -0,0 +1,95 @@ +package cx.aswin.boxlore.feature.settings.feedback + +import androidx.compose.runtime.saveable.Saver +import cx.aswin.boxlore.core.designsystem.layout.BoxloreWindowLayout +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +/** What Feedback shows: the conversation list, a new message, or one conversation. */ +sealed interface FeedbackPane { + data object List : FeedbackPane + + data object Compose : FeedbackPane + + data class Thread(val id: String) : FeedbackPane +} + +internal val FeedbackPaneSaver = Saver( + save = { pane -> + when (pane) { + FeedbackPane.List -> "list" + FeedbackPane.Compose -> "compose" + is FeedbackPane.Thread -> "thread:${pane.id}" + } + }, + restore = { saved -> + when { + saved == "list" -> FeedbackPane.List + saved.startsWith("thread:") -> FeedbackPane.Thread(saved.removePrefix("thread:")) + else -> FeedbackPane.Compose + } + }, +) + +private val THREAD_ID = Regex("[0-9a-f]{24}") + +/** A notification's conversation first; otherwise the list once there are conversations, else straight to writing. */ +internal fun initialFeedbackPane(openThreadId: String?, hasConversations: Boolean): FeedbackPane = when { + openThreadId != null && THREAD_ID.matches(openThreadId) -> FeedbackPane.Thread(openThreadId) + hasConversations -> FeedbackPane.List + else -> FeedbackPane.Compose +} + +/** + * Where Back goes from [pane]; null leaves Feedback. One pane at a time: a conversation goes back + * to the list, and a new message too when there is a list to go back to. Side by side, Back leaves. + */ +internal fun feedbackBack(pane: FeedbackPane, hasConversations: Boolean, split: Boolean): FeedbackPane? = when { + split -> null + pane is FeedbackPane.Thread -> FeedbackPane.List + pane == FeedbackPane.Compose && hasConversations -> FeedbackPane.List + else -> null +} + +/** List and conversation side by side: a landscape tablet with at least 840dp for Feedback. */ +internal fun feedbackUsesSplit(paneWidthDp: Float, layout: BoxloreWindowLayout): Boolean { + val width = if (paneWidthDp > 0f) paneWidthDp else layout.widthDp + return layout.usesTabletLayout && width >= 840f && layout.widthDp > layout.heightDp +} + +private fun clock(at: Instant, zone: ZoneId, locale: Locale, is24Hour: Boolean): String = + DateTimeFormatter.ofPattern(if (is24Hour) "HH:mm" else "h:mm a", locale).format(at.atZone(zone)) + +private fun day(date: LocalDate, today: LocalDate, locale: Locale): String = + DateTimeFormatter.ofPattern(if (date.year == today.year) "d MMM" else "d MMM yyyy", locale).format(date) + +/** The list's time: the clock today, "Yesterday", then the date. */ +internal fun feedbackListTime(atMs: Long, nowMs: Long, zone: ZoneId, locale: Locale, is24Hour: Boolean): String { + val at = Instant.ofEpochMilli(atMs) + val date = at.atZone(zone).toLocalDate() + val today = Instant.ofEpochMilli(nowMs).atZone(zone).toLocalDate() + return when (date) { + today -> clock(at, zone, locale, is24Hour) + today.minusDays(1) -> "Yesterday" + else -> day(date, today, locale) + } +} + +/** A message's time: "Today, 11:02", "Yesterday, 18:20" or "2 Nov, 09:41". */ +internal fun feedbackMessageTime(atMs: Long, nowMs: Long, zone: ZoneId, locale: Locale, is24Hour: Boolean): String { + val at = Instant.ofEpochMilli(atMs) + val date = at.atZone(zone).toLocalDate() + val today = Instant.ofEpochMilli(nowMs).atZone(zone).toLocalDate() + val dayPart = when (date) { + today -> "Today" + today.minusDays(1) -> "Yesterday" + else -> day(date, today, locale) + } + return "$dayPart, ${clock(at, zone, locale, is24Hour)}" +} + +/** A status line: the server's label, and the time when the server says it matters. */ +internal fun feedbackStatusLine(label: String, showTime: Boolean, time: String): String = if (showTime && time.isNotBlank()) "$label · $time" else label 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 e215b8268..121f0a6ce 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 @@ -35,9 +35,12 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf 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 @@ -47,22 +50,24 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import cx.aswin.boxlore.core.analytics.AnalyticsHelper import cx.aswin.boxlore.core.designsystem.analytics.TrackScreenView import cx.aswin.boxlore.core.designsystem.layout.BoxloreCardFamily +import cx.aswin.boxlore.core.designsystem.layout.LocalBoxlorePaneWidth +import cx.aswin.boxlore.core.designsystem.layout.LocalBoxloreWindowLayout import cx.aswin.boxlore.core.designsystem.layout.boxloreCardWidth import cx.aswin.boxlore.core.prefs.BoxcastPrefs import cx.aswin.boxlore.core.support.SupportRepository +import kotlinx.coroutines.launch /** Below this height the full layout scrolls instead of stretching the message card. */ internal val FEEDBACK_TALL_ENOUGH = 600.dp private val FEEDBACK_SHORT_MESSAGE_HEIGHT = 220.dp /** - * "Share your thoughts": the type pills, a roomy message card, photos (when the server switches - * them on), diagnostics and Send. While typing, the pills fold into one pill in the header and a - * slim bar rides on the keyboard, so Send is always reachable. After sending, [FeedbackSentScreen]. + * Feedback: the conversation list once there are conversations, otherwise straight to "Share + * your thoughts"; one conversation from the list or a reply notification ([openThreadId]). On a + * landscape tablet the list and the conversation sit side by side. */ @Composable fun FeedbackScreen( @@ -71,43 +76,54 @@ fun FeedbackScreen( onBack: () -> Unit, modifier: Modifier = Modifier, bottomContentPadding: Dp = 0.dp, + openThreadId: String? = null, ) { TrackScreenView("feedback") val context = LocalContext.current - val feedbackViewModel: FeedbackViewModel = viewModel( - factory = FeedbackViewModel.Factory(supportRepository, boxcastPrefs, context), + val uriHandler = LocalUriHandler.current + val scope = rememberCoroutineScope() + var pane by rememberSaveable(stateSaver = FeedbackPaneSaver) { mutableStateOf(initialFeedbackPane(openThreadId, supportRepository.hasConversations)) } + var composeSession by rememberSaveable { mutableIntStateOf(0) } + var loadFailed by remember { mutableStateOf(false) } + val threads by supportRepository.threads.collectAsStateWithLifecycle() + val refresh: () -> Unit = { + scope.launch { loadFailed = supportRepository.refresh() == null && supportRepository.threads.value == null } + } + // The list is fresh each time it shows: replies read or sent elsewhere change its statuses. + LaunchedEffect(pane == FeedbackPane.List) { if (pane == FeedbackPane.List || supportRepository.threads.value == null) refresh() } + val split = feedbackUsesSplit(LocalBoxlorePaneWidth.current, LocalBoxloreWindowLayout.current) + val goBack: () -> Unit = { feedbackBack(pane, supportRepository.hasConversations, split)?.let { pane = it } ?: onBack() } + BackHandler(onBack = goBack) + val openCompose: () -> Unit = { + composeSession += 1 + pane = FeedbackPane.Compose + } + val listActions = FeedbackListActions( + onOpen = { pane = FeedbackPane.Thread(it) }, + onNew = openCompose, + onRetry = refresh, + onRate = { + AnalyticsHelper.trackStoreReviewOpened(method = "store_page", source = "feedback_list") + openPlayStorePage(context) + }, + onGitHub = { uriHandler.openUri(FEEDBACK_GITHUB_NEW_ISSUE_URL) }, + onBack = onBack, ) - val uiState by feedbackViewModel.uiState.collectAsStateWithLifecycle() - - BackHandler(onBack = onBack) - - FeedbackContent( - state = uiState, - actions = FeedbackComposeActions( - onBack = onBack, - onCategorySelected = feedbackViewModel::onCategorySelected, - onMessageChanged = feedbackViewModel::onMessageChanged, - onAttachDiagnosticsChanged = feedbackViewModel::onAttachDiagnosticsChanged, - onPhotosPicked = feedbackViewModel::onPhotosPicked, - onRemovePhoto = feedbackViewModel::onRemovePhoto, - onSubmit = feedbackViewModel::onSubmit, - onPreviewDiagnostics = feedbackViewModel::loadLogsForPreview, - onDiscardDraft = feedbackViewModel::onDiscardDraft, - ), - boxcastPrefs = boxcastPrefs, - bottomContentPadding = bottomContentPadding, + val callbacks = FeedbackDetailCallbacks( + back = goBack, + leave = onBack, + newFeedback = openCompose, + // A message that became a conversation goes to the list; one through the old form leaves. + sent = { repliesInApp -> if (repliesInApp) pane = FeedbackPane.List else onBack() }, + ) + FeedbackPanesLayout( + split = split, + pane = pane, + threads = threads, + list = { listModifier, selectedId -> FeedbackListScreen(threads, loadFailed, selectedId, bottomContentPadding, listActions, listModifier) }, + detail = { detail, showBack -> FeedbackDetailPane(supportRepository, boxcastPrefs, detail, composeSession, bottomContentPadding, callbacks, showBack) }, modifier = modifier, ) - - if (uiState.logsPreview != null || uiState.isLoadingLogs) { - LogsPreviewDialog( - logs = uiState.logsPreview, - isLoading = uiState.isLoadingLogs, - diagnosticInfo = uiState.diagnosticInfo, - onShare = { shareFeedbackDiagnostics(context, uiState) }, - onDismissRequest = feedbackViewModel::clearLogsPreview, - ) - } } internal class FeedbackComposeActions( @@ -123,7 +139,7 @@ internal class FeedbackComposeActions( ) /** - * The compose screen until the message is sent, then [FeedbackSentScreen]; Done leaves like Back. + * The compose screen until the message is sent, then [FeedbackSentScreen], whose Done calls [onDone]. * [imeBottom] is the keyboard's height (tests pass their own). */ @Composable @@ -134,13 +150,14 @@ internal fun FeedbackContent( bottomContentPadding: Dp, modifier: Modifier = Modifier, imeBottom: Dp = WindowInsets.ime.asPaddingValues().calculateBottomPadding(), + onDone: () -> Unit = actions.onBack, ) { if (state.isSuccess) { FeedbackSentScreen( boxcastPrefs = boxcastPrefs, repliesInApp = state.repliesInApp, photosDropped = state.photosDropped, - onDone = actions.onBack, + onDone = onDone, bottomContentPadding = bottomContentPadding, modifier = modifier, ) diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackThreadScreen.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackThreadScreen.kt new file mode 100644 index 000000000..394e255fc --- /dev/null +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackThreadScreen.kt @@ -0,0 +1,328 @@ +package cx.aswin.boxlore.feature.settings.feedback + +import android.graphics.BitmapFactory +import android.text.format.DateFormat +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.Lock +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import cx.aswin.boxlore.core.designsystem.analytics.TrackScreenView +import cx.aswin.boxlore.core.designsystem.layout.BoxloreCardFamily +import cx.aswin.boxlore.core.designsystem.layout.boxloreCardWidth +import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight +import cx.aswin.boxlore.core.support.SupportEnding +import cx.aswin.boxlore.core.support.SupportMessage +import java.time.ZoneId +import java.util.Locale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +internal const val FEEDBACK_THREAD_TAG = "feedback_thread" +internal const val FEEDBACK_REPLY_FIELD_TAG = "feedback_reply_field" +internal const val FEEDBACK_REPLY_SEND_TAG = "feedback_reply_send" + +internal class FeedbackThreadActions( + val onBack: (() -> Unit)?, + val onRetry: () -> Unit, + val onLoadEarlier: () -> Unit, + val onReplyOpen: (Boolean) -> Unit, + val onReplyChanged: (String) -> Unit, + val onSendReply: () -> Unit, + val onSolve: () -> Unit, + val onNewFeedback: () -> Unit, + val photo: suspend (String) -> ByteArray?, +) + +/** + * One conversation: the listener's messages in outlined cards, the team's in a filled card, then + * how it ended (solved invites a reply; closed points to new feedback). The reply box rides on the + * keyboard, which replaces the bottom chrome padding as on the compose screen. + */ +@Composable +internal fun FeedbackThreadScreen( + state: FeedbackThreadUiState, + actions: FeedbackThreadActions, + bottomContentPadding: Dp, + modifier: Modifier = Modifier, + imeBottom: Dp = WindowInsets.ime.asPaddingValues().calculateBottomPadding(), +) { + TrackScreenView("feedback_thread") + val systemBar = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + Surface(modifier = modifier.fillMaxSize().testTag(FEEDBACK_THREAD_TAG), color = MaterialTheme.colorScheme.surface) { + Box( + Modifier.fillMaxSize().statusBarsPadding().padding(bottom = feedbackBottomPadding(imeBottom, bottomContentPadding, systemBar)), + contentAlignment = Alignment.TopCenter, + ) { + Column(Modifier.fillMaxSize().boxloreCardWidth(BoxloreCardFamily.Utility)) { + Row(Modifier.fillMaxWidth().heightIn(min = 64.dp).padding(horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically) { + if (actions.onBack != null) FeedbackIconButton(Icons.AutoMirrored.Rounded.ArrowBack, "Navigate back", actions.onBack) else Spacer(Modifier.width(16.dp)) + Text( + state.conversation?.thread?.categoryLabel ?: "Feedback", + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.weight(1f).padding(start = 4.dp), + ) + } + Box(Modifier.weight(1f).fillMaxWidth()) { + when { + state.conversation != null -> Messages(state, actions) + state.gone -> Notice("This conversation isn't available any more.", actions.onBack?.let { "Back" to it }, Modifier.align(Alignment.Center)) + state.loadFailed -> Notice("Couldn't load this conversation. Check your connection and try again.", "Try again" to actions.onRetry, Modifier.align(Alignment.Center)) + else -> CircularProgressIndicator(Modifier.align(Alignment.Center)) + } + } + if (state.replyOpen) ReplyBar(state, actions) + } + } + } +} + +@Composable +private fun Messages(state: FeedbackThreadUiState, actions: FeedbackThreadActions) { + val conversation = state.conversation ?: return + val context = LocalContext.current + val is24Hour = DateFormat.is24HourFormat(context) + val now = System.currentTimeMillis() + val zone = ZoneId.systemDefault() + val locale = Locale.getDefault() + LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + if (conversation.earlier != null) { + item(key = "earlier") { + TextButton(onClick = actions.onLoadEarlier, enabled = !state.loadingEarlier, modifier = Modifier.fillMaxWidth()) { + Text(if (state.loadingEarlier) "Loading…" else "Show earlier messages", fontWeight = GoogleSansWeight.semiBold) + } + } + } + items(conversation.messages, key = { it.id }) { message -> + MessageCard(message, feedbackMessageTime(message.createdAt, now, zone, locale, is24Hour), actions.photo) + } + conversation.ending?.let { ending -> + item(key = "ending") { + EndingCard(ending, feedbackListTime(ending.at, now, zone, locale, is24Hour), onReply = { actions.onReplyOpen(true) }, onNew = actions.onNewFeedback, canReply = state.canReply && !state.replyOpen) + } + } + if (conversation.ending == null && state.canReply && !state.replyOpen) { + item(key = "actions") { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Button(onClick = { actions.onReplyOpen(true) }, shape = CircleShape, modifier = Modifier.heightIn(min = 48.dp)) { + Text("Reply", fontWeight = GoogleSansWeight.semiBold) + } + if (state.canSolve) { + TextButton(onClick = actions.onSolve, enabled = !state.solving, modifier = Modifier.heightIn(min = 48.dp)) { + Text("Mark solved", fontWeight = GoogleSansWeight.semiBold) + } + } + } + } + } + } +} + +@Composable +private fun MessageCard(message: SupportMessage, time: String, photo: suspend (String) -> ByteArray?) { + val team = message.fromTeam + Surface( + shape = RoundedCornerShape(20.dp), + color = if (team) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface, + contentColor = if (team) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface, + border = if (team) null else BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + if (team) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Box( + Modifier.size(28.dp).background(MaterialTheme.colorScheme.primary, CircleShape), + contentAlignment = Alignment.Center, + ) { + Text("b", color = MaterialTheme.colorScheme.onPrimary, style = MaterialTheme.typography.labelLarge, fontWeight = GoogleSansWeight.semiBold) + } + Text(message.author.ifBlank { "boxlore team" }, style = MaterialTheme.typography.titleSmall) + Text("· $time", style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f, fill = false)) + } + } else { + Text("${message.author.ifBlank { "You" }} · $time", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Text(message.body, style = MaterialTheme.typography.bodyLarge) + if (message.photoIds.isNotEmpty() || message.diagnostics) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.Bottom) { + message.photoIds.forEachIndexed { index, id -> PhotoThumb(id, index, photo) } + if (message.diagnostics) { + Text("Diagnostics included", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 4.dp)) + } + } + } + } + } +} + +@Composable +private fun PhotoThumb(id: String, index: Int, photo: suspend (String) -> ByteArray?) { + val image by produceState(null, id) { + value = withContext(Dispatchers.Default) { + photo(id)?.let { bytes -> + runCatching { + val options = BitmapFactory.Options().apply { inSampleSize = 4 } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)?.asImageBitmap() + }.getOrNull() + } + } + } + Box( + Modifier + .size(width = 64.dp, height = 112.dp) + .background(MaterialTheme.colorScheme.surfaceContainerHighest, RoundedCornerShape(12.dp)) + .semantics { contentDescription = "Photo ${index + 1}" }, + ) { + image?.let { Image(it, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) } + } +} + +@Composable +private fun EndingCard(ending: SupportEnding, time: String, onReply: () -> Unit, onNew: () -> Unit, canReply: Boolean) { + val solved = ending.kind == "solved" + Surface( + shape = RoundedCornerShape(20.dp), + color = if (solved) MaterialTheme.colorScheme.tertiaryContainer else MaterialTheme.colorScheme.surfaceContainer, + contentColor = if (solved) MaterialTheme.colorScheme.onTertiaryContainer else MaterialTheme.colorScheme.onSurface, + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Icon( + if (solved) Icons.Rounded.CheckCircle else Icons.Rounded.Lock, + contentDescription = null, + tint = if (solved) MaterialTheme.colorScheme.onTertiaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp), + ) + Text(feedbackStatusLine(ending.title, showTime = ending.at > 0, time = time), style = MaterialTheme.typography.titleMedium, fontWeight = GoogleSansWeight.semiBold) + } + if (ending.body.isNotBlank()) { + Text( + ending.body, + style = MaterialTheme.typography.bodyMedium, + color = if (solved) MaterialTheme.colorScheme.onTertiaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + val label = ending.actionLabel + when { + ending.actionKind == "reply" && label != null && canReply -> Button( + onClick = onReply, + shape = CircleShape, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.surface, contentColor = MaterialTheme.colorScheme.onSurface), + modifier = Modifier.heightIn(min = 44.dp), + ) { Text(label, fontWeight = GoogleSansWeight.semiBold) } + ending.actionKind == "new" && label != null -> Button(onClick = onNew, shape = CircleShape, modifier = Modifier.heightIn(min = 44.dp)) { + Text(label, fontWeight = GoogleSansWeight.semiBold) + } + } + } + } +} + +@Composable +private fun ReplyBar(state: FeedbackThreadUiState, actions: FeedbackThreadActions) { + val focus = remember { FocusRequester() } + LaunchedEffect(Unit) { runCatching { focus.requestFocus() } } + Surface( + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + ) { + Column(Modifier.padding(start = 18.dp, end = 10.dp, top = 14.dp, bottom = 8.dp)) { + Box(Modifier.fillMaxWidth().heightIn(min = 72.dp, max = 180.dp)) { + if (state.reply.isEmpty()) { + Text("Write your reply", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + BasicTextField( + value = state.reply, + onValueChange = actions.onReplyChanged, + readOnly = state.sending, + textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focus) + .testTag(FEEDBACK_REPLY_FIELD_TAG) + .semantics { contentDescription = "Your reply" }, + ) + } + state.replyError?.let { error -> + Text(error, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(top = 4.dp, end = 8.dp)) + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = { actions.onReplyOpen(false) }, enabled = !state.sending, modifier = Modifier.heightIn(min = 48.dp)) { + Text("Cancel", fontWeight = GoogleSansWeight.semiBold) + } + Button( + onClick = actions.onSendReply, + enabled = state.canSendReply, + shape = CircleShape, + modifier = Modifier.heightIn(min = 48.dp).testTag(FEEDBACK_REPLY_SEND_TAG), + ) { + Text(if (state.sending) "Sending" else "Send reply", fontWeight = GoogleSansWeight.semiBold) + } + } + } + } +} + +@Composable +private fun Notice(text: String, action: Pair Unit>?, modifier: Modifier = Modifier) { + Column(modifier.padding(horizontal = 32.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(text, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) + action?.let { (label, onClick) -> TextButton(onClick = onClick) { Text(label, fontWeight = GoogleSansWeight.semiBold) } } + } +} diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackThreadViewModel.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackThreadViewModel.kt new file mode 100644 index 000000000..6e4d0f363 --- /dev/null +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackThreadViewModel.kt @@ -0,0 +1,156 @@ +package cx.aswin.boxlore.feature.settings.feedback + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import cx.aswin.boxlore.core.support.ConversationResult +import cx.aswin.boxlore.core.support.ReplyResult +import cx.aswin.boxlore.core.support.SupportConversation +import cx.aswin.boxlore.core.support.SupportRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class FeedbackThreadUiState( + val conversation: SupportConversation? = null, + val loading: Boolean = true, + /** Not this phone's conversation (any more), or support isn't available. */ + val gone: Boolean = false, + val loadFailed: Boolean = false, + val loadingEarlier: Boolean = false, + val replyOpen: Boolean = false, + val reply: String = "", + val sending: Boolean = false, + val replyError: String? = null, + val solving: Boolean = false, +) { + val canReply: Boolean get() = conversation?.thread?.canReply == true + val canSendReply: Boolean get() = reply.isNotBlank() && !sending && canReply + + /** Mark solved: an open conversation the team has answered. */ + val canSolve: Boolean get() = conversation?.let { c -> c.ending == null && c.thread.canReply && c.messages.any { it.fromTeam } } == true +} + +/** What a conversation screen needs from support; the factory wires [SupportRepository], tests pass fakes. */ +class FeedbackThreadSupport( + val load: suspend (id: String, before: String?) -> ConversationResult, + val reply: suspend (id: String, body: String, requestId: String) -> ReplyResult, + val markRead: suspend (id: String) -> Unit, + val solve: suspend (id: String) -> Boolean, + val photo: suspend (id: String) -> ByteArray?, + /** The server's message limit. */ + val maxBody: () -> Int, +) + +class FeedbackThreadViewModel(private val support: FeedbackThreadSupport, private val threadId: String) : ViewModel() { + private val retryKeys = RetryKeys() + private val photoCache = mutableMapOf() + private val _uiState = MutableStateFlow(FeedbackThreadUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + reload() + } + + /** Loads the newest page; an unread team reply is marked read once it is on screen. */ + fun reload() { + _uiState.update { it.copy(loading = it.conversation == null, loadFailed = false) } + viewModelScope.launch { + when (val result = support.load(threadId, null)) { + is ConversationResult.Loaded -> { + _uiState.update { it.copy(conversation = result.conversation, loading = false, gone = false, loadFailed = false) } + if (result.conversation.thread.unread) runCatching { support.markRead(threadId) } + } + ConversationResult.Gone -> _uiState.update { it.copy(loading = false, gone = true) } + ConversationResult.Failed -> _uiState.update { it.copy(loading = false, loadFailed = true) } + } + } + } + + fun loadEarlier() { + val current = _uiState.value.conversation ?: return + val before = current.earlier ?: return + if (_uiState.value.loadingEarlier) return + _uiState.update { it.copy(loadingEarlier = true) } + viewModelScope.launch { + val page = (support.load(threadId, before) as? ConversationResult.Loaded)?.conversation + _uiState.update { state -> + val now = state.conversation + if (page == null || now == null) { + state.copy(loadingEarlier = false) + } else { + state.copy(conversation = now.copy(messages = page.messages + now.messages, earlier = page.earlier), loadingEarlier = false) + } + } + } + } + + fun onReplyOpen(open: Boolean) { + if (_uiState.value.sending) return + _uiState.update { it.copy(replyOpen = open && it.canReply, replyError = null) } + } + + fun onReplyChanged(text: String) { + if (_uiState.value.sending) return + _uiState.update { it.copy(reply = limitedMessage(it.reply, text, support.maxBody()), replyError = null) } + } + + fun sendReply() { + val state = _uiState.value + val body = state.reply.trim() + if (!state.canSendReply || body.isBlank()) return + val requestId = retryKeys.keyFor(body) + _uiState.update { it.copy(sending = true, replyError = null) } + viewModelScope.launch { + when (val result = support.reply(threadId, body, requestId)) { + is ReplyResult.Sent -> { + retryKeys.clear() + _uiState.update { it.copy(sending = false, reply = "", replyOpen = false) } + reload() + } + is ReplyResult.Closed -> { + _uiState.update { it.copy(sending = false, replyOpen = false) } + reload() + } + is ReplyResult.Refused -> _uiState.update { it.copy(sending = false, replyError = result.message) } + ReplyResult.Failed -> _uiState.update { + it.copy(sending = false, replyError = "Couldn't send your reply. Check your connection and try again.") + } + } + } + } + + fun solve() { + if (!_uiState.value.canSolve || _uiState.value.solving) return + _uiState.update { it.copy(solving = true) } + viewModelScope.launch { + support.solve(threadId) + _uiState.update { it.copy(solving = false) } + reload() + } + } + + /** A photo's bytes, fetched once. */ + suspend fun photo(id: String): ByteArray? { + if (photoCache.containsKey(id)) return photoCache[id] + return support.photo(id).also { photoCache[id] = it } + } + + class Factory(private val repository: SupportRepository, private val threadId: String) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = FeedbackThreadViewModel( + support = FeedbackThreadSupport( + load = repository::conversation, + reply = { id, body, key -> repository.reply(id, body, key) }, + markRead = { id -> repository.markRead(id) }, + solve = repository::solve, + photo = repository::photo, + maxBody = { repository.meta.value.maxBody }, + ), + threadId = threadId, + ) as T + } + +} 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 89d82358b..3728432ea 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 @@ -291,6 +291,8 @@ class FeedbackViewModel( ), ) handleResult(choice, state, result, photosDropped = photos is PhotoBatch.Unavailable) + // A first conversation: refresh now, so the phone joins its reply-push topic before a reply can arrive. + if (result is SendResult.Sent) runCatching { support.refresh() } } } diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackConversationsAdaptiveTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackConversationsAdaptiveTest.kt new file mode 100644 index 000000000..bb03de2cf --- /dev/null +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackConversationsAdaptiveTest.kt @@ -0,0 +1,201 @@ +package cx.aswin.boxlore.feature.settings.feedback + +import android.app.Application +import android.content.Context +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.StateRestorationTester +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.dp +import androidx.test.core.app.ApplicationProvider +import cx.aswin.boxlore.core.designsystem.echo.LocalEchoMotion +import cx.aswin.boxlore.core.designsystem.layout.BoxloreWindowLayout +import cx.aswin.boxlore.core.designsystem.layout.LocalBoxlorePaneWidth +import cx.aswin.boxlore.core.designsystem.layout.LocalBoxloreWindowLayout +import cx.aswin.boxlore.core.designsystem.theme.BoxLoreTheme +import cx.aswin.boxlore.core.network.SupportApi +import cx.aswin.boxlore.core.network.model.SupportConversationResponse +import cx.aswin.boxlore.core.network.model.SupportListResponse +import cx.aswin.boxlore.core.network.model.SupportMessageDto +import cx.aswin.boxlore.core.network.model.SupportMetaDto +import cx.aswin.boxlore.core.network.model.SupportNewThreadRequest +import cx.aswin.boxlore.core.network.model.SupportPhotoResponse +import cx.aswin.boxlore.core.network.model.SupportReplyRequest +import cx.aswin.boxlore.core.network.model.SupportReplyResponse +import cx.aswin.boxlore.core.network.model.SupportStatusDto +import cx.aswin.boxlore.core.network.model.SupportThreadDto +import cx.aswin.boxlore.core.network.model.SupportThreadResponse +import cx.aswin.boxlore.core.prefs.BoxcastPrefs +import cx.aswin.boxlore.core.support.SupportLocalStore +import cx.aswin.boxlore.core.support.SupportRepository +import cx.aswin.boxlore.core.testing.PhoneGoldenConfiguration +import cx.aswin.boxlore.core.testing.PhoneGoldenRule +import kotlinx.coroutines.Dispatchers +import okhttp3.RequestBody +import okhttp3.ResponseBody +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import retrofit2.Response + +/** + * The whole Feedback page across window changes: one pane on phones, list and conversation side by + * side on landscape tablets, with the open conversation kept through resizes in both directions, + * a fold and recreation; Back walks to the list; a notification opens its conversation directly. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33], application = Application::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class FeedbackConversationsAdaptiveTest { + @get:Rule val fixture = PhoneGoldenRule(PhoneGoldenConfiguration(1_280, 960, false)) + + private val window = mutableStateOf(PHONE) + private val backs = mutableListOf() + private lateinit var restoration: StateRestorationTester + + private class FakeApi(private val threads: List) : SupportApi { + val reads = mutableListOf() + override suspend fun threads() = Response.success(SupportListResponse(meta = SupportMetaDto(), threads = threads)) + override suspend fun startThread(request: SupportNewThreadRequest) = Response.success(SupportThreadResponse(threads.firstOrNull())) + override suspend fun uploadPhoto(photo: RequestBody) = Response.error(403, "{}".toResponseBody()) + override suspend fun conversation(id: String, before: String?) = Response.success( + SupportConversationResponse( + thread = threads.first { it.id == id }, + messages = listOf(SupportMessageDto("m$id", "you", "You", "Message for $id", createdAt = 1), SupportMessageDto("r$id", "team", "boxlore team", "Reply for $id", createdAt = 2)), + ), + ) + override suspend fun reply(id: String, request: SupportReplyRequest) = Response.success(SupportReplyResponse()) + override suspend fun markRead(id: String): Response { + reads += id + return Response.success(Unit) + } + override suspend fun solve(id: String) = Response.success(SupportConversationResponse()) + override suspend fun photo(id: String) = Response.error(404, "{}".toResponseBody()) + } + + private fun dto(id: String, title: String, unread: Boolean = false) = + SupportThreadDto(id, "bug", "Bug report", title, SupportStatusDto(key = if (unread) "new_reply" else "replied", label = if (unread) "New reply" else "Replied", showTime = true), 1, unread, true) + + private val first = "a1b2c3d4e5f6a1b2c3d4e5f6" + private val second = "b1b2c3d4e5f6a1b2c3d4e5f6" + private lateinit var api: FakeApi + + @Test + fun `the open conversation stays open through window changes and recreation, and Back returns to the list`() { + render() + fixture.compose.onNodeWithText("Second problem").performClick() + fixture.compose.waitForIdle() + fixture.compose.onNodeWithText("Message for $second").assertExists() + + for (size in TRANSITIONS + PHONE) { + fixture.compose.runOnIdle { window.value = size } + fixture.compose.runOnUiThread { Snapshot.sendApplyNotifications() } + fixture.compose.waitForIdle() + fixture.compose.onNodeWithText("Message for $second").assertExists() + val split = feedbackUsesSplit(size.widthDp, size) + fixture.compose.onNodeWithTag(FEEDBACK_LIST_TAG).apply { if (split) assertExists() else assertDoesNotExist() } + } + fixture.compose.runOnIdle { window.value = TABLET } + restoration.emulateSavedInstanceStateRestore() + fixture.compose.waitForIdle() + fixture.compose.onNodeWithText("Message for $second").assertExists() + + fixture.compose.runOnIdle { window.value = PHONE } + fixture.compose.waitForIdle() + fixture.compose.runOnUiThread { fixture.compose.activity.onBackPressedDispatcher.onBackPressed() } + fixture.compose.waitForIdle() + fixture.compose.onNodeWithTag(FEEDBACK_LIST_TAG).assertExists() + fixture.compose.onNodeWithText("Message for $second").assertDoesNotExist() + assertEquals(0, backs.size) + fixture.compose.runOnUiThread { fixture.compose.activity.onBackPressedDispatcher.onBackPressed() } + fixture.compose.waitForIdle() + assertEquals(1, backs.size) + } + + @Test + fun `side by side, the newest conversation shows until another is picked, and an unread one is marked read`() { + window.value = TABLET + render(firstUnread = true) + fixture.compose.onNodeWithTag(FEEDBACK_LIST_TAG).assertExists() + fixture.compose.onNodeWithText("Message for $first").assertExists() + fixture.compose.onNodeWithText("Second problem").performClick() + fixture.compose.waitForIdle() + fixture.compose.onNodeWithText("Message for $second").assertExists() + assertEquals(listOf(first), api.reads) + } + + @Test + fun `a reply notification opens its conversation, and a phone without conversations starts writing`() { + render(openThreadId = second) + fixture.compose.onNodeWithText("Message for $second").assertExists() + fixture.compose.onNodeWithTag(FEEDBACK_LIST_TAG).assertDoesNotExist() + } + + @Test + fun `without conversations Feedback opens on Share your thoughts`() { + render(threads = emptyList(), hasConversations = false) + fixture.compose.onNodeWithText("Share your thoughts").assertExists() + fixture.compose.onNodeWithTag(FEEDBACK_LIST_TAG).assertDoesNotExist() + } + + private fun render( + threads: List? = null, + hasConversations: Boolean = true, + firstUnread: Boolean = false, + openThreadId: String? = null, + ) { + val context = ApplicationProvider.getApplicationContext() + context.getSharedPreferences(BoxcastPrefs.PREFS_NAME, Context.MODE_PRIVATE).edit().clear().commit() + api = FakeApi(threads ?: listOf(dto(first, "First problem", unread = firstUnread), dto(second, "Second problem"))) + val repository = SupportRepository( + api = api, + legacySubmit = { _, _, _, _, _ -> true }, + ioDispatcher = Dispatchers.Unconfined, + local = SupportLocalStore.InMemory().apply { this.hasConversations = hasConversations }, + ) + val prefs = BoxcastPrefs(context) + fixture.prepare() + fixture.compose.mainClock.autoAdvance = true + restoration = StateRestorationTester(fixture.compose) + restoration.setContent { + val size = window.value + CompositionLocalProvider(LocalBoxloreWindowLayout provides size, LocalBoxlorePaneWidth provides size.widthDp, LocalEchoMotion provides false) { + Box(Modifier.width(size.widthDp.dp).height(size.heightDp.dp)) { + BoxLoreTheme(dynamicColor = false) { + FeedbackScreen(repository, prefs, onBack = { backs += Unit }, bottomContentPadding = 96.dp, openThreadId = openThreadId) + } + } + } + } + fixture.compose.waitForIdle() + } + + companion object { + private val PHONE = BoxloreWindowLayout(412f, 900f) + private val TABLET = BoxloreWindowLayout(1_280f, 800f) + + /** Across the split gate both ways, a portrait tablet, a short window and a fold. */ + private val TRANSITIONS = listOf( + BoxloreWindowLayout(839f, 600f), + BoxloreWindowLayout(840f, 600f), + BoxloreWindowLayout(839f, 600f), + TABLET, + BoxloreWindowLayout(800f, 1_280f), + BoxloreWindowLayout(900f, 479f), + BoxloreWindowLayout(673f, 841f), + TABLET, + ) + } +} diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackPanesTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackPanesTest.kt new file mode 100644 index 000000000..6b3aa8102 --- /dev/null +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackPanesTest.kt @@ -0,0 +1,63 @@ +package cx.aswin.boxlore.feature.settings.feedback + +import androidx.compose.runtime.saveable.SaverScope +import cx.aswin.boxlore.core.designsystem.layout.BoxloreWindowLayout +import java.time.ZoneId +import java.time.ZonedDateTime +import java.util.Locale +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Which pane Feedback opens on, where Back goes, when it splits, and how times read. */ +class FeedbackPanesTest { + private val id = "0123456789abcdef01234567" + + @Test fun `a notification's conversation comes first, then the list once there is one`() { + assertEquals(FeedbackPane.Thread(id), initialFeedbackPane(id, hasConversations = false)) + assertEquals(FeedbackPane.List, initialFeedbackPane("not-an-id", hasConversations = true)) + assertEquals(FeedbackPane.Compose, initialFeedbackPane(null, hasConversations = false)) + } + + @Test fun `Back walks to the list one pane at a time, and leaves side by side`() { + assertEquals(FeedbackPane.List, feedbackBack(FeedbackPane.Thread(id), hasConversations = true, split = false)) + assertEquals(FeedbackPane.List, feedbackBack(FeedbackPane.Compose, hasConversations = true, split = false)) + assertNull(feedbackBack(FeedbackPane.Compose, hasConversations = false, split = false)) + assertNull(feedbackBack(FeedbackPane.List, hasConversations = true, split = false)) + assertNull(feedbackBack(FeedbackPane.Thread(id), hasConversations = true, split = true)) + } + + @Test fun `side by side needs a landscape tablet with 840dp for Feedback`() { + assertTrue(feedbackUsesSplit(0f, BoxloreWindowLayout(1280f, 800f))) + assertTrue(feedbackUsesSplit(840f, BoxloreWindowLayout(1280f, 800f))) + assertFalse(feedbackUsesSplit(839f, BoxloreWindowLayout(1280f, 800f))) + assertFalse(feedbackUsesSplit(0f, BoxloreWindowLayout(839f, 600f))) + assertFalse(feedbackUsesSplit(0f, BoxloreWindowLayout(900f, 1200f))) + assertFalse(feedbackUsesSplit(0f, BoxloreWindowLayout(900f, 479f))) + } + + @Test fun `the open pane survives recreation`() { + val scope = SaverScope { true } + for (pane in listOf(FeedbackPane.List, FeedbackPane.Compose, FeedbackPane.Thread(id))) { + val saved = with(FeedbackPaneSaver) { scope.save(pane) }!! + assertEquals(pane, FeedbackPaneSaver.restore(saved)) + } + } + + @Test fun `times read as the clock today, Yesterday, then the date`() { + val zone = ZoneId.of("Asia/Kolkata") + val now = ZonedDateTime.of(2026, 11, 2, 15, 0, 0, 0, zone).toInstant().toEpochMilli() + fun at(month: Int, day: Int, hour: Int, minute: Int, year: Int = 2026) = ZonedDateTime.of(year, month, day, hour, minute, 0, 0, zone).toInstant().toEpochMilli() + assertEquals("11:02", feedbackListTime(at(11, 2, 11, 2), now, zone, Locale.UK, is24Hour = true)) + assertEquals("Yesterday", feedbackListTime(at(11, 1, 23, 0), now, zone, Locale.UK, is24Hour = true)) + assertEquals("28 Oct", feedbackListTime(at(10, 28, 10, 5), now, zone, Locale.UK, is24Hour = true)) + assertEquals("28 Oct 2025", feedbackListTime(at(10, 28, 10, 5, year = 2025), now, zone, Locale.UK, is24Hour = true)) + assertEquals("Today, 11:02", feedbackMessageTime(at(11, 2, 11, 2), now, zone, Locale.UK, is24Hour = true)) + assertEquals("28 Oct, 10:05 AM", feedbackMessageTime(at(10, 28, 10, 5), now, zone, Locale.US, is24Hour = false)) + assertEquals("Yesterday, 23:00", feedbackMessageTime(at(11, 1, 23, 0), now, zone, Locale.UK, is24Hour = true)) + assertEquals("Sent · Waiting for a reply", feedbackStatusLine("Sent · Waiting for a reply", showTime = false, time = "11:02")) + assertEquals("New reply · 11:02", feedbackStatusLine("New reply", showTime = true, time = "11:02")) + } +} diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackThreadViewModelTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackThreadViewModelTest.kt new file mode 100644 index 000000000..df45e8cc0 --- /dev/null +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackThreadViewModelTest.kt @@ -0,0 +1,166 @@ +package cx.aswin.boxlore.feature.settings.feedback + +import cx.aswin.boxlore.core.support.ConversationResult +import cx.aswin.boxlore.core.support.ReplyResult +import cx.aswin.boxlore.core.support.SupportConversation +import cx.aswin.boxlore.core.support.SupportEnding +import cx.aswin.boxlore.core.support.SupportMessage +import cx.aswin.boxlore.core.support.SupportStatus +import cx.aswin.boxlore.core.support.SupportThread +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.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class FeedbackThreadViewModelTest { + private val dispatcher = StandardTestDispatcher() + + private fun thread(unread: Boolean = false, canReply: Boolean = true) = + SupportThread("t", "bug", "Bug report", "It stops", SupportStatus("replied", "Replied", true), 1, unread, canReply) + + private fun message(id: String, team: Boolean) = SupportMessage(id, team, if (team) "boxlore team" else "You", "Body $id", emptyList(), false, 1) + + /** Answers in order of [pages]; records what the screen did. */ + private class Fake(var pages: MutableList) { + val loads = mutableListOf() + val replies = mutableListOf>() + var replyResult: ReplyResult = ReplyResult.Sent(null, null) + var reads = 0 + var solves = 0 + var photoCalls = 0 + var maxBody = 2000 + + fun support() = FeedbackThreadSupport( + load = { _, before -> + loads += before + if (pages.size > 1) pages.removeAt(0) else pages.first() + }, + reply = { _, body, key -> + replies += body to key + replyResult + }, + markRead = { reads++ }, + solve = { + solves++ + true + }, + photo = { + photoCalls++ + byteArrayOf(1) + }, + maxBody = { maxBody }, + ) + } + + private fun loaded(vararg messages: SupportMessage, unread: Boolean = false, earlier: String? = null, ending: SupportEnding? = null, canReply: Boolean = true) = + ConversationResult.Loaded(SupportConversation(thread(unread, canReply), messages.toList(), earlier, ending)) + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + + @After fun tearDown() = Dispatchers.resetMain() + + @Test fun `opening an unread conversation marks it read once`() = runTest(dispatcher) { + val fake = Fake(mutableListOf(loaded(message("m1", false), message("m2", true), unread = true))) + val vm = FeedbackThreadViewModel(fake.support(), "t") + advanceUntilIdle() + assertEquals(2, vm.uiState.value.conversation?.messages?.size) + assertEquals(1, fake.reads) + assertTrue(vm.uiState.value.canSolve) + } + + @Test fun `a gone conversation and a failed load say so, and a retry recovers`() = runTest(dispatcher) { + val gone = FeedbackThreadViewModel(Fake(mutableListOf(ConversationResult.Gone)).support(), "t") + advanceUntilIdle() + assertTrue(gone.uiState.value.gone) + + val fake = Fake(mutableListOf(ConversationResult.Failed, loaded(message("m1", false)))) + val vm = FeedbackThreadViewModel(fake.support(), "t") + advanceUntilIdle() + assertTrue(vm.uiState.value.loadFailed) + vm.reload() + advanceUntilIdle() + assertFalse(vm.uiState.value.loadFailed) + assertEquals(1, vm.uiState.value.conversation?.messages?.size) + assertFalse(vm.uiState.value.canSolve) + } + + @Test fun `earlier messages go before the ones on screen`() = runTest(dispatcher) { + val fake = Fake(mutableListOf(loaded(message("m3", false), earlier = "m3"), loaded(message("m1", false), message("m2", true)))) + val vm = FeedbackThreadViewModel(fake.support(), "t") + advanceUntilIdle() + vm.loadEarlier() + advanceUntilIdle() + assertEquals(listOf("m1", "m2", "m3"), vm.uiState.value.conversation?.messages?.map { it.id }) + assertNull(vm.uiState.value.conversation?.earlier) + assertEquals(listOf(null, "m3"), fake.loads) + } + + @Test fun `a failed reply keeps its text and retry key, sent clears both`() = runTest(dispatcher) { + val fake = Fake(mutableListOf(loaded(message("m1", false), message("m2", true)))) + fake.replyResult = ReplyResult.Failed + val vm = FeedbackThreadViewModel(fake.support(), "t") + advanceUntilIdle() + vm.onReplyOpen(true) + vm.onReplyChanged(" Still happening ") + vm.sendReply() + advanceUntilIdle() + assertEquals("Couldn't send your reply. Check your connection and try again.", vm.uiState.value.replyError) + assertEquals(" Still happening ", vm.uiState.value.reply) + + fake.replyResult = ReplyResult.Sent(null, null) + vm.sendReply() + advanceUntilIdle() + assertEquals("Still happening", fake.replies.first().first) + assertEquals(fake.replies[0].second, fake.replies[1].second) + assertEquals("", vm.uiState.value.reply) + assertFalse(vm.uiState.value.replyOpen) + } + + @Test fun `a refusal shows the server's words, a closed conversation reloads to show its ending`() = runTest(dispatcher) { + val ending = SupportEnding("closed", "This conversation is closed", "", 2, "new", "Send new feedback") + val fake = Fake(mutableListOf(loaded(message("m1", false)), loaded(message("m1", false), ending = ending, canReply = false))) + fake.replyResult = ReplyResult.Refused("Try again tomorrow.") + val vm = FeedbackThreadViewModel(fake.support(), "t") + advanceUntilIdle() + vm.onReplyOpen(true) + vm.onReplyChanged("More") + vm.sendReply() + advanceUntilIdle() + assertEquals("Try again tomorrow.", vm.uiState.value.replyError) + + fake.replyResult = ReplyResult.Closed(null) + vm.sendReply() + advanceUntilIdle() + assertEquals(ending, vm.uiState.value.conversation?.ending) + assertFalse(vm.uiState.value.canReply) + vm.onReplyOpen(true) + assertFalse(vm.uiState.value.replyOpen) + } + + @Test fun `mark solved, the server's limit for replies, and photos fetched once`() = runTest(dispatcher) { + val fake = Fake(mutableListOf(loaded(message("m1", false), message("m2", true)))) + fake.maxBody = 5 + val vm = FeedbackThreadViewModel(fake.support(), "t") + advanceUntilIdle() + vm.solve() + advanceUntilIdle() + assertEquals(1, fake.solves) + vm.onReplyOpen(true) + vm.onReplyChanged("123456789") + assertEquals("12345", vm.uiState.value.reply) + vm.photo("p1") + vm.photo("p1") + assertEquals(1, fake.photoCalls) + } +} 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 40792e0bf..ba487285a 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 @@ -139,6 +139,7 @@ class FeedbackViewModelTest { val state = vm.uiState.value assertTrue(state.isSuccess) assertTrue(state.repliesInApp) + assertEquals("Refreshed on open and again after the first conversation", 2, support.refreshes) assertNull(prefs.getFeedbackDraft()) val sent = support.sent.single() assertEquals("audio", sent.category) diff --git a/release-notes/upcoming.md b/release-notes/upcoming.md index 7c249cec7..6d7c18560 100644 --- a/release-notes/upcoming.md +++ b/release-notes/upcoming.md @@ -34,7 +34,7 @@ Announcement title: - On New Episodes, swipe right to add an episode to your queue or left to mark it played, and pull down to check for new episodes. (#1198) - Listening in German, French, Spanish, Portuguese, Russian or Dutch? You can now turn English off in Library settings › Languages, and boxlore then adds English shows to recommendations, search and suggestions only when there aren't enough in your languages. (#1187, #1191) - Free up space with Clear saved shows in Library settings: it removes the episode lists saved for shows you opened but don't follow. (#1192) -- Feedback is now a conversation: pick a type, write your message and send, no email needed. The boxlore team's reply shows up in Feedback, and a notification tells you when it arrives if you turn them on. +- Feedback is now a conversation: pick a type, write your message and send, no email needed. The boxlore team's reply shows up in Feedback, Home's Feedback button lights up, and a notification tells you when it arrives if you turn them on. ## Improved From a7e8ceeb3d59e8f4b30cb7edda2e75080586997d Mon Sep 17 00:00:00 2001 From: Aswin C Date: Mon, 2 Nov 2026 02:56:34 +0530 Subject: [PATCH 5/8] fix(feedback): keep retry keys across restarts and send the account token A send's retry key is kept until the message is saved or discarded, so a retry after the app was closed isn't saved twice, and photos already uploaded are reused. Support requests carry the signed-in account's ID token from the first request, so conversations link to the account, and a missing device-key proof is no longer retried with a token. --- .../cx/aswin/boxlore/BoxLoreApplication.kt | 9 +++ core/network/README.md | 4 +- .../core/network/AccountTokenInterceptor.kt | 22 ++++++ .../boxlore/core/network/NetworkModule.kt | 17 ++++- .../core/network/session/ApiErrorCodes.kt | 6 +- .../core/network/session/RequestProofTest.kt | 21 ++++++ feature/settings/README.md | 4 +- .../settings/feedback/FeedbackViewModel.kt | 72 +++++++++++++++++-- .../feedback/FeedbackViewModelTest.kt | 32 ++++++++- 9 files changed, 173 insertions(+), 14 deletions(-) create mode 100644 core/network/src/main/java/cx/aswin/boxlore/core/network/AccountTokenInterceptor.kt diff --git a/app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt b/app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt index 3e9db3f28..16262f27f 100644 --- a/app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt +++ b/app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt @@ -170,6 +170,12 @@ class BoxLoreApplication : cx.aswin.boxlore.core.model.EpisodeShareTargetSource { container.episodeSharePreparer.targetFor(it) } NetworkModule.authenticator = FirebaseAuthAuthenticator(container.authRepository) + // Support requests carry the account's cached token, so conversations follow the account. + NetworkModule.accountToken = { + kotlinx.coroutines.runBlocking { + kotlinx.coroutines.withTimeoutOrNull(ACCOUNT_TOKEN_WAIT_MS) { container.authRepository.getIdToken(forceRefresh = false) } + } + } cx.aswin.boxlore.lifecycle.AutoDownloadLifecycle(this, container, applicationScope).start() HomeScreenWidgetsInstaller.install( context = this, @@ -514,3 +520,6 @@ internal object CastSessionTransferPolicy { } private const val CAST_SESSION_RECHECK_DELAY_MS = 15_000L + +/** How long a support request waits for the account's token before going without it. */ +private const val ACCOUNT_TOKEN_WAIT_MS = 3_000L diff --git a/core/network/README.md b/core/network/README.md index 37152d00e..dc7369083 100644 --- a/core/network/README.md +++ b/core/network/README.md @@ -21,7 +21,7 @@ Owns the Retrofit API boundary, OkHttp/Retrofit construction, request and respon - `NetworkModule` creates OkHttp, Retrofit, and related network clients. `createCachedClient(context)` builds the shared client with the 50 MiB HTTP cache; build `BoxLoreApi` and `BoxLoreApiV2` on that one client (`createBoxLoreApi(baseUrl, client)`, `createBoxLoreApiV2(baseUrl, client)`), because two caches must never share a directory. There is no context-based `createBoxLoreApi` shortcut, so nothing can open a second cache on the same directory; tests pass their own client. The clients come with automatic header redaction for `Authorization`, `X-App-Key`, `X-Firebase-AppCheck`, `X-Boxlore-Session`, `X-Session-Renew` and `X-Session-Proof`. Every request carries `X-App-Version` and `X-App-Channel` (`play` or `github`) when the app sets `appVersion` / `appChannel`. `createSessionExchangeClient()` is the plain client for `POST /auth/session` (no session interceptor, no Firebase authenticator, no cache). - **English off (D9, 29 Oct).** `EnglishOffInterceptor` (first on the API client) adds `englishOff` to any request whose languages leave English out, which happens only when the listener switched it off: `englishOff=1` on a `languages` query, `"englishOff": true` in a JSON body with a `languages` array. Requests with English, without languages or that already say pass through byte for byte (`EnglishOffInterceptorTest`). - **API sessions (`session/`, 2026-10-17).** This build authenticates with API session tickets only and **never sends the bundled app key**: `ApiSessionInterceptor` strips `X-App-Key` from every request. `NetworkModule.sessionManager` is set by the app at startup. -- **Request proofs (`session/RequestProofInterceptor`, 2026-11-02).** A session can be asked for with any install id, so routes holding an install's private data need proof the request comes from the phone that registered the session's device key. `RequestProofSigner` (`ApiSessionManager.requestProofs`) signs `bls-request....` (API clock) with the device key, and `RequestProofInterceptor`, a network interceptor that `NetworkModule.createSupportApi` adds to the support client only, sends it as `X-Request-Proof` (redacted in logs). No device key or no session means no header, and the API answers 401. +- **Request proofs (`session/RequestProofInterceptor`, 2026-11-02).** A session can be asked for with any install id, so routes holding an install's private data need proof the request comes from the phone that registered the session's device key. `RequestProofSigner` (`ApiSessionManager.requestProofs`) signs `bls-request....` (API clock) with the device key, and `RequestProofInterceptor`, a network interceptor that `NetworkModule.createSupportApi` adds to the support client only, sends it as `X-Request-Proof` (redacted in logs). No device key or no session means no header, and the API answers 401. The support client also carries the signed-in account's Firebase ID token from the first request (`AccountTokenInterceptor`, fed by `NetworkModule.accountToken`, which the app sets to the cached token with a 3-second wait; nothing is added when signed out or when a request already has `Authorization`), so the API links conversations to the account. `device_key_required` and `proof_required` count as shield codes, so `FirebaseAuthAuthenticator` doesn't retry them with a token. - `ApiSessionManager` owns the install's session. One shared `POST /auth/session` exchange runs at a time; `start()` begins it at launch when no usable session is stored, and requests wait for it (at most 15 s) only then: a fresh install, or after the 30-day attestation window. With a stored session, requests attach it without waiting. Failed exchanges back off (2 s, 5 s, 15 s, 30 s, 1 min, then every 5 min, or the server's `Retry-After`); inside a backoff, requests go out without a session at once. There is no app-key fallback. - Exchanges send `{installId, platform: "android", channel, devicePublicKey}` (plus `turnstileToken` for a step-up) with `X-Firebase-AppCheck`. App Check is fetched only here, bounded to 6 s; a phone that has failed App Check gets its next session without asking App Check on the critical path. Background upgrades and refreshes give it 30 s instead (`backgroundAppCheckTimeoutMs`): they run while a usable session is held, so no request waits, and slow Play Integrity has time to finish. - Renewal: once a token is past `exp` (30 s early, for requests in flight), requests add `X-Session-Proof: .`, a SHA256withECDSA signature of `bls-renew..` by the device key (`AndroidKeystoreDeviceKey`: non-exportable EC P-256 in StrongBox when available, else the TEE). One proof is reused for up to 2 minutes. `X-Session-Renew` from a network response (never a cached one) replaces the token when it extends the same session. The API clock is learned from each token's `iat`, so a wrong phone clock does not break renewals. @@ -95,7 +95,7 @@ src/main/java/cx/aswin/boxlore/core/network/ - Prefer MockWebServer over live backend calls. - `session/ApiSessionInterceptorTest` (MockWebServer): a stored session goes out at once while an exchange hangs and no request carries `X-App-Key`; one exchange serves concurrent first requests; `X-Session-Renew` is stored and foreign or older renewals are ignored; the proof appears only from 30 s before `exp`; a missing device key sends no proof and reports it once; a refused session is replaced once; a brand-new session refused again backs off; failed exchanges back off without an app-key fallback; App Check failure gives a limited session and the next launch skips App Check; the step-up retries with a `challenge` session, a failed step-up returns the 401 and cools down, and it stays off until enabled; `daily_ai_limit` passes through; other hosts get no session. - `session/ApiSessionSchedulerTest` (virtual time): limited → full upgrade on the 1 min / hourly / daily schedule, a slow App Check that times out at launch but passes on the background upgrade, nothing in the background or offline, an overdue retry when the network returns, and the pure next-step plan. `ApiSessionPolicyTest` covers token parsing, proof timing, usability, replacement and backoffs. `OkHttpSessionExchangerTest` covers the `/auth/session` body, headers and failure reasons. `FirebaseAuthAuthenticatorTest` checks shield 401s are left alone. -- `session/RequestProofTest`: the signed message and its clock, no proof without a device key or a readable session, and the interceptor signing only requests that carry a session. +- `session/RequestProofTest`: the signed message and its clock, no proof without a device key or a readable session, the interceptor signing only requests that carry a session, and the account token added when signed in (kept when a request has its own, none when signed out). - `BoxLoreApiV2ContractTest` (MockWebServer) checks every `/v2` path (also under a base URL with a path prefix) and that no request carries `X-App-Key`; the exact request JSON of shows/feeds, feed reports, registration, recommendations (with `contractVersion: 2`), bootstrap (with and without its current-algorithm recommendations part), similar episodes (a recipe ID `id`, `limit` left at its default is not sent) and because-you-like (with and without `excludePodcastId` and `country`) (defaults are not encoded, so only explicitly set fields go out); decoding of show addresses (including `fetchUrl: null` and proxied shows), that a proxied show is read with `getFeed` on the base URL and never at the server's `transportUrl`, `needsFeed` with and without an address, a known episode with its identity, the fallback list, a 404 and a 304; that feed documents never enter the HTTP cache; and briefing URLs. `model/V2ModelsTest` covers the range checks (including the episode-route show limit and `/v2/episodes` paging bounds), strict show ID parsing, request validation (including the 2,048-character catalog key) and read targets. - `BoxLoreApiSyncContractTest` (MockWebServer) checks the push and pull JSON: each row's `recipeId` and `catalogKey` are sent only when set, the rich removals are sent (an empty list too) and left out only when unset, a pull sends `richRemovals: true` only when asked and sends nothing for the defaults, and a pull answer recorded in the sync API's shape decodes (recipe fields as numbers or null, the rich removals present or absent), plus the range checks of `SyncEpisodeIdentityFields`. - `OnboardingGuidedContractTest` checks legacy decoding, request-specific opt-in and structured recovery action decoding, including unknown additive fields, and the finish-hint opt-in (features, country, languages omitted by default; `finish` decoded), plus show deck cards with and without a preview and opt-in reactions. diff --git a/core/network/src/main/java/cx/aswin/boxlore/core/network/AccountTokenInterceptor.kt b/core/network/src/main/java/cx/aswin/boxlore/core/network/AccountTokenInterceptor.kt new file mode 100644 index 000000000..99a4660b4 --- /dev/null +++ b/core/network/src/main/java/cx/aswin/boxlore/core/network/AccountTokenInterceptor.kt @@ -0,0 +1,22 @@ +package cx.aswin.boxlore.core.network + +import okhttp3.Interceptor +import okhttp3.Response + +/** + * Adds the signed-in account's Firebase ID token (`Authorization: Bearer`) to requests that don't + * carry one, so routes that link data to an account (support) see it from the first request + * instead of only after a 401. Signed out, [token] returns null and nothing is added. + */ +class AccountTokenInterceptor(private val token: () -> String?) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + if (request.header(AUTHORIZATION) != null) return chain.proceed(request) + val idToken = runCatching { token() }.getOrNull()?.takeIf { it.isNotBlank() } ?: return chain.proceed(request) + return chain.proceed(request.newBuilder().header(AUTHORIZATION, "Bearer $idToken").build()) + } + + private companion object { + const val AUTHORIZATION = "Authorization" + } +} diff --git a/core/network/src/main/java/cx/aswin/boxlore/core/network/NetworkModule.kt b/core/network/src/main/java/cx/aswin/boxlore/core/network/NetworkModule.kt index 5551b34f3..256905350 100644 --- a/core/network/src/main/java/cx/aswin/boxlore/core/network/NetworkModule.kt +++ b/core/network/src/main/java/cx/aswin/boxlore/core/network/NetworkModule.kt @@ -44,6 +44,15 @@ object NetworkModule { private val sessionInterceptor = ApiSessionInterceptor { sessionManager } + /** + * The signed-in account's Firebase ID token (cached, may block briefly on an OkHttp thread), set + * by app wiring. Support requests carry it so the API links conversations to the account. + */ + @Volatile + var accountToken: (() -> String?)? = null + + private val accountTokenInterceptor = AccountTokenInterceptor { accountToken?.invoke() } + /** Signs support requests with the device key (see [RequestProofInterceptor]). */ private val requestProofInterceptor = RequestProofInterceptor { token, method, path -> sessionManager?.requestProofs?.proof(token, method, path) } @@ -130,10 +139,14 @@ object NetworkModule { /** * Builds [SupportApi] on [client] (production: the cached client from [createCachedClient]), - * with every request signed by the device key, which the support routes require. + * with every request signed by the device key, which the support routes require, and the + * account's ID token when signed in ([accountToken]). */ fun createSupportApi(baseUrl: String, client: OkHttpClient): SupportApi = - retrofit(baseUrl, client.newBuilder().addNetworkInterceptor(requestProofInterceptor).build()).create(SupportApi::class.java) + retrofit( + baseUrl, + client.newBuilder().addInterceptor(accountTokenInterceptor).addNetworkInterceptor(requestProofInterceptor).build(), + ).create(SupportApi::class.java) private fun retrofit(baseUrl: String, client: OkHttpClient): Retrofit = Retrofit.Builder() .baseUrl(baseUrl) diff --git a/core/network/src/main/java/cx/aswin/boxlore/core/network/session/ApiErrorCodes.kt b/core/network/src/main/java/cx/aswin/boxlore/core/network/session/ApiErrorCodes.kt index 25dd11749..c1d8ab40b 100644 --- a/core/network/src/main/java/cx/aswin/boxlore/core/network/session/ApiErrorCodes.kt +++ b/core/network/src/main/java/cx/aswin/boxlore/core/network/session/ApiErrorCodes.kt @@ -13,11 +13,15 @@ object ApiErrorCodes { const val VERIFICATION_REQUIRED = "verification_required" const val DAILY_AI_LIMIT = "daily_ai_limit" + /** Support routes: the session has no device key, or the request wasn't signed with it. */ + const val DEVICE_KEY_REQUIRED = "device_key_required" + const val PROOF_REQUIRED = "proof_required" + /** The session itself was refused: get a new one once, then retry. */ val SESSION_REJECTIONS = setOf(SESSION_EXPIRED, SESSION_INVALID, SESSION_REVOKED) /** Answers about the API session, never about a Firebase sign-in. */ - val SHIELD_CODES = SESSION_REJECTIONS + setOf(SESSION_REQUIRED, VERIFICATION_REQUIRED, DAILY_AI_LIMIT) + val SHIELD_CODES = SESSION_REJECTIONS + setOf(SESSION_REQUIRED, VERIFICATION_REQUIRED, DAILY_AI_LIMIT, DEVICE_KEY_REQUIRED, PROOF_REQUIRED) /** AI routes refused for this session even after any step-up: onboarding uses its non-AI path. */ val AI_UNAVAILABLE = setOf(VERIFICATION_REQUIRED, DAILY_AI_LIMIT) diff --git a/core/network/src/test/java/cx/aswin/boxlore/core/network/session/RequestProofTest.kt b/core/network/src/test/java/cx/aswin/boxlore/core/network/session/RequestProofTest.kt index fac9ef48a..e1a9b4077 100644 --- a/core/network/src/test/java/cx/aswin/boxlore/core/network/session/RequestProofTest.kt +++ b/core/network/src/test/java/cx/aswin/boxlore/core/network/session/RequestProofTest.kt @@ -47,6 +47,27 @@ class RequestProofTest { assertNull(manager().requestProofs.proof(sessionToken(), "GET", "/support/threads")) } + @Test + fun `support requests carry the account token when signed in, and nothing when signed out`() { + val server = MockWebServer().apply { start() } + try { + var token: String? = "id-token-1" + val client = OkHttpClient.Builder().addInterceptor(cx.aswin.boxlore.core.network.AccountTokenInterceptor { token }).build() + server.enqueue(MockResponse()) + server.enqueue(MockResponse()) + server.enqueue(MockResponse()) + client.newCall(Request.Builder().url(server.url("/support/threads")).build()).execute().close() + client.newCall(Request.Builder().url(server.url("/support/threads")).header("Authorization", "Bearer other").build()).execute().close() + token = null + client.newCall(Request.Builder().url(server.url("/support/threads")).build()).execute().close() + assertEquals("Bearer id-token-1", server.takeRequest().getHeader("Authorization")) + assertEquals("Bearer other", server.takeRequest().getHeader("Authorization")) + assertNull(server.takeRequest().getHeader("Authorization")) + } finally { + server.shutdown() + } + } + @Test fun `the interceptor signs the session the request carries, and leaves other requests alone`() { val server = MockWebServer().apply { start() } diff --git a/feature/settings/README.md b/feature/settings/README.md index 19f32ab73..186a23bbe 100644 --- a/feature/settings/README.md +++ b/feature/settings/README.md @@ -21,7 +21,7 @@ Owns the unified Settings hub, category pages (Account, Sync & Backups, Appearan - Shared `SettingsScaffold` headers use a smaller 36sp expanded title and naturally wrap to two lines when needed, including Cloud Sync & Backups. The expanded title smoothly reduces to 22sp on scroll; the separate compact row always uses 22sp and retains two-line support for narrow screens and larger system fonts. Material's two-row app bar measures the title height, so a hidden expanded title cannot inflate the navigation row or force a single-line ellipsis. - `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. `FeedbackScreen(supportRepository, boxcastPrefs, onBack, bottomContentPadding)` takes `SupportRepository` from `:core:support` (app wiring passes the mini player clearance). -- **Feedback, redesigned 2026-11-02 (support conversations, phase 2).** "Share your thoughts" opens straight on the form: bold circular type pills (`FeedbackTypePills`), a roomy message card that takes the free height, photos (only while the server's `meta.photos` switch is on; picked with the system photo picker, no storage permission, downscaled to 1600 px JPEG by `PhotoPrep`; the row scrolls sideways so every photo and its Remove stay reachable on narrow phones), an Include device diagnostics row, **Send feedback** at the bottom and a **How we handle feedback** link to the privacy page. The pills are the server's `meta.categories` (id and label, shown as sent); the app's own four (Feature idea, Bug report, Audio / Stream, General) are the offline list and give icons and hints for the ids they share (`FeedbackChoice`), and an id the server stops offering falls back to its first. The message limit is the server's `maxBody` as is; typing stops there, and an older draft past it (its steps now join the message) is kept whole and can only get shorter, with the counter in red and "This is over the …-character limit. Shorten it to send." until it fits (`limitedMessage`). The phone model goes only with diagnostics on. While a message is sending its draft is locked (field read-only, pills, switch and photos disabled), so what was sent is what gets cleared. Windows shorter than 600dp scroll with a 220dp message card. While typing (message focused and the keyboard up) the pills fold into one pill in the header and a slim bar with photos, diagnostics and **Send** rides on the keyboard, which replaces the bottom chrome padding (`isTypingLayout`, `feedbackBottomPadding`). It is one layout throughout: the keyboard or a height change only changes what sits around the message field, so the field keeps its focus, cursor and the text being composed. The top-bar menu has Preview diagnostics, Rate on Play, Report on GitHub and Discard draft. The email and steps fields are gone. Send is disabled while the server pauses new messages (`canStartNew`), with the server's notice or `PAUSED_NOTICE`; a refusal (today's limit) shows the server's words and keeps the draft. Each send carries a retry key (`RetryKeys`): sending the same message again reuses it, an edited message gets a new one, so a send whose answer was lost isn't saved twice. When support can't take photos (it is unavailable), the message goes without them. After sending, `FeedbackSentScreen` shows Echo's `feedback-sent` sequence (magnifier, then rest), "Feedback sent" and, when the message became a conversation and notifications are off, a **Know when the team replies** card (`canAskForNotifications`: Android's prompt while it will still show, otherwise the app's notification settings; re-checked on resume, **Not now** hides it). A message that fell back to the old one-way form (`SendResult.SentByEmailForm`) gets neither the reply promise nor the card, and left-out photos are mentioned (`feedbackSentBody`). +- **Feedback, redesigned 2026-11-02 (support conversations, phase 2).** "Share your thoughts" opens straight on the form: bold circular type pills (`FeedbackTypePills`), a roomy message card that takes the free height, photos (only while the server's `meta.photos` switch is on; picked with the system photo picker, no storage permission, downscaled to 1600 px JPEG by `PhotoPrep`; the row scrolls sideways so every photo and its Remove stay reachable on narrow phones), an Include device diagnostics row, **Send feedback** at the bottom and a **How we handle feedback** link to the privacy page. The pills are the server's `meta.categories` (id and label, shown as sent); the app's own four (Feature idea, Bug report, Audio / Stream, General) are the offline list and give icons and hints for the ids they share (`FeedbackChoice`), and an id the server stops offering falls back to its first. The message limit is the server's `maxBody` as is; typing stops there, and an older draft past it (its steps now join the message) is kept whole and can only get shorter, with the counter in red and "This is over the …-character limit. Shorten it to send." until it fits (`limitedMessage`). The phone model goes only with diagnostics on. While a message is sending its draft is locked (field read-only, pills, switch and photos disabled), so what was sent is what gets cleared. Windows shorter than 600dp scroll with a 220dp message card. While typing (message focused and the keyboard up) the pills fold into one pill in the header and a slim bar with photos, diagnostics and **Send** rides on the keyboard, which replaces the bottom chrome padding (`isTypingLayout`, `feedbackBottomPadding`). It is one layout throughout: the keyboard or a height change only changes what sits around the message field, so the field keeps its focus, cursor and the text being composed. The top-bar menu has Preview diagnostics, Rate on Play, Report on GitHub and Discard draft. The email and steps fields are gone. Send is disabled while the server pauses new messages (`canStartNew`), with the server's notice or `PAUSED_NOTICE`; a refusal (today's limit) shows the server's words and keeps the draft. Each send carries a retry key (`RetryKeys`): sending the same message again reuses it, an edited message gets a new one, so a send whose answer was lost isn't saved twice. The key is kept in `boxlore_feedback_retry` (`SharedPrefsRetryKeyStore`, a fingerprint of the content and the key) until the message is saved or discarded, so it survives the screen closing or the app being killed; photos already uploaded for the message are reused on a retry. When support can't take photos (it is unavailable), the message goes without them. After sending, `FeedbackSentScreen` shows Echo's `feedback-sent` sequence (magnifier, then rest), "Feedback sent" and, when the message became a conversation and notifications are off, a **Know when the team replies** card (`canAskForNotifications`: Android's prompt while it will still show, otherwise the app's notification settings; re-checked on resume, **Not now** hides it). A message that fell back to the old one-way form (`SendResult.SentByEmailForm`) gets neither the reply promise nor the card, and left-out photos are mentioned (`feedbackSentBody`). - **Feedback conversations (support conversations, phase 3).** `FeedbackScreen(..., openThreadId)` opens on one conversation for a reply notification (`feedback?thread=`), on the list once the phone has conversations, and on "Share your thoughts" otherwise (`initialFeedbackPane`; the pane is saved with `FeedbackPaneSaver`). The list (`FeedbackListScreen`) shows "Your feedback" in one card, newest first: an unread dot and bold title for a new reply, the server's status label with its time ("11:02", "Yesterday", "28 Oct"; `feedbackListTime`), a check for Solved and a lock with a dimmed title for Closed. Rate on Play, Report on GitHub and **Send feedback** are pinned at the bottom with the list scrolling behind them; each **Send feedback** opens a fresh form (a new view-model key; the saved draft still comes back). After sending, the list refreshes at once (so the phone joins its reply-push topic before a reply can come), and Done goes to the list when the message became a conversation. A conversation (`FeedbackThreadScreen`, `FeedbackThreadViewModel`) shows the listener's messages in outlined cards and the team's in a primary-container card ("boxlore team"), with photos fetched once through the support API and "Diagnostics included"; **Show earlier messages** pages back. Opening an unread one marks it read, which clears the list's dot and Home's indicator. **Reply** opens a reply box that rides on the keyboard (the server's message limit, a retry key per reply, the draft kept on failure); **Mark solved** shows once the team has answered an open conversation. The server's ending card closes it: Solved (tertiary container, check, the server's words, Reply) or Closed (lock, the server's words, **Send new feedback**). Back walks conversation → list → out, and a new message goes back to the list when there is one (`feedbackBack`). On a landscape tablet with at least 840dp for Feedback (`feedbackUsesSplit`) the list (400dp) and the conversation sit side by side: the picked conversation, else the newest, else a new message; Back then leaves. The open conversation survives resizes and recreation in both directions. - `AutoDownloadSettingsScreen` and `SmartDownloadsSettingsScreen` under `downloads/` for download management. - `AccountSettingsPage`, `SyncAndBackupsPage`, `AppearanceSettingsPage`, `PlaybackSettingsPage`, `PrivacySettingsPage`, `LibrarySettingsPage`, `DownloadsSettingsPage`, `AboutSettingsPage`, `SupportDevelopmentPage`. @@ -183,7 +183,7 @@ src/main/java/cx/aswin/boxlore/feature/settings/ - `SupportPageLayoutTest` covers the arrangement thresholds (full stacked page, side by side, mission card cut-off, larger text) and renders the control and every world in 392×800 and 360×600 portrait and 800×392 landscape windows with no scrollable node, the pay button and tier info fully on screen, and the mission card shown only when it fits. - `SupportEntryImpressionTest` covers one impression per view, none for a configuration restore, a new one after returning, and the real Settings hub's top-bar surface. - Not covered by JVM tests: rendering quality on real devices and fold postures. Those need a phone, tablet or foldable check. -- Feedback: `FeedbackViewModelTest` covers draft restore (old steps joined), a draft past the limit kept whole until shortened, a server limit above 2,000, a conversation send (body trimmed, diagnostics, analytics without the text), the phone model only with diagnostics, the server's categories (an unknown id falls back, a new one is sent as is and counts as other in analytics), the old-form fallback without the reply promise, refusal and failure keeping the draft with a retry, blank messages, the draft locked while sending, photos ignored while switched off, de-duplicated picks up to the limit uploaded in order before the message, a failed or oversized photo sending nothing, photos left out when support can't take them, paused sending, retry keys (kept for a resend, new after an edit or a success), and Discard draft. `FeedbackLayoutPolicyTest` covers the typing-layout and keyboard-clearance rules, `limitedMessage`, when Android's notification prompt can still show, and the sent-screen copy. `FeedbackAdaptiveTransitionTest` (Robolectric) holds the view model outside composition, as the navigation entry does in the app, so it covers the screen's own state rather than the view model's lifetime. It keeps one live screen while the window crosses the 600dp width, 480dp height and 840dp split gates in both directions, portrait/landscape tablets, a short 800×360 window that scrolls, and a fold going flat → tabletop → flat, then recreates it (`StateRestorationTester`): the typed message, the picked type and a reachable, enabled Send survive, Send goes out once, and the sent screen keeps Done and the notifications card (or its dismissal) through the same walk; an old-form send never shows the card. With the keyboard played by passing its height in, the message field stays focused with its text as the keyboard opens and closes twice and the window goes short and tall, and typing continues where it was. On a 360dp phone all five photos and their Remove buttons are reachable. Not covered by JVM tests: a real keyboard's animation and composition, the photo picker, and Echo's motion; those need a phone and tablet check. Manual acceptance: on a phone, a tablet and a foldable, write a message, rotate and fold with the keyboard up and down, then send; the draft stays, the cursor stays in the field, Send stays reachable above the keyboard, and the sent screen offers notifications only while they are off. +- Feedback: `FeedbackViewModelTest` covers draft restore (old steps joined), a draft past the limit kept whole until shortened, a server limit above 2,000, a conversation send (body trimmed, diagnostics, analytics without the text), the phone model only with diagnostics, the server's categories (an unknown id falls back, a new one is sent as is and counts as other in analytics), the old-form fallback without the reply promise, refusal and failure keeping the draft with a retry, blank messages, the draft locked while sending, photos ignored while switched off, de-duplicated picks up to the limit uploaded in order before the message, a failed or oversized photo sending nothing, photos left out when support can't take them, paused sending, retry keys (kept for a resend, new after an edit or a success, the same after the screen is rebuilt, with photos uploaded once), and Discard draft. `FeedbackLayoutPolicyTest` covers the typing-layout and keyboard-clearance rules, `limitedMessage`, when Android's notification prompt can still show, and the sent-screen copy. `FeedbackAdaptiveTransitionTest` (Robolectric) holds the view model outside composition, as the navigation entry does in the app, so it covers the screen's own state rather than the view model's lifetime. It keeps one live screen while the window crosses the 600dp width, 480dp height and 840dp split gates in both directions, portrait/landscape tablets, a short 800×360 window that scrolls, and a fold going flat → tabletop → flat, then recreates it (`StateRestorationTester`): the typed message, the picked type and a reachable, enabled Send survive, Send goes out once, and the sent screen keeps Done and the notifications card (or its dismissal) through the same walk; an old-form send never shows the card. With the keyboard played by passing its height in, the message field stays focused with its text as the keyboard opens and closes twice and the window goes short and tall, and typing continues where it was. On a 360dp phone all five photos and their Remove buttons are reachable. Not covered by JVM tests: a real keyboard's animation and composition, the photo picker, and Echo's motion; those need a phone and tablet check. Manual acceptance: on a phone, a tablet and a foldable, write a message, rotate and fold with the keyboard up and down, then send; the draft stays, the cursor stays in the field, Send stays reachable above the keyboard, and the sent screen offers notifications only while they are off. - Feedback conversations: `FeedbackPanesTest` covers the opening pane, Back, the split gate (839/840dp, portrait, short), the saved pane and the times. `FeedbackThreadViewModelTest` covers marking an unread conversation read, gone and failed loads with a retry, earlier pages going first, a failed reply keeping its text and retry key, a refusal, a closed conversation reloading to its ending, Mark solved, the reply limit and photos fetched once. `FeedbackConversationsAdaptiveTest` (Robolectric, the real `FeedbackScreen` on a fake support API) keeps the open conversation through phone ↔ split both ways across the 840dp gate, a portrait tablet, a short window and a fold, and through recreation; Back goes to the list, then out; side by side the newest conversation shows until another is picked and an unread one is marked read; a notification's id opens its conversation; and a phone without conversations opens on Share your thoughts. Not covered by JVM tests: real photos, the reply keyboard on a device, and push delivery. - Unit tests live under `feature/settings/src/test`. - `AccountPagePresentationTest` covers the onboarding and Settings layouts, picking each sign-in method and getting back to the cards, the check-email and confirm-email screens (resend cooldown), every sync state with its action, the Sync now label at 200% text in RTL, password-only Reset password, Echo press feedback, and that every Echo pose the page uses ships in the Echo manifest with a stage reach. `AccountPageAdaptiveTest` keeps one live page while the window moves phone → 599/600dp → tablet split → 479/480dp tall → portrait tablet → phone and checks that the chosen method, the typed email and the password mode survive, in both directions, and across activity recreation (`StateRestorationTester`). It also keeps a live syncing onboarding page across the same windows: the No need to wait note stays on screen above the tiles with Continue reachable, and finishing sync mid-resize turns all three tiles done and removes the note. Tests hold Echo on its resting pose with `LocalEchoMotion provides false` so the screen can go idle. App navigation tests cover hiding restored playback during Account and onboarding and restoring normal player presentation afterward. 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 3728432ea..680ee89ae 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 @@ -16,6 +16,7 @@ import cx.aswin.boxlore.core.support.SendResult import cx.aswin.boxlore.core.support.SupportMeta import cx.aswin.boxlore.core.support.SupportRepository import java.net.URLEncoder +import java.security.MessageDigest import java.util.UUID import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers @@ -100,21 +101,70 @@ internal fun limitedMessage(current: String, next: String, limit: Int): String = else -> next.take(limit) } +/** Where a retry key outlives the screen: (fingerprint of the content, key). */ +interface RetryKeyStore { + fun load(): Pair? + + fun save(fingerprint: String, key: String) + + fun clear() +} + +/** [RetryKeyStore] in the feature's own preferences file, so a send retried after the app was closed keeps its key. */ +class SharedPrefsRetryKeyStore(context: Context) : RetryKeyStore { + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + override fun load(): Pair? { + val fingerprint = prefs.getString(KEY_FINGERPRINT, null) ?: return null + val key = prefs.getString(KEY_REQUEST, null) ?: return null + return fingerprint to key + } + + override fun save(fingerprint: String, key: String) { + prefs.edit().putString(KEY_FINGERPRINT, fingerprint).putString(KEY_REQUEST, key).apply() + } + + override fun clear() { + prefs.edit().clear().apply() + } + + private companion object { + const val PREFS_NAME = "boxlore_feedback_retry" + const val KEY_FINGERPRINT = "fingerprint" + const val KEY_REQUEST = "request_id" + } +} + /** * One retry key per message: sending the same content again (after a failure whose answer may - * have been lost) reuses the key, so the API saves it once; changed content gets a new key. + * have been lost) reuses the key, so the API saves it once; changed content gets a new key. With a + * [store] the key also survives the screen closing or the app being killed. */ -internal class RetryKeys(private val newKey: () -> String = { UUID.randomUUID().toString() }) { +internal class RetryKeys( + private val newKey: () -> String = { UUID.randomUUID().toString() }, + private val store: RetryKeyStore? = null, +) { private var last: Pair? = null fun keyFor(content: Any): String { last?.let { (seen, key) -> if (seen == content) return key } - return newKey().also { last = content to it } + val fingerprint = fingerprintOf(content) + store?.load()?.let { (saved, key) -> + if (saved == fingerprint) return key.also { last = content to it } + } + return newKey().also { + last = content to it + store?.save(fingerprint, it) + } } fun clear() { last = null + store?.clear() } + + private fun fingerprintOf(content: Any): String = + MessageDigest.getInstance("SHA-256").digest(content.toString().toByteArray()).joinToString("") { "%02x".format(it) } } /** What the screen needs from support; the factory wires [SupportRepository], tests pass fakes. */ @@ -134,15 +184,17 @@ private sealed interface PhotoBatch { data object Failed : PhotoBatch } -class FeedbackViewModel( +class FeedbackViewModel internal constructor( private val support: FeedbackSupport, private val boxcastPrefs: BoxcastPrefs, private val context: Context, private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, private val readPhoto: (Uri) -> ByteArray? = { uri -> PhotoPrep.prepare(context.contentResolver, uri) }, + private val retryKeys: RetryKeys = RetryKeys(), ) : ViewModel() { - private val retryKeys = RetryKeys() + /** Photos already uploaded for this message, so a retry doesn't upload (and count) them again. */ + private val uploadedPhotos = mutableMapOf() private val _uiState = MutableStateFlow(FeedbackUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -216,6 +268,7 @@ class FeedbackViewModel( fun onDiscardDraft() { if (sending) return boxcastPrefs.clearFeedbackDraft() + retryKeys.clear() _uiState.update { it.copy( categoryId = FeedbackCategory.FEATURE.id, @@ -302,10 +355,15 @@ class FeedbackViewModel( if (!state.meta.photosEnabled) return PhotoBatch.Unavailable val ids = mutableListOf() for (photo in state.photos) { + val uploaded = uploadedPhotos[photo.uri] + if (uploaded != null) { + ids += uploaded + continue + } val bytes = withContext(ioDispatcher) { readPhoto(photo.uri) } ?: return PhotoBatch.Failed if (state.meta.maxPhotoBytes > 0 && bytes.size > state.meta.maxPhotoBytes) return PhotoBatch.Failed when (val upload = support.uploadPhoto(bytes, "image/jpeg")) { - is PhotoUpload.Uploaded -> ids += upload.id + is PhotoUpload.Uploaded -> ids += upload.id.also { uploadedPhotos[photo.uri] = it } PhotoUpload.Unavailable -> return PhotoBatch.Unavailable PhotoUpload.Failed -> return PhotoBatch.Failed } @@ -331,6 +389,7 @@ class FeedbackViewModel( ) boxcastPrefs.clearFeedbackDraft() retryKeys.clear() + uploadedPhotos.clear() _uiState.update { it.copy( isSubmitting = false, @@ -364,6 +423,7 @@ class FeedbackViewModel( ), boxcastPrefs = boxcastPrefs, context = context.applicationContext, + retryKeys = RetryKeys(store = SharedPrefsRetryKeyStore(context.applicationContext)), ) as T } } 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 ba487285a..94245c2a4 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 @@ -234,10 +234,40 @@ class FeedbackViewModelTest { assertEquals(keys[2], keys[3]) } + @Test + fun a_retry_key_outlives_the_screen_and_photos_upload_once() = runTest(testDispatcher) { + val saved = object : RetryKeyStore { + var value: Pair? = null + override fun load() = value + override fun save(fingerprint: String, key: String) { value = fingerprint to key } + override fun clear() { value = null } + } + val support = FakeSupport(SupportMeta.Offline.copy(photosEnabled = true, maxPhotos = 3, maxPhotoBytes = 100)).apply { result = SendResult.Failed } + val first = FeedbackViewModel(support.asSupport(), prefs, context, ioDispatcher = testDispatcher, readPhoto = { ByteArray(10) }, retryKeys = RetryKeys(store = saved)) + advanceUntilIdle() + first.onPhotosPicked(listOf(Uri.parse("content://a"))) + first.onMessageChanged("Lost answer") + first.onSubmit() + advanceUntilIdle() + first.onSubmit() + advanceUntilIdle() + assertEquals("One upload for two tries", 1, support.uploads.size) + + // The screen (or the app) went away: a new one sending the same message uses the same key. + val second = FeedbackViewModel(support.asSupport(), prefs, context, ioDispatcher = testDispatcher, readPhoto = { ByteArray(10) }, retryKeys = RetryKeys(store = saved)) + advanceUntilIdle() + second.onPhotosPicked(listOf(Uri.parse("content://a"))) + support.result = SendResult.Sent("t1") + second.onSubmit() + advanceUntilIdle() + assertEquals(1, support.sent.map { it.requestId }.distinct().size) + assertNull("Cleared once it was saved", saved.value) + } + @Test fun retry_keys_change_with_content_and_start_over_once_cleared() { var n = 0 - val keys = RetryKeys { "key-${++n}" } + val keys = RetryKeys(newKey = { "key-${++n}" }) assertEquals("key-1", keys.keyFor(listOf("bug", "a"))) assertEquals("key-1", keys.keyFor(listOf("bug", "a"))) assertEquals("key-2", keys.keyFor(listOf("bug", "b"))) From f5ecdbd5b0956654359c3b2b7b007e99622ee1e0 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Mon, 2 Nov 2026 03:05:43 +0530 Subject: [PATCH 6/8] fix(feedback): keep conversation state across windows and mark only what was read A conversation loads each time it shows, reloads on a reply push, and is marked read only once on screen, up to the newest message. Its reply draft and retry key live in saved state; a closed answer shows its ending at once; photos use a small retrying cache and scroll sideways. The list and conversation keep their places across the split gate, the shown conversation stays selected when narrowing, and a late send no longer clears a newer draft. Push topics follow every refresh, a list from before a push can't clear it, start-up checks count failed attempts, and a reply notification closes an open tablet detail. --- app/README.md | 2 +- .../java/cx/aswin/boxlore/AppContainer.kt | 3 +- .../cx/aswin/boxlore/fcm/BoxLoreFcmService.kt | 2 +- .../cx/aswin/boxlore/fcm/SupportReplies.kt | 12 +- .../navigation/PushTargetRouteAllowlist.kt | 3 + .../cx/aswin/boxlore/ui/BoxLoreAppRoot.kt | 6 +- .../PushTargetRouteAllowlistTest.kt | 4 + core/designsystem/README.md | 2 +- .../aswin/boxlore/core/network/SupportApi.kt | 4 +- .../core/network/model/SupportModels.kt | 3 + core/support/README.md | 12 +- .../core/support/SupportConversations.kt | 6 + .../boxlore/core/support/SupportRepository.kt | 55 +++++-- .../core/support/SupportConversationsTest.kt | 45 +++++- .../components/TopBarUtilityActionsTest.kt | 9 ++ feature/settings/README.md | 4 +- .../settings/feedback/FeedbackListScreen.kt | 9 +- .../settings/feedback/FeedbackPaneRoutes.kt | 36 +++-- .../settings/feedback/FeedbackScreen.kt | 12 +- .../settings/feedback/FeedbackThreadScreen.kt | 42 +++-- .../feedback/FeedbackThreadViewModel.kt | 145 +++++++++++++++--- .../settings/feedback/FeedbackViewModel.kt | 9 +- .../FeedbackConversationsAdaptiveTest.kt | 25 ++- .../feedback/FeedbackThreadViewModelTest.kt | 78 +++++++++- .../feedback/FeedbackViewModelTest.kt | 15 ++ 25 files changed, 462 insertions(+), 81 deletions(-) diff --git a/app/README.md b/app/README.md index 8b98b8573..a305fc001 100644 --- a/app/README.md +++ b/app/README.md @@ -50,7 +50,7 @@ The application module owns the Android app shell: `BoxLoreApplication`, `MainAc - Authentication & Deep Links: `MainActivity` handles incoming App Link intents for `https://boxlore.in/auth` (and `https://aswin.cx/boxlore/auth` from links sent by older builds) to complete passwordless email link sign-in via `authRepository.signInWithEmailLink`. It prevents one-time link replay across activity recreation by nullifying intent data and tracking consumed links, and prompts for the user's email via `CrossDeviceEmailDialog` if a magic link is opened cross-device without local pending auth state. Sign-in goes through `AppContainer.emailLinkSignIn` instead of toasts: each new link raises an Account page request, and `BoxLoreAppRoot` opens Settings on Account & sync (`ACCOUNT_PAGE_ROUTE`, unless `isAccountPage` says it is already showing; before onboarding finishes Settings shows the onboarding version), where the page shows Signing you in, then the first sync, or why the link failed. `accountPageRequestAction` decides each request (ignore one already handled, including across recreation, since the handled value is saved with `rememberSaveable`; only mark it handled when Account & sync is already showing; otherwise open it), and `AccountPageRouteTest` covers it and the route check. `BoxLoreApplication` configures `NetworkModule.authenticator` with `FirebaseAuthAuthenticator` at startup to automatically refresh STS tokens on HTTP 401. - Install attribution: `AppContainer` wires `InstallReferrerManager.onInstallReferrerResolved` → analytics person properties (`install_channel`). Catalog stays free of `:core:analytics`. - FCM (`BoxLoreFcmService` + `NewEpisodeFcmLogic`) owns notification_received / tap extras (`notification_type`, podcast/episode ids for snake+camel keys). Push and episode notifications use bounded slot allocation (16 bounded slots for new episodes, 4 for announcements) with normalized intents (`Intent.ACTION_VIEW`, component set to `MainActivity`, `data = null`, and routes passed in `target_route`) so `Intent.filterEquals()` returns `true` across notifications. This ensures Android's `system_server` reuses `PendingIntentRecord`s via `FLAG_UPDATE_CURRENT` instead of leaking up to the 1,000 UID quota. PendingIntent creations are guarded with `try-catch (e: SecurityException)` falling back to posting notifications without `contentIntent` if quota was exhausted prior to reboot. Download scheduling and notification display fail independently. Payloads without a usable episode ID initially open the podcast page; durable hydration replaces that route with the exact local episode when matched. -- **Feedback replies (support conversations).** `AppContainer.supportRepository` keeps its local state in SharedPreferences `boxlore_support` (`fcm/SupportPrefsStore`: whether the phone has conversations, a reply waiting, the push topics it joined, the last check). After every successful refresh `SupportPushTopicSync` joins and leaves FCM topics to match `meta.pushTopics` (none without conversations), recording only what Firebase confirmed. A `support_reply` data push marks a reply waiting (Home's Feedback button lights up through `LocalFeedbackReplyWaiting`) and posts a notification in the **Feedback replies** channel (`boxlore_feedback_replies_v1`, one of 50 slots per conversation) whose tap opens `feedback?thread=` (allowed by `PushTargetRouteAllowlist`; an id that isn't 24 hex characters opens Feedback). On every app start `checkSupportReplies` refreshes at most every 30 minutes, and only for phones with conversations. The Feedback route keeps the mini player in its small form, as Lore does (`keepsCompactPlayer`). +- **Feedback replies (support conversations).** `AppContainer.supportRepository` keeps its local state in SharedPreferences `boxlore_support` (`fcm/SupportPrefsStore`: whether the phone has conversations, a reply waiting, the push topics it joined, the last check). After every successful refresh (`SupportRepository.refreshed`, even when the list didn't change) `SupportPushTopicSync` joins and leaves FCM topics to match that refresh's `meta.pushTopics` (none without conversations), one plan at a time, recording only what Firebase confirmed, so a topic that failed is planned again next time. A `support_reply` data push marks a reply waiting (Home's Feedback button lights up through `LocalFeedbackReplyWaiting`) and posts a notification in the **Feedback replies** channel (`boxlore_feedback_replies_v1`, one of 50 slots per conversation) whose tap opens `feedback?thread=` (allowed by `PushTargetRouteAllowlist`; an id that isn't 24 hex characters opens Feedback); on a tablet it first closes an open detail (`pushClosesDetails`), so Feedback isn't opened beneath an episode. The push also tells an open conversation or list to reload. On every app start `checkSupportReplies` refreshes at most every 30 minutes, and only for phones with conversations. The Feedback route keeps the mini player in its small form, as Lore does (`keepsCompactPlayer`). - Library backup/import analytics (`trackBackupRestoreResult`, import failed) use allowlisted error codes from `LibraryBackupAnalyticsErrors` — never raw exception text. - `MainActivity` / `BoxLoreAppRoot` own deep-link and session-restore analytics at the shell layer. - `BoxLoreAppRoot` collects the default-off miniplayer seek preference from `:core:prefs` and supplies the same value to Appearance settings and `PlayerSheetLayout`. Appearance callbacks persist it without a feature-to-feature dependency. diff --git a/app/src/main/java/cx/aswin/boxlore/AppContainer.kt b/app/src/main/java/cx/aswin/boxlore/AppContainer.kt index eb5ec0afb..fd13838f2 100644 --- a/app/src/main/java/cx/aswin/boxlore/AppContainer.kt +++ b/app/src/main/java/cx/aswin/boxlore/AppContainer.kt @@ -93,7 +93,6 @@ import cx.aswin.boxlore.sync.SavedKillSwitch import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch /** @@ -388,7 +387,7 @@ class AppContainer( local = supportLocalStore, ).also { repository -> val topicSync = SupportPushTopicSync(repository, SupportPushTopics(supportLocalStore)) - syncScope.launch { repository.threads.filterNotNull().collect { topicSync.sync() } } + syncScope.launch { repository.refreshed.collect { meta -> topicSync.sync(meta) } } } } diff --git a/app/src/main/java/cx/aswin/boxlore/fcm/BoxLoreFcmService.kt b/app/src/main/java/cx/aswin/boxlore/fcm/BoxLoreFcmService.kt index c4c11fdfa..7c6135882 100644 --- a/app/src/main/java/cx/aswin/boxlore/fcm/BoxLoreFcmService.kt +++ b/app/src/main/java/cx/aswin/boxlore/fcm/BoxLoreFcmService.kt @@ -80,7 +80,7 @@ class BoxLoreFcmService : FirebaseMessagingService() { /** The boxlore team replied: Home shows it, and a notification opens the conversation. */ private fun handleSupportReply(data: Map) { - runCatching { (application as BoxLoreApplication).container.supportRepository.onReplyPushed() } + runCatching { (application as BoxLoreApplication).container.supportRepository.onReplyPushed(data["threadId"]) } .onFailure { android.util.Log.w("BoxLoreFcmService", "Reply not recorded", it) } SupportReplyNotifications.show(this, data["threadId"], data["title"], data["body"]) } diff --git a/app/src/main/java/cx/aswin/boxlore/fcm/SupportReplies.kt b/app/src/main/java/cx/aswin/boxlore/fcm/SupportReplies.kt index dd1b26f0f..b6f897b60 100644 --- a/app/src/main/java/cx/aswin/boxlore/fcm/SupportReplies.kt +++ b/app/src/main/java/cx/aswin/boxlore/fcm/SupportReplies.kt @@ -14,6 +14,7 @@ import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import com.google.firebase.messaging.FirebaseMessaging import cx.aswin.boxlore.core.support.SupportLocalStore +import cx.aswin.boxlore.core.support.SupportMeta import cx.aswin.boxlore.core.support.SupportPushTopics import cx.aswin.boxlore.core.support.SupportRepository @@ -37,12 +38,17 @@ class SupportPrefsStore(context: Context) : SupportLocalStore { get() = prefs.getLong(KEY_LAST_CHECKED, 0L) set(value) = prefs.edit().putLong(KEY_LAST_CHECKED, value).apply() + override var lastPushAt: Long + get() = prefs.getLong(KEY_LAST_PUSH, 0L) + set(value) = prefs.edit().putLong(KEY_LAST_PUSH, value).apply() + companion object { const val PREFS_NAME = "boxlore_support" private const val KEY_HAS_CONVERSATIONS = "has_conversations" private const val KEY_REPLY_WAITING = "reply_waiting" private const val KEY_TOPICS = "push_topics" private const val KEY_LAST_CHECKED = "last_checked_at" + private const val KEY_LAST_PUSH = "last_push_at" } } @@ -92,8 +98,10 @@ object SupportReplyNotifications { * (none for a phone without conversations), recording only what Firebase confirmed. */ class SupportPushTopicSync(private val repository: SupportRepository, private val topics: SupportPushTopics) { - fun sync() { - val plan = topics.planFor(repository.meta.value, repository.hasConversations) + /** [meta] is a successful refresh's; one plan at a time, and topics Firebase didn't confirm are planned again next time. */ + @Synchronized + fun sync(meta: SupportMeta) { + val plan = topics.planFor(meta, repository.hasConversations) if (plan.isEmpty) return val messaging = runCatching { FirebaseMessaging.getInstance() }.getOrNull() ?: return plan.subscribe.forEach { topic -> messaging.subscribeToTopic(topic).addOnSuccessListener { topics.joined(topic) } } diff --git a/app/src/main/java/cx/aswin/boxlore/navigation/PushTargetRouteAllowlist.kt b/app/src/main/java/cx/aswin/boxlore/navigation/PushTargetRouteAllowlist.kt index 266c16bd6..7adec25d6 100644 --- a/app/src/main/java/cx/aswin/boxlore/navigation/PushTargetRouteAllowlist.kt +++ b/app/src/main/java/cx/aswin/boxlore/navigation/PushTargetRouteAllowlist.kt @@ -57,3 +57,6 @@ object PushTargetRouteAllowlist { return if (isAllowed(trimmed)) trimmed else null } } + +/** Push targets that replace an open tablet detail instead of opening beneath it: Feedback (a reply notification). */ +fun pushClosesDetails(route: String): Boolean = route == "feedback" || route.startsWith("feedback?") 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 d70944e2d..ade05ebf2 100644 --- a/app/src/main/java/cx/aswin/boxlore/ui/BoxLoreAppRoot.kt +++ b/app/src/main/java/cx/aswin/boxlore/ui/BoxLoreAppRoot.kt @@ -146,7 +146,11 @@ fun BoxLoreAppRoot( ) tabletNavigation.handleDeepLink(if (intent.data != null) intent else deepLinkIntent) }, - navigate = { tabletNavigation.navigate(navController, it) }, + navigate = { route -> + // A reply notification opens Feedback over whatever detail the tablet had open. + if (cx.aswin.boxlore.navigation.pushClosesDetails(route)) tabletNavigation.closeDetails() + tabletNavigation.navigate(navController, route) + }, openExternal = { activity.startActivity(externalPushActionIntent(it)) }, openUpdater = { download -> if (download) { diff --git a/app/src/test/java/cx/aswin/boxlore/navigation/PushTargetRouteAllowlistTest.kt b/app/src/test/java/cx/aswin/boxlore/navigation/PushTargetRouteAllowlistTest.kt index f40e5791d..fa339eb70 100644 --- a/app/src/test/java/cx/aswin/boxlore/navigation/PushTargetRouteAllowlistTest.kt +++ b/app/src/test/java/cx/aswin/boxlore/navigation/PushTargetRouteAllowlistTest.kt @@ -26,6 +26,10 @@ class PushTargetRouteAllowlistTest { assertEquals("feedback?thread=0123456789abcdef01234567", cx.aswin.boxlore.fcm.SupportReplyNotifications.routeFor("0123456789abcdef01234567")) assertEquals("feedback", cx.aswin.boxlore.fcm.SupportReplyNotifications.routeFor("../../etc")) assertEquals("feedback", cx.aswin.boxlore.fcm.SupportReplyNotifications.routeFor(null)) + assertTrue(pushClosesDetails("feedback?thread=0123456789abcdef01234567")) + assertTrue(pushClosesDetails("feedback")) + assertFalse(pushClosesDetails("podcast/123")) + assertFalse(pushClosesDetails("feedbackish")) } @Test diff --git a/core/designsystem/README.md b/core/designsystem/README.md index 0f66322f8..c5e494e1f 100644 --- a/core/designsystem/README.md +++ b/core/designsystem/README.md @@ -24,7 +24,7 @@ Owns shared Compose visual primitives: theme, typography, shapes, motion, loader - `CustomThemeSeeds` encodes primary and optional secondary/tertiary seeds as versioned `custom:1:...` values in the existing theme-brand key. `generatePersonalColorScheme` uses Material Color Utilities HCT/tonal palettes and coordinated color roles for both modes; existing authored/hex/preset resolution remains unchanged. `artworkSurfaceStyle` retains Pure surfaces while allowing artwork-tinted detail pages; `extractArtworkPalette` is the one artwork picker shared by podcast and episode pages, the full and mini player and Home's hero cards: `selectArtworkSeed` takes Palette's 16 swatches (default filter, so near-black and near-white are already gone), ignores any under 1% of the cover, outside HCT tone 12–92 or under chroma 8, and picks the best mix of coverage and colourfulness (share × 70 + chroma, capped at 64, × 0.3); covers with no meaningful colour have no palette, so their screens keep the listener's theme. `ArtworkPalette` carries that `seed` and the cover's mean colour (`average`). `artworkColorScheme` builds a page's scheme from the seed exactly as a custom theme (`generatePersonalColorScheme`), keeping Pure grounds. `loadArtworkPalette` / `cachedArtworkPalette` share one 96-entry process cache keyed by artwork URL between detail pages and the player. `LocalArtworkColorsEnabled` carries the saved opt-out. `ArtworkNavigationColors` captures the visible source palette synchronously at navigation, retains up to 80 entry snapshots and starts forward and Back handoffs with the departing colors, and invalidates them when the saved theme or artwork opt-out changes; `rememberAnimatedArtworkColors` eases all roles on one clock (`ArtworkColorTransitionMillis`, 350ms, the page slide's length) and retargets from the visible palette. `LocalArtworkLoaderColors` carries the loader palette: detail pages provide their synchronized page palette, and regular pages use the saved app colours from the first frame. `OnScreenArtwork` records which Coil memory-cache entry shows each artwork (the largest `OptimizedImage` copy), so `loadArtworkPalette` reads the cover the listener just tapped instead of fetching another copy. `ArtworkNavigationTheme` inherits the page’s exact animated accents and restrained neutral surfaces, including Pure overrides; playback chrome remains independent. `artworkPlayerColorScheme` is the player's scheme from the same seed through `generateBrandColorScheme`; `ArtworkMiniPlayerColors.fromScheme` gives the mini bar the scheme's primary container and Play its primary. `ArtworkShade` turns a seed or the app's primary into fixed-tone colours for words over a cover (a deep shade, light text, a bright button), each pair readable on any hue. `ArtworkDuotone` redraws a cover in two tones of one colour (a shadow at tone 8 and a highlight at tone 38, chroma up to 40 and 56, golds for dark yellows; `Neutral` is black and white) through a brightness colour matrix with a slight contrast lift (`colorFilter`, no extra bitmap), so `ArtworkShade`'s light text reads on any part of it. Its vivid pairs (`fromColor` / `fromPalette` with `vivid`, and `NeutralVivid`) keep a tone-10 shadow and lift the highlight to tone 80 in the colour's own hue (chroma up to 72), for covers whose words sit on their own scrim (Home's Marquee hero), so the art keeps its brightness. `artworkDeepShade` gives a deep but clearly coloured shade of a colour (its hue at a chosen Material tone with chroma up to 36; golds instead of olives), used for Lore card grounds. - Shared components including `OptimizedImage` (optional `errorContent` for blank/failed art; `showLoadingPlaceholder = false` for decorative layers such as Marquee's blurred backdrop, so the logo placeholder never shows through), loaders, `PillFilterChip` (onboarding/Explore genre pills), `BoxLoreLogo` (optional `height` for hero vs chrome sizes), `RemoveDownloadConfirmationDialog` (Material 3 confirmation dialog before removing downloaded media), player-control primitives used by UI modules, floating 3+1 navigation chrome, bottom-content clearance helpers, sleep-timer chrome, and the non-dismissible `RepairProgressPopup` for app-shell foreground repair status. - `TopBarUtilityActions` groups Feedback and Settings as 33dp-tall solid Material 3 connected action segments, adding Update when available. Settings uses the sliders (`Tune`) icon. Feedback/Settings use 20dp icons in 43dp-wide segments with press feedback; the update symbol is 22dp. Presses retain each segment's connected corners and use the existing restrained scale response without an extra rectangular ripple. The shimmer remains clipped to the Update segment. These remain action buttons rather than mutually exclusive selection controls. Foundation expands touch bounds vertically beyond the compact visual height. Screen owners retain placement, click/long-press callbacks and shortcut visibility; this component owns no scroll behavior. Home's JVM `TopBarUtilityActionsTest` covers actual pressed-corner pixels with/without Update and the click/long-press callbacks. Custom control backgrounds belong inside the press layer so the complete coloured surface contracts together with its content; native Material controls keep their own indication. -- `LocalFeedbackReplyWaiting` (the app shell provides it from `SupportRepository.replyWaiting`) lights the Feedback segment while a team reply is unread: primary-container colours and the same shimmer as Update, a 10dp primary dot ringed in the surface at its top-end corner (outside the segment's clip, test tag `FEEDBACK_REPLY_DOT_TAG`), and the label **Feedback, new reply**. With Update showing too, Update keeps the leading corner and Feedback sits in the middle. `TopBarUtilityActionsTest` covers the dot and label with and without Update, the click, and Settings' corner. +- `LocalFeedbackReplyWaiting` (the app shell provides it from `SupportRepository.replyWaiting`) lights the Feedback segment while a team reply is unread: primary-container colours and the same shimmer as Update, a 10dp primary dot ringed in the surface at its top-end corner (outside the segment's clip, test tag `FEEDBACK_REPLY_DOT_TAG`), and the label **Feedback, new reply**. With Update showing too, Update keeps the leading corner and Feedback sits in the middle. `TopBarUtilityActionsTest` covers the dot and label appearing with Update showing (and absent before), the click, and, without Update, Feedback taking the leading corner. - `LocalAdaptivePlayerCompactProgress` carries the app-owned player/nav morph clock. `Modifier.adaptivePlayerOverlayOffset` reads it only during placement, moving floating feature controls down by the released player row during the shared `adaptivePlayerDockingProgress` phase. Classic and absent playback retain their placement; list/content clearance remains stable. - Regular navigation tabs in both presentations use the current theme's `primary` accent and `onPrimary` icon color through `NavigationAccentColors`, including authored presets, wallpaper palettes and exact custom accents. Classic selected labels use the same accent. Floating Lore retains its multicolour aurora and container base, blended into the single moving indicator during draw rather than adding another selection surface. Its selected icon uses `onPrimary` before the aurora arrives, `onPrimaryContainer` at rest, and the more readable of those foregrounds during the solid-base blend; foreground selection follows the same indicator position without a second delayed color animation. Unselected icons retain `onSurfaceVariant`. - `icon.GenreIcons`: Rich icon palette and resolution helpers for custom podcast genre tags, subscription folders, and filter chips. Maps icon keys to Material Rounded icons (`all`, `findIcon`, `defaultGenreIcon`, `iconOrFallback`, `defaultFolderIcon`, `folderIconOrFallback`). diff --git a/core/network/src/main/java/cx/aswin/boxlore/core/network/SupportApi.kt b/core/network/src/main/java/cx/aswin/boxlore/core/network/SupportApi.kt index ef02ee15c..17c3f73dd 100644 --- a/core/network/src/main/java/cx/aswin/boxlore/core/network/SupportApi.kt +++ b/core/network/src/main/java/cx/aswin/boxlore/core/network/SupportApi.kt @@ -4,6 +4,7 @@ import cx.aswin.boxlore.core.network.model.SupportConversationResponse import cx.aswin.boxlore.core.network.model.SupportListResponse import cx.aswin.boxlore.core.network.model.SupportNewThreadRequest import cx.aswin.boxlore.core.network.model.SupportPhotoResponse +import cx.aswin.boxlore.core.network.model.SupportReadRequest import cx.aswin.boxlore.core.network.model.SupportReplyRequest import cx.aswin.boxlore.core.network.model.SupportReplyResponse import cx.aswin.boxlore.core.network.model.SupportThreadResponse @@ -45,9 +46,10 @@ interface SupportApi { @POST("support/threads/{id}/messages") suspend fun reply(@Path("id") id: String, @Body request: SupportReplyRequest): Response + /** Marks the team's replies read up to `upTo` (ms; the newest message shown), so a later one stays unread. */ @Headers(NO_STORE) @POST("support/threads/{id}/read") - suspend fun markRead(@Path("id") id: String): Response + suspend fun markRead(@Path("id") id: String, @Body request: SupportReadRequest): Response @Headers(NO_STORE) @POST("support/threads/{id}/solve") diff --git a/core/network/src/main/java/cx/aswin/boxlore/core/network/model/SupportModels.kt b/core/network/src/main/java/cx/aswin/boxlore/core/network/model/SupportModels.kt index c2400d02b..9ecddc38a 100644 --- a/core/network/src/main/java/cx/aswin/boxlore/core/network/model/SupportModels.kt +++ b/core/network/src/main/java/cx/aswin/boxlore/core/network/model/SupportModels.kt @@ -92,6 +92,9 @@ data class SupportEndingDto( @Serializable data class SupportEndingActionDto(val kind: String = "", val label: String = "") +@Serializable +data class SupportReadRequest(val upTo: Long? = null) + @Serializable data class SupportReplyRequest( val body: String, diff --git a/core/support/README.md b/core/support/README.md index a18491c87..6912bad54 100644 --- a/core/support/README.md +++ b/core/support/README.md @@ -19,7 +19,13 @@ send pushes; that is the API's job. a refresh succeeds), `replyWaiting`, `hasConversations`, `conversation(id, before)` (a page of messages oldest first, `earlier` to page back, `ending`), `reply(id, body, requestId)` (`ReplyResult.Sent`, `Closed` with the server's ending, `Refused`, `Failed`), `markRead`, `solve`, - `photo(id)` (bytes, owner only), `onReplyPushed()` and `refreshIfDue(minIntervalMs)`. + `photo(id)` (bytes, owner only), `onReplyPushed(threadId)` and `refreshIfDue(minIntervalMs)`. + `markRead(id, upTo)` sends the newest shown message's time, so a later reply stays unread; it, + `reply` and `solve` refresh the list afterwards so its labels follow the server. `refreshed` + announces every successful refresh's `meta` (push topics follow it even when the list didn't + change) and `pushedReplies` the conversation a push was for. A list asked for before a push + arrived (`lastPushAt`) can't clear the push's waiting flag. `refreshIfDue` runs one check at a + time and counts a failed attempt, so an outage isn't asked about on every start. - `SupportLocalStore`: what the phone remembers between launches (has conversations, a reply waiting, joined push topics, last check); the app backs it with its own preferences file. `SupportPushTopics` plans which topics to join and leave (`PushTopicPlan`) from a successful @@ -81,7 +87,9 @@ Features use it; it never depends on a feature, the design system or the catalog (messages, photos, ending, the `before` cursor, gone and failed), replies (retry key, closed with its ending, refused, failed), reading the last unread reply, a push marking a reply waiting until a refresh says otherwise, the start-up check (only with conversations, not more often than asked), a - first conversation remembered at once, photo bytes, and push-topic plans. + first conversation remembered at once, photo bytes, push-topic plans, the read marker and the + refresh after it, a list from before a push keeping its waiting flag, every refresh announced + and a push naming its conversation, and a failed start-up check still counting. ```bash ./gradlew :core:support:testDebugUnitTest diff --git a/core/support/src/main/java/cx/aswin/boxlore/core/support/SupportConversations.kt b/core/support/src/main/java/cx/aswin/boxlore/core/support/SupportConversations.kt index e23fd85b5..85ed290f9 100644 --- a/core/support/src/main/java/cx/aswin/boxlore/core/support/SupportConversations.kt +++ b/core/support/src/main/java/cx/aswin/boxlore/core/support/SupportConversations.kt @@ -67,14 +67,20 @@ interface SupportLocalStore { var hasConversations: Boolean var replyWaiting: Boolean var subscribedTopics: Set + + /** The last time the app asked for the list (tried, not only succeeded). */ var lastCheckedAt: Long + /** When the last reply push arrived: a list fetched before it can't clear its waiting flag. */ + var lastPushAt: Long + /** For tests and before the app wires a real one. */ class InMemory : SupportLocalStore { override var hasConversations = false override var replyWaiting = false override var subscribedTopics: Set = emptySet() override var lastCheckedAt = 0L + override var lastPushAt = 0L } } diff --git a/core/support/src/main/java/cx/aswin/boxlore/core/support/SupportRepository.kt b/core/support/src/main/java/cx/aswin/boxlore/core/support/SupportRepository.kt index 88524f2a0..d7a7b88ec 100644 --- a/core/support/src/main/java/cx/aswin/boxlore/core/support/SupportRepository.kt +++ b/core/support/src/main/java/cx/aswin/boxlore/core/support/SupportRepository.kt @@ -6,6 +6,7 @@ import cx.aswin.boxlore.core.network.model.SupportListResponse import cx.aswin.boxlore.core.network.model.SupportEndingDto import cx.aswin.boxlore.core.network.model.SupportMessageDto import cx.aswin.boxlore.core.network.model.SupportNewThreadRequest +import cx.aswin.boxlore.core.network.model.SupportReadRequest import cx.aswin.boxlore.core.network.model.SupportReplyRequest import cx.aswin.boxlore.core.network.model.SupportReplyResponse import cx.aswin.boxlore.core.network.model.SupportThreadDto @@ -13,7 +14,12 @@ import java.io.IOException import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.withContext @@ -38,6 +44,9 @@ class SupportRepository( private val _meta = MutableStateFlow(SupportMeta.Offline) private val _threads = MutableStateFlow?>(null) private val _replyWaiting = MutableStateFlow(local.replyWaiting) + private val _refreshed = MutableSharedFlow(extraBufferCapacity = 4) + private val _pushedReplies = MutableSharedFlow(extraBufferCapacity = 8) + private val checking = AtomicBoolean(false) /** The last known switches and limits; [SupportMeta.Offline] until [refresh] succeeds. */ val meta: StateFlow = _meta.asStateFlow() @@ -48,11 +57,18 @@ class SupportRepository( /** A team reply hasn't been read yet: Home's Feedback button shows it. */ val replyWaiting: StateFlow = _replyWaiting.asStateFlow() + /** Each successful refresh's `meta`, even when nothing else changed: push topics follow it. */ + val refreshed: SharedFlow = _refreshed.asSharedFlow() + + /** The conversation a reply push was for (null when it didn't say): an open conversation or the list reloads. */ + val pushedReplies: SharedFlow = _pushedReplies.asSharedFlow() + /** Whether this phone has (or had, last time it looked) conversations: Feedback then opens on the list. */ val hasConversations: Boolean get() = _threads.value?.isNotEmpty() ?: local.hasConversations /** Reads the switches and this phone's conversations. */ suspend fun refresh(): SupportMeta? = withContext(ioDispatcher) { + val startedAt = clock() val response = call { api.threads() } ?: return@withContext null if (!response.isSuccessful) { // Support went away (or this phone can't use it): forget switches like a pause, so Send works and falls back. @@ -66,7 +82,9 @@ class SupportRepository( _threads.value = threads local.hasConversations = threads.isNotEmpty() local.lastCheckedAt = clock() - setReplyWaiting(threads.any { it.unread }) + // A push that arrived while this list was on its way is newer than the list. + setReplyWaiting(threads.any { it.unread } || local.lastPushAt > startedAt) + _refreshed.tryEmit(meta) meta } @@ -76,13 +94,22 @@ class SupportRepository( */ suspend fun refreshIfDue(minIntervalMs: Long): SupportMeta? { if (!local.hasConversations || clock() - local.lastCheckedAt < minIntervalMs) return null - return refresh() + // One check at a time, and the attempt counts even if it fails, so an outage isn't asked on every start. + if (!checking.compareAndSet(false, true)) return null + return try { + local.lastCheckedAt = clock() + refresh() + } finally { + checking.set(false) + } } - /** A reply push arrived: show it on Home until the conversation is read. */ - fun onReplyPushed() { + /** A reply push arrived for [threadId]: show it on Home until it's read, and let open screens reload. */ + fun onReplyPushed(threadId: String? = null) { + local.lastPushAt = clock() local.hasConversations = true setReplyWaiting(true) + _pushedReplies.tryEmit(threadId) } private fun setReplyWaiting(waiting: Boolean) { @@ -109,27 +136,29 @@ class SupportRepository( val response = call { api.reply(id, SupportReplyRequest(body = body, photos = photoIds, requestId = requestId)) } ?: return@withContext ReplyResult.Failed val reply = response.body() when { - response.isSuccessful -> ReplyResult.Sent(reply?.thread?.toThread(), reply?.message?.toMessage()) + response.isSuccessful -> ReplyResult.Sent(reply?.thread?.toThread(), reply?.message?.toMessage()).also { refresh() } response.code() == HTTP_CONFLICT -> ReplyResult.Closed(response.errorBody()?.ending()?.toEnding()) response.code() == HTTP_TOO_MANY -> ReplyResult.Refused(response.errorBody()?.message() ?: DEFAULT_REFUSAL) else -> ReplyResult.Failed } } - /** The team's reply was seen: the list and Home stop showing it. */ - suspend fun markRead(id: String): Boolean = withContext(ioDispatcher) { - val ok = call { api.markRead(id) }?.isSuccessful == true + /** + * The team's replies up to [upTo] (the newest message on screen) were seen: a reply that came + * after it stays unread. The list then refreshes, so its labels and Home follow the server. + */ + suspend fun markRead(id: String, upTo: Long?): Boolean = withContext(ioDispatcher) { + val ok = call { api.markRead(id, SupportReadRequest(upTo)) }?.isSuccessful == true if (ok) { - val threads = _threads.value?.map { if (it.id == id) it.copy(unread = false) else it } - _threads.value = threads - setReplyWaiting(threads?.any { it.unread } == true) + _threads.update { list -> list?.map { if (it.id == id) it.copy(unread = false) else it } } + refresh() } ok } - /** The listener marks an open conversation solved. */ + /** The listener marks an open conversation solved; the list then refreshes. */ suspend fun solve(id: String): Boolean = withContext(ioDispatcher) { - call { api.solve(id) }?.isSuccessful == true + (call { api.solve(id) }?.isSuccessful == true).also { if (it) refresh() } } /** A photo's bytes from a conversation (owner only), or null. */ diff --git a/core/support/src/test/java/cx/aswin/boxlore/core/support/SupportConversationsTest.kt b/core/support/src/test/java/cx/aswin/boxlore/core/support/SupportConversationsTest.kt index 220bb220f..ce6b449d7 100644 --- a/core/support/src/test/java/cx/aswin/boxlore/core/support/SupportConversationsTest.kt +++ b/core/support/src/test/java/cx/aswin/boxlore/core/support/SupportConversationsTest.kt @@ -2,6 +2,7 @@ package cx.aswin.boxlore.core.support import cx.aswin.boxlore.core.network.NetworkModule import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest import okhttp3.OkHttpClient import okhttp3.mockwebserver.MockResponse @@ -84,6 +85,7 @@ class SupportConversationsTest { @Test fun `replies are sent with their retry key, a closed conversation and a limit say why`() = runTest { server.enqueue(MockResponse().setResponseCode(201).setBody("""{"message":{"id":"m3","from":"you","body":"Still"}}""")) + server.enqueue(MockResponse().setBody(list)) server.enqueue(MockResponse().setResponseCode(409).setBody("""{"error":"closed","ending":{"kind":"closed","title":"This conversation is closed","action":{"kind":"new","label":"Send new feedback"}}}""")) server.enqueue(MockResponse().setResponseCode(429).setBody("""{"error":"limit","message":"Try again tomorrow."}""")) server.enqueue(MockResponse().setResponseCode(503)) @@ -91,23 +93,54 @@ class SupportConversationsTest { val sent = repo.reply("t1", "Still", requestId = "retry-key-0001") as ReplyResult.Sent assertEquals("m3", sent.message?.id) assertTrue(server.takeRequest().body.readUtf8().contains("\"requestId\":\"retry-key-0001\"")) + assertEquals("/support/threads", server.takeRequest().path) assertEquals("Send new feedback", (repo.reply("t1", "Hello?", null) as ReplyResult.Closed).ending?.actionLabel) assertEquals(ReplyResult.Refused("Try again tomorrow."), repo.reply("t1", "More", null)) assertEquals(ReplyResult.Failed, repo.reply("t1", "More", null)) } @Test - fun `reading the last unread reply clears the waiting flag`() = runTest { + fun `reading up to the shown message sends the marker and takes the list from the server again`() = runTest { server.enqueue(MockResponse().setBody(list)) server.enqueue(MockResponse().setResponseCode(204)) + server.enqueue(MockResponse().setBody(list.replace("\"unread\":true", "\"unread\":false").replace("new_reply", "replied"))) val repo = repository() repo.refresh() - assertTrue(repo.markRead("a1b2c3d4e5f6a1b2c3d4e5f6")) - assertFalse(repo.threads.value!!.first().unread) + server.takeRequest() + assertTrue(repo.markRead("a1b2c3d4e5f6a1b2c3d4e5f6", upTo = 5)) + assertEquals("""{"upTo":5}""", server.takeRequest().body.readUtf8()) + assertEquals("replied", repo.threads.value!!.first().status.key) assertFalse(repo.replyWaiting.value) assertFalse(local.replyWaiting) } + @Test + fun `a list asked for before a push can't clear the push's waiting flag`() = runTest { + server.enqueue(MockResponse().setBody("""{"meta":{},"threads":[{"id":"a1b2c3d4e5f6a1b2c3d4e5f6","unread":false}]}""")) + local.lastPushAt = now + 1 + val repo = repository() + repo.refresh() + assertTrue(repo.replyWaiting.value) + } + + @Test + fun `every successful refresh is announced, and a push says which conversation`() = runTest { + val metas = mutableListOf() + val pushed = mutableListOf() + val repo = repository() + backgroundScope.launch(kotlinx.coroutines.test.UnconfinedTestDispatcher(testScheduler)) { repo.refreshed.collect { metas += it } } + backgroundScope.launch(kotlinx.coroutines.test.UnconfinedTestDispatcher(testScheduler)) { repo.pushedReplies.collect { pushed += it } } + server.enqueue(MockResponse().setBody(list)) + server.enqueue(MockResponse().setBody(list)) + repo.refresh() + repo.refresh() + testScheduler.advanceUntilIdle() + assertEquals(2, metas.size) + repo.onReplyPushed("a1b2c3d4e5f6a1b2c3d4e5f6") + testScheduler.advanceUntilIdle() + assertEquals(listOf("a1b2c3d4e5f6a1b2c3d4e5f6"), pushed) + } + @Test fun `a push marks a reply waiting until the next refresh says otherwise`() = runTest { val repo = repository() @@ -134,9 +167,13 @@ class SupportConversationsTest { assertNull(repo.refreshIfDue(30 * 60_000L)) assertEquals(1, server.requestCount) now += 30 * 60_000L - server.enqueue(MockResponse().setBody(list)) + server.enqueue(MockResponse().setResponseCode(500)) repo.refreshIfDue(30 * 60_000L) assertEquals(2, server.requestCount) + // A failed check still counts: the next start doesn't ask again straight away. + now += 60_000L + assertNull(repo.refreshIfDue(30 * 60_000L)) + assertEquals(2, server.requestCount) } @Test diff --git a/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/TopBarUtilityActionsTest.kt b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/TopBarUtilityActionsTest.kt index 984ebf1d6..33619cc3a 100644 --- a/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/TopBarUtilityActionsTest.kt +++ b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/TopBarUtilityActionsTest.kt @@ -107,6 +107,15 @@ class TopBarUtilityActionsTest { checkCornerWhilePressed("Settings", trailing = true) } + @Test fun `without an update, a waiting reply takes the leading Feedback segment`() { + show(update = false) + composeRule.runOnIdle { replyWaiting.value = true } + composeRule.waitForIdle() + composeRule.onNodeWithTag(FEEDBACK_REPLY_DOT_TAG).assertExists() + checkCornerWhilePressed("Feedback, new reply", trailing = false) + checkCornerWhilePressed("Settings", trailing = true) + } + private fun show(update: Boolean) { val updateAction: (() -> Unit)? = if (update) ({ actions.add("update") }) else null composeRule.setContent { diff --git a/feature/settings/README.md b/feature/settings/README.md index 186a23bbe..8c8ecc0fe 100644 --- a/feature/settings/README.md +++ b/feature/settings/README.md @@ -22,7 +22,7 @@ Owns the unified Settings hub, category pages (Account, Sync & Backups, Appearan - `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. `FeedbackScreen(supportRepository, boxcastPrefs, onBack, bottomContentPadding)` takes `SupportRepository` from `:core:support` (app wiring passes the mini player clearance). - **Feedback, redesigned 2026-11-02 (support conversations, phase 2).** "Share your thoughts" opens straight on the form: bold circular type pills (`FeedbackTypePills`), a roomy message card that takes the free height, photos (only while the server's `meta.photos` switch is on; picked with the system photo picker, no storage permission, downscaled to 1600 px JPEG by `PhotoPrep`; the row scrolls sideways so every photo and its Remove stay reachable on narrow phones), an Include device diagnostics row, **Send feedback** at the bottom and a **How we handle feedback** link to the privacy page. The pills are the server's `meta.categories` (id and label, shown as sent); the app's own four (Feature idea, Bug report, Audio / Stream, General) are the offline list and give icons and hints for the ids they share (`FeedbackChoice`), and an id the server stops offering falls back to its first. The message limit is the server's `maxBody` as is; typing stops there, and an older draft past it (its steps now join the message) is kept whole and can only get shorter, with the counter in red and "This is over the …-character limit. Shorten it to send." until it fits (`limitedMessage`). The phone model goes only with diagnostics on. While a message is sending its draft is locked (field read-only, pills, switch and photos disabled), so what was sent is what gets cleared. Windows shorter than 600dp scroll with a 220dp message card. While typing (message focused and the keyboard up) the pills fold into one pill in the header and a slim bar with photos, diagnostics and **Send** rides on the keyboard, which replaces the bottom chrome padding (`isTypingLayout`, `feedbackBottomPadding`). It is one layout throughout: the keyboard or a height change only changes what sits around the message field, so the field keeps its focus, cursor and the text being composed. The top-bar menu has Preview diagnostics, Rate on Play, Report on GitHub and Discard draft. The email and steps fields are gone. Send is disabled while the server pauses new messages (`canStartNew`), with the server's notice or `PAUSED_NOTICE`; a refusal (today's limit) shows the server's words and keeps the draft. Each send carries a retry key (`RetryKeys`): sending the same message again reuses it, an edited message gets a new one, so a send whose answer was lost isn't saved twice. The key is kept in `boxlore_feedback_retry` (`SharedPrefsRetryKeyStore`, a fingerprint of the content and the key) until the message is saved or discarded, so it survives the screen closing or the app being killed; photos already uploaded for the message are reused on a retry. When support can't take photos (it is unavailable), the message goes without them. After sending, `FeedbackSentScreen` shows Echo's `feedback-sent` sequence (magnifier, then rest), "Feedback sent" and, when the message became a conversation and notifications are off, a **Know when the team replies** card (`canAskForNotifications`: Android's prompt while it will still show, otherwise the app's notification settings; re-checked on resume, **Not now** hides it). A message that fell back to the old one-way form (`SendResult.SentByEmailForm`) gets neither the reply promise nor the card, and left-out photos are mentioned (`feedbackSentBody`). -- **Feedback conversations (support conversations, phase 3).** `FeedbackScreen(..., openThreadId)` opens on one conversation for a reply notification (`feedback?thread=`), on the list once the phone has conversations, and on "Share your thoughts" otherwise (`initialFeedbackPane`; the pane is saved with `FeedbackPaneSaver`). The list (`FeedbackListScreen`) shows "Your feedback" in one card, newest first: an unread dot and bold title for a new reply, the server's status label with its time ("11:02", "Yesterday", "28 Oct"; `feedbackListTime`), a check for Solved and a lock with a dimmed title for Closed. Rate on Play, Report on GitHub and **Send feedback** are pinned at the bottom with the list scrolling behind them; each **Send feedback** opens a fresh form (a new view-model key; the saved draft still comes back). After sending, the list refreshes at once (so the phone joins its reply-push topic before a reply can come), and Done goes to the list when the message became a conversation. A conversation (`FeedbackThreadScreen`, `FeedbackThreadViewModel`) shows the listener's messages in outlined cards and the team's in a primary-container card ("boxlore team"), with photos fetched once through the support API and "Diagnostics included"; **Show earlier messages** pages back. Opening an unread one marks it read, which clears the list's dot and Home's indicator. **Reply** opens a reply box that rides on the keyboard (the server's message limit, a retry key per reply, the draft kept on failure); **Mark solved** shows once the team has answered an open conversation. The server's ending card closes it: Solved (tertiary container, check, the server's words, Reply) or Closed (lock, the server's words, **Send new feedback**). Back walks conversation → list → out, and a new message goes back to the list when there is one (`feedbackBack`). On a landscape tablet with at least 840dp for Feedback (`feedbackUsesSplit`) the list (400dp) and the conversation sit side by side: the picked conversation, else the newest, else a new message; Back then leaves. The open conversation survives resizes and recreation in both directions. +- **Feedback conversations (support conversations, phase 3).** `FeedbackScreen(..., openThreadId)` opens on one conversation for a reply notification (`feedback?thread=`), on the list once the phone has conversations, and on "Share your thoughts" otherwise (`initialFeedbackPane`; the pane is saved with `FeedbackPaneSaver`). The list (`FeedbackListScreen`) shows "Your feedback" in one card, newest first: an unread dot and bold title for a new reply, the server's status label with its time ("11:02", "Yesterday", "28 Oct"; `feedbackListTime`), a check for Solved and a lock with a dimmed title for Closed. Rate on Play, Report on GitHub and **Send feedback** are pinned at the bottom with the list scrolling behind them; each **Send feedback** opens a fresh form (a new view-model key; the saved draft still comes back). After sending, the list refreshes at once (so the phone joins its reply-push topic before a reply can come), and Done goes to the list when the message became a conversation. A conversation (`FeedbackThreadScreen`, `FeedbackThreadViewModel`) shows the listener's messages in outlined cards and the team's in a primary-container card ("boxlore team"), with photos fetched through the support API into a small cache (12, one fetch per photo at a time, a failed one fetched again later) in a row that scrolls sideways, and "Diagnostics included" on its own line; **Show earlier messages** pages back. The conversation loads fresh each time it shows (its view model may be kept from earlier in the visit), reloads when a reply push arrives for it, and is marked read only once it is on screen, up to the newest message shown, so a reply that came after stays unread; that clears the list's dot and Home's indicator, and the list then refreshes its statuses. A failed refresh keeps what is shown with a "Couldn't refresh" line and Try again; a conversation that is gone clears. **Reply** opens a reply box that rides on the keyboard (the server's message limit, a retry key per reply, the draft kept on failure; the draft, whether the box is open and the retry key live in the view model's saved state, so they survive the process being killed); a "closed" answer shows its ending at once; **Mark solved** shows once the team has answered an open conversation. The server's ending card closes it: Solved (tertiary container, check, the server's words, Reply) or Closed (lock, the server's words, **Send new feedback**). Back walks conversation → list → out, and a new message goes back to the list when there is one (`feedbackBack`). On a landscape tablet with at least 840dp for Feedback (`feedbackUsesSplit`) the list (400dp) and the conversation sit side by side: the picked conversation, else the newest, else a new message, which then becomes the selection, so narrowing the window keeps it open; Back then leaves. The list and the conversation keep their places in one row in every window (`FeedbackPanesLayout`), so crossing the gate never rebuilds them: the conversation's scroll and the reply box's text, cursor and focus stay, and the host keeps the list's scroll position while it is hidden. A reply push refreshes the list while Feedback is open. A send that finishes after another form saved a new draft leaves that draft alone, and clears only its own retry key. - `AutoDownloadSettingsScreen` and `SmartDownloadsSettingsScreen` under `downloads/` for download management. - `AccountSettingsPage`, `SyncAndBackupsPage`, `AppearanceSettingsPage`, `PlaybackSettingsPage`, `PrivacySettingsPage`, `LibrarySettingsPage`, `DownloadsSettingsPage`, `AboutSettingsPage`, `SupportDevelopmentPage`. - Sync & Backups export copy names only what the JSON backup writes: subscriptions, folders, history, likes and settings (`FULL_LIBRARY_EXPORT_SUMMARY`, covered by `SyncAndBackupsPageTest`). Settings copy spells the product name boxlore in lowercase, including sentence starts. @@ -184,7 +184,7 @@ src/main/java/cx/aswin/boxlore/feature/settings/ - `SupportEntryImpressionTest` covers one impression per view, none for a configuration restore, a new one after returning, and the real Settings hub's top-bar surface. - Not covered by JVM tests: rendering quality on real devices and fold postures. Those need a phone, tablet or foldable check. - Feedback: `FeedbackViewModelTest` covers draft restore (old steps joined), a draft past the limit kept whole until shortened, a server limit above 2,000, a conversation send (body trimmed, diagnostics, analytics without the text), the phone model only with diagnostics, the server's categories (an unknown id falls back, a new one is sent as is and counts as other in analytics), the old-form fallback without the reply promise, refusal and failure keeping the draft with a retry, blank messages, the draft locked while sending, photos ignored while switched off, de-duplicated picks up to the limit uploaded in order before the message, a failed or oversized photo sending nothing, photos left out when support can't take them, paused sending, retry keys (kept for a resend, new after an edit or a success, the same after the screen is rebuilt, with photos uploaded once), and Discard draft. `FeedbackLayoutPolicyTest` covers the typing-layout and keyboard-clearance rules, `limitedMessage`, when Android's notification prompt can still show, and the sent-screen copy. `FeedbackAdaptiveTransitionTest` (Robolectric) holds the view model outside composition, as the navigation entry does in the app, so it covers the screen's own state rather than the view model's lifetime. It keeps one live screen while the window crosses the 600dp width, 480dp height and 840dp split gates in both directions, portrait/landscape tablets, a short 800×360 window that scrolls, and a fold going flat → tabletop → flat, then recreates it (`StateRestorationTester`): the typed message, the picked type and a reachable, enabled Send survive, Send goes out once, and the sent screen keeps Done and the notifications card (or its dismissal) through the same walk; an old-form send never shows the card. With the keyboard played by passing its height in, the message field stays focused with its text as the keyboard opens and closes twice and the window goes short and tall, and typing continues where it was. On a 360dp phone all five photos and their Remove buttons are reachable. Not covered by JVM tests: a real keyboard's animation and composition, the photo picker, and Echo's motion; those need a phone and tablet check. Manual acceptance: on a phone, a tablet and a foldable, write a message, rotate and fold with the keyboard up and down, then send; the draft stays, the cursor stays in the field, Send stays reachable above the keyboard, and the sent screen offers notifications only while they are off. -- Feedback conversations: `FeedbackPanesTest` covers the opening pane, Back, the split gate (839/840dp, portrait, short), the saved pane and the times. `FeedbackThreadViewModelTest` covers marking an unread conversation read, gone and failed loads with a retry, earlier pages going first, a failed reply keeping its text and retry key, a refusal, a closed conversation reloading to its ending, Mark solved, the reply limit and photos fetched once. `FeedbackConversationsAdaptiveTest` (Robolectric, the real `FeedbackScreen` on a fake support API) keeps the open conversation through phone ↔ split both ways across the 840dp gate, a portrait tablet, a short window and a fold, and through recreation; Back goes to the list, then out; side by side the newest conversation shows until another is picked and an unread one is marked read; a notification's id opens its conversation; and a phone without conversations opens on Share your thoughts. Not covered by JVM tests: real photos, the reply keyboard on a device, and push delivery. +- Feedback conversations: `FeedbackPanesTest` covers the opening pane, Back, the split gate (839/840dp, portrait, short), the saved pane and the times. `FeedbackThreadViewModelTest` covers marking an unread conversation read only once it shows and up to the newest message, gone and failed loads with a retry, earlier pages going first, a failed reply keeping its text and retry key, a refusal, a closed answer showing its ending at once even when the reload fails, gone clearing the screen, a push for this conversation reloading it (and one for another not), the reply draft outliving the view model through saved state, Mark solved, the reply limit, and a failed photo fetched again while a kept one isn't. `FeedbackConversationsAdaptiveTest` (Robolectric, the real `FeedbackScreen` on a fake support API) keeps the open conversation through phone ↔ split both ways across the 840dp gate, a portrait tablet, a short window and a foldable-sized window (no hinge data), and through recreation; a reply being written keeps its text and focus across the split gate both ways, and a conversation shown side by side stays open after narrowing; Back goes to the list, then out; side by side the newest conversation shows until another is picked and an unread one is marked read; a notification's id opens its conversation; and a phone without conversations opens on Share your thoughts. Not covered by JVM tests: real photos, the reply keyboard on a device, and push delivery. - Unit tests live under `feature/settings/src/test`. - `AccountPagePresentationTest` covers the onboarding and Settings layouts, picking each sign-in method and getting back to the cards, the check-email and confirm-email screens (resend cooldown), every sync state with its action, the Sync now label at 200% text in RTL, password-only Reset password, Echo press feedback, and that every Echo pose the page uses ships in the Echo manifest with a stage reach. `AccountPageAdaptiveTest` keeps one live page while the window moves phone → 599/600dp → tablet split → 479/480dp tall → portrait tablet → phone and checks that the chosen method, the typed email and the password mode survive, in both directions, and across activity recreation (`StateRestorationTester`). It also keeps a live syncing onboarding page across the same windows: the No need to wait note stays on screen above the tiles with Continue reachable, and finishing sync mid-resize turns all three tiles done and removes the note. Tests hold Echo on its resting pose with `LocalEchoMotion provides false` so the screen can go idle. App navigation tests cover hiding restored playback during Account and onboarding and restoring normal player presentation afterward. - `AutoDownloadSettingsActionsTest` exercises the same action callbacks and background presentation used by the screen: all four episode limits, invalid-limit rejection, help opening/dismissal, both background-control visibility states, and retaining all network/charging combinations when checks are disabled and re-enabled. No device or screenshot automation is required. diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackListScreen.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackListScreen.kt index b84047bff..28ff82053 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackListScreen.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackListScreen.kt @@ -20,7 +20,9 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -86,6 +88,7 @@ internal fun FeedbackListScreen( bottomContentPadding: Dp, actions: FeedbackListActions, modifier: Modifier = Modifier, + listState: LazyListState = rememberLazyListState(), ) { val onBack = actions.onBack val systemBar = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() @@ -99,7 +102,7 @@ internal fun FeedbackListScreen( } Box(Modifier.weight(1f).fillMaxWidth()) { when { - threads != null -> ThreadList(threads, selectedId, actions.onOpen, PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = bottom + LIST_ACTIONS_HEIGHT + 16.dp)) + threads != null -> ThreadList(threads, selectedId, actions.onOpen, listState, PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = bottom + LIST_ACTIONS_HEIGHT + 16.dp)) loadFailed -> LoadFailed(actions.onRetry, Modifier.align(Alignment.Center).padding(bottom = bottom + LIST_ACTIONS_HEIGHT)) else -> CircularProgressIndicator(Modifier.align(Alignment.Center).padding(bottom = bottom + LIST_ACTIONS_HEIGHT)) } @@ -111,11 +114,11 @@ internal fun FeedbackListScreen( } @Composable -private fun ThreadList(threads: List, selectedId: String?, onOpen: (String) -> Unit, contentPadding: PaddingValues) { +private fun ThreadList(threads: List, selectedId: String?, onOpen: (String) -> Unit, listState: LazyListState, contentPadding: PaddingValues) { val context = LocalContext.current val is24Hour = DateFormat.is24HourFormat(context) val now = System.currentTimeMillis() - LazyColumn(Modifier.fillMaxSize().testTag(FEEDBACK_LIST_TAG), contentPadding = contentPadding) { + LazyColumn(Modifier.fillMaxSize().testTag(FEEDBACK_LIST_TAG), state = listState, contentPadding = contentPadding) { item(key = "heading") { Text( "Your feedback", diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackPaneRoutes.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackPaneRoutes.kt index 9bba4504e..632f938e3 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackPaneRoutes.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackPaneRoutes.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.material3.MaterialTheme import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -24,6 +25,12 @@ import cx.aswin.boxlore.core.support.SupportThread internal val FEEDBACK_LIST_PANE_WIDTH = 400.dp /** One pane at a time on phones; side by side, the list keeps a conversation open: the picked one, else the newest, else a new message. */ +/** + * One pane at a time on phones; side by side, the list keeps a conversation open: the picked one, + * else the newest, else a new message. The list and the detail keep their places in one row in + * every window, so crossing the split gate never rebuilds them: the conversation's scroll, the + * reply box's text, cursor and focus all stay (the list's scroll is kept by the host). + */ @Composable internal fun FeedbackPanesLayout( split: Boolean, @@ -33,17 +40,19 @@ internal fun FeedbackPanesLayout( detail: @Composable (FeedbackPane, Boolean) -> Unit, modifier: Modifier = Modifier, ) { - if (split) { - val shown = if (pane == FeedbackPane.List) threads?.firstOrNull()?.let { FeedbackPane.Thread(it.id) } ?: FeedbackPane.Compose else pane - Row(modifier.fillMaxSize()) { - list(Modifier.width(FEEDBACK_LIST_PANE_WIDTH).fillMaxHeight(), (shown as? FeedbackPane.Thread)?.id) - VerticalDivider(color = MaterialTheme.colorScheme.outlineVariant) - Box(Modifier.weight(1f).fillMaxHeight()) { detail(shown, false) } + val shown = if (split && pane == FeedbackPane.List) threads?.firstOrNull()?.let { FeedbackPane.Thread(it.id) } ?: FeedbackPane.Compose else pane + val showList = split || pane == FeedbackPane.List + val showDetail = split || pane != FeedbackPane.List + Row(modifier.fillMaxSize()) { + if (showList) { + Box(if (split) Modifier.width(FEEDBACK_LIST_PANE_WIDTH).fillMaxHeight() else Modifier.weight(1f).fillMaxHeight()) { + list(Modifier.fillMaxSize(), if (split) (shown as? FeedbackPane.Thread)?.id else null) + } + } + if (split) VerticalDivider(color = MaterialTheme.colorScheme.outlineVariant) + if (showDetail) { + Box(Modifier.weight(1f).fillMaxHeight()) { detail(shown, !split) } } - } else if (pane == FeedbackPane.List) { - list(modifier, null) - } else { - Box(modifier) { detail(pane, true) } } } @@ -138,18 +147,19 @@ private fun FeedbackThreadRoute( factory = FeedbackThreadViewModel.Factory(supportRepository, threadId), ) val state by threadViewModel.uiState.collectAsStateWithLifecycle() + // The view model can be kept from an earlier visit: load fresh each time the conversation shows. + LaunchedEffect(threadViewModel) { threadViewModel.activate() } FeedbackThreadScreen( state = state, actions = FeedbackThreadActions( onBack = onBack, onRetry = threadViewModel::reload, onLoadEarlier = threadViewModel::loadEarlier, - onReplyOpen = threadViewModel::onReplyOpen, - onReplyChanged = threadViewModel::onReplyChanged, - onSendReply = threadViewModel::sendReply, + reply = FeedbackReplyActions(threadViewModel::onReplyOpen, threadViewModel::onReplyChanged, threadViewModel::sendReply), onSolve = threadViewModel::solve, onNewFeedback = onNewFeedback, photo = threadViewModel::photo, + onSeen = threadViewModel::onSeen, ), bottomContentPadding = bottomContentPadding, ) 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 121f0a6ce..6000a44ce 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 @@ -7,6 +7,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column @@ -85,6 +86,8 @@ fun FeedbackScreen( var pane by rememberSaveable(stateSaver = FeedbackPaneSaver) { mutableStateOf(initialFeedbackPane(openThreadId, supportRepository.hasConversations)) } var composeSession by rememberSaveable { mutableIntStateOf(0) } var loadFailed by remember { mutableStateOf(false) } + // Kept here, so the list's scroll position survives it being hidden behind a conversation on phones. + val listState = rememberLazyListState() val threads by supportRepository.threads.collectAsStateWithLifecycle() val refresh: () -> Unit = { scope.launch { loadFailed = supportRepository.refresh() == null && supportRepository.threads.value == null } @@ -92,6 +95,13 @@ fun FeedbackScreen( // The list is fresh each time it shows: replies read or sent elsewhere change its statuses. LaunchedEffect(pane == FeedbackPane.List) { if (pane == FeedbackPane.List || supportRepository.threads.value == null) refresh() } val split = feedbackUsesSplit(LocalBoxlorePaneWidth.current, LocalBoxloreWindowLayout.current) + // Side by side, the conversation shown for the list becomes the selection, so narrowing the window keeps it open. + val newestId = threads?.firstOrNull()?.id + LaunchedEffect(split, newestId, threads != null) { + if (split && pane == FeedbackPane.List && threads != null) pane = newestId?.let { FeedbackPane.Thread(it) } ?: FeedbackPane.Compose + } + // A reply push brings the list's statuses up to date while Feedback is open. + LaunchedEffect(supportRepository) { supportRepository.pushedReplies.collect { refresh() } } val goBack: () -> Unit = { feedbackBack(pane, supportRepository.hasConversations, split)?.let { pane = it } ?: onBack() } BackHandler(onBack = goBack) val openCompose: () -> Unit = { @@ -120,7 +130,7 @@ fun FeedbackScreen( split = split, pane = pane, threads = threads, - list = { listModifier, selectedId -> FeedbackListScreen(threads, loadFailed, selectedId, bottomContentPadding, listActions, listModifier) }, + list = { listModifier, selectedId -> FeedbackListScreen(threads, loadFailed, selectedId, bottomContentPadding, listActions, listModifier, listState) }, detail = { detail, showBack -> FeedbackDetailPane(supportRepository, boxcastPrefs, detail, composeSession, bottomContentPadding, callbacks, showBack) }, modifier = modifier, ) diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackThreadScreen.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackThreadScreen.kt index 394e255fc..e251f70e0 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackThreadScreen.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackThreadScreen.kt @@ -5,6 +5,7 @@ import android.text.format.DateFormat import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -24,6 +25,7 @@ import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField @@ -74,17 +76,23 @@ internal const val FEEDBACK_THREAD_TAG = "feedback_thread" internal const val FEEDBACK_REPLY_FIELD_TAG = "feedback_reply_field" internal const val FEEDBACK_REPLY_SEND_TAG = "feedback_reply_send" +internal class FeedbackReplyActions(val onOpen: (Boolean) -> Unit, val onChanged: (String) -> Unit, val onSend: () -> Unit) + internal class FeedbackThreadActions( val onBack: (() -> Unit)?, val onRetry: () -> Unit, val onLoadEarlier: () -> Unit, - val onReplyOpen: (Boolean) -> Unit, - val onReplyChanged: (String) -> Unit, - val onSendReply: () -> Unit, + val reply: FeedbackReplyActions, val onSolve: () -> Unit, val onNewFeedback: () -> Unit, val photo: suspend (String) -> ByteArray?, -) + /** The conversation is on screen (marks the team's replies read up to the newest one shown). */ + val onSeen: () -> Unit = {}, +) { + val onReplyOpen: (Boolean) -> Unit get() = reply.onOpen + val onReplyChanged: (String) -> Unit get() = reply.onChanged + val onSendReply: () -> Unit get() = reply.onSend +} /** * One conversation: the listener's messages in outlined cards, the team's in a filled card, then @@ -132,12 +140,27 @@ internal fun FeedbackThreadScreen( @Composable private fun Messages(state: FeedbackThreadUiState, actions: FeedbackThreadActions) { val conversation = state.conversation ?: return + // Read only once it is really showing, up to what is showing. + LaunchedEffect(conversation.thread.unread, state.newestShownAt) { if (conversation.thread.unread) actions.onSeen() } val context = LocalContext.current val is24Hour = DateFormat.is24HourFormat(context) val now = System.currentTimeMillis() val zone = ZoneId.systemDefault() val locale = Locale.getDefault() LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + if (state.loadFailed) { + item(key = "refresh_failed") { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "Couldn't refresh this conversation.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = actions.onRetry) { Text("Try again", fontWeight = GoogleSansWeight.semiBold) } + } + } + } if (conversation.earlier != null) { item(key = "earlier") { TextButton(onClick = actions.onLoadEarlier, enabled = !state.loadingEarlier, modifier = Modifier.fillMaxWidth()) { @@ -196,14 +219,15 @@ private fun MessageCard(message: SupportMessage, time: String, photo: suspend (S Text("${message.author.ifBlank { "You" }} · $time", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } Text(message.body, style = MaterialTheme.typography.bodyLarge) - if (message.photoIds.isNotEmpty() || message.diagnostics) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.Bottom) { + if (message.photoIds.isNotEmpty()) { + // Scrolls sideways, so all of a message's photos stay reachable on narrow phones. + Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) { message.photoIds.forEachIndexed { index, id -> PhotoThumb(id, index, photo) } - if (message.diagnostics) { - Text("Diagnostics included", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 4.dp)) - } } } + if (message.diagnostics) { + Text("Diagnostics included", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } } } } diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackThreadViewModel.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackThreadViewModel.kt index 6e4d0f363..a0dbca377 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackThreadViewModel.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackThreadViewModel.kt @@ -1,23 +1,32 @@ package cx.aswin.boxlore.feature.settings.feedback +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.createSavedStateHandle import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.CreationExtras import cx.aswin.boxlore.core.support.ConversationResult import cx.aswin.boxlore.core.support.ReplyResult import cx.aswin.boxlore.core.support.SupportConversation import cx.aswin.boxlore.core.support.SupportRepository +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock data class FeedbackThreadUiState( val conversation: SupportConversation? = null, val loading: Boolean = true, /** Not this phone's conversation (any more), or support isn't available. */ val gone: Boolean = false, + /** The last load failed; with a conversation on screen it stays, with a way to try again. */ val loadFailed: Boolean = false, val loadingEarlier: Boolean = false, val replyOpen: Boolean = false, @@ -31,44 +40,89 @@ data class FeedbackThreadUiState( /** Mark solved: an open conversation the team has answered. */ val canSolve: Boolean get() = conversation?.let { c -> c.ending == null && c.thread.canReply && c.messages.any { it.fromTeam } } == true + + /** The newest message on screen: replies up to it count as read. */ + val newestShownAt: Long? get() = conversation?.messages?.maxOfOrNull { it.createdAt } } /** What a conversation screen needs from support; the factory wires [SupportRepository], tests pass fakes. */ class FeedbackThreadSupport( val load: suspend (id: String, before: String?) -> ConversationResult, val reply: suspend (id: String, body: String, requestId: String) -> ReplyResult, - val markRead: suspend (id: String) -> Unit, + val markRead: suspend (id: String, upTo: Long?) -> Unit, val solve: suspend (id: String) -> Boolean, val photo: suspend (id: String) -> ByteArray?, /** The server's message limit. */ val maxBody: () -> Int, + /** Conversation ids reply pushes arrived for. */ + val pushes: Flow = emptyFlow(), ) -class FeedbackThreadViewModel(private val support: FeedbackThreadSupport, private val threadId: String) : ViewModel() { - private val retryKeys = RetryKeys() - private val photoCache = mutableMapOf() - private val _uiState = MutableStateFlow(FeedbackThreadUiState()) +/** [RetryKeyStore] in the screen's saved state, so a reply retried after the process died keeps its key. */ +internal class SavedStateRetryKeyStore(private val handle: SavedStateHandle) : RetryKeyStore { + override fun load(): Pair? { + val fingerprint = handle.get(KEY_FINGERPRINT) ?: return null + val key = handle.get(KEY_REQUEST) ?: return null + return fingerprint to key + } + + override fun save(fingerprint: String, key: String) { + handle[KEY_FINGERPRINT] = fingerprint + handle[KEY_REQUEST] = key + } + + override fun clear() { + handle.remove(KEY_FINGERPRINT) + handle.remove(KEY_REQUEST) + } + + private companion object { + const val KEY_FINGERPRINT = "reply_fingerprint" + const val KEY_REQUEST = "reply_request_id" + } +} + +class FeedbackThreadViewModel internal constructor( + private val support: FeedbackThreadSupport, + private val threadId: String, + private val saved: SavedStateHandle = SavedStateHandle(), +) : ViewModel() { + private val retryKeys = RetryKeys(store = SavedStateRetryKeyStore(saved)) + private val photos = PhotoCache(support.photo) + private var readUpTo = 0L + private val _uiState = MutableStateFlow( + FeedbackThreadUiState(reply = saved[KEY_REPLY] ?: "", replyOpen = saved[KEY_REPLY_OPEN] ?: false), + ) val uiState: StateFlow = _uiState.asStateFlow() init { - reload() + // A reply to this conversation (or a push that doesn't say which) brings it up to date while it's open. + viewModelScope.launch { support.pushes.collect { id -> if (id == null || id == threadId) reload() } } } - /** Loads the newest page; an unread team reply is marked read once it is on screen. */ + /** Each time the conversation shows: load it fresh (the view model may be kept from an earlier visit). */ + fun activate() = reload() + fun reload() { _uiState.update { it.copy(loading = it.conversation == null, loadFailed = false) } viewModelScope.launch { when (val result = support.load(threadId, null)) { - is ConversationResult.Loaded -> { - _uiState.update { it.copy(conversation = result.conversation, loading = false, gone = false, loadFailed = false) } - if (result.conversation.thread.unread) runCatching { support.markRead(threadId) } - } - ConversationResult.Gone -> _uiState.update { it.copy(loading = false, gone = true) } + is ConversationResult.Loaded -> _uiState.update { it.copy(conversation = result.conversation, loading = false, gone = false, loadFailed = false) } + ConversationResult.Gone -> _uiState.update { it.copy(conversation = null, loading = false, gone = true, replyOpen = false) } ConversationResult.Failed -> _uiState.update { it.copy(loading = false, loadFailed = true) } } } } + /** The conversation is on screen: the team's replies up to the newest shown message count as read. */ + fun onSeen() { + val state = _uiState.value + val upTo = state.newestShownAt ?: return + if (state.conversation?.thread?.unread != true || upTo <= readUpTo) return + readUpTo = upTo + viewModelScope.launch { runCatching { support.markRead(threadId, upTo) } } + } + fun loadEarlier() { val current = _uiState.value.conversation ?: return val before = current.earlier ?: return @@ -90,11 +144,13 @@ class FeedbackThreadViewModel(private val support: FeedbackThreadSupport, privat fun onReplyOpen(open: Boolean) { if (_uiState.value.sending) return _uiState.update { it.copy(replyOpen = open && it.canReply, replyError = null) } + saved[KEY_REPLY_OPEN] = _uiState.value.replyOpen } fun onReplyChanged(text: String) { if (_uiState.value.sending) return _uiState.update { it.copy(reply = limitedMessage(it.reply, text, support.maxBody()), replyError = null) } + saved[KEY_REPLY] = _uiState.value.reply } fun sendReply() { @@ -107,11 +163,22 @@ class FeedbackThreadViewModel(private val support: FeedbackThreadSupport, privat when (val result = support.reply(threadId, body, requestId)) { is ReplyResult.Sent -> { retryKeys.clear() + saved.remove(KEY_REPLY) + saved[KEY_REPLY_OPEN] = false _uiState.update { it.copy(sending = false, reply = "", replyOpen = false) } reload() } is ReplyResult.Closed -> { - _uiState.update { it.copy(sending = false, replyOpen = false) } + saved[KEY_REPLY_OPEN] = false + // The server's answer says how it ended; show that now rather than wait for a reload. + _uiState.update { s -> + val c = s.conversation + s.copy( + sending = false, + replyOpen = false, + conversation = c?.copy(ending = result.ending ?: c.ending, thread = c.thread.copy(canReply = false)), + ) + } reload() } is ReplyResult.Refused -> _uiState.update { it.copy(sending = false, replyError = result.message) } @@ -132,25 +199,63 @@ class FeedbackThreadViewModel(private val support: FeedbackThreadSupport, privat } } - /** A photo's bytes, fetched once. */ - suspend fun photo(id: String): ByteArray? { - if (photoCache.containsKey(id)) return photoCache[id] - return support.photo(id).also { photoCache[id] = it } - } + /** A photo's bytes: fetched once while it's kept, and fetched again later if it failed. */ + suspend fun photo(id: String): ByteArray? = photos.get(id) class Factory(private val repository: SupportRepository, private val threadId: String) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = FeedbackThreadViewModel( + override fun create(modelClass: Class, extras: CreationExtras): T = FeedbackThreadViewModel( support = FeedbackThreadSupport( load = repository::conversation, reply = { id, body, key -> repository.reply(id, body, key) }, - markRead = { id -> repository.markRead(id) }, + markRead = { id, upTo -> repository.markRead(id, upTo) }, solve = repository::solve, photo = repository::photo, maxBody = { repository.meta.value.maxBody }, + pushes = repository.pushedReplies, ), threadId = threadId, + saved = extras.createSavedStateHandle(), ) as T } + private companion object { + const val KEY_REPLY = "reply" + const val KEY_REPLY_OPEN = "reply_open" + } +} + +/** + * The newest [MAX_PHOTOS] photos' bytes. One fetch per photo at a time; a failed fetch isn't + * kept, so the next look tries again. + */ +internal class PhotoCache(private val fetch: suspend (String) -> ByteArray?) { + private val lock = Mutex() + private val bytes = object : LinkedHashMap(MAX_PHOTOS, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > MAX_PHOTOS + } + private val inFlight = mutableMapOf>() + + suspend fun get(id: String): ByteArray? { + var mine: CompletableDeferred? = null + val waiting = lock.withLock { + bytes[id]?.let { return it } + inFlight[id] ?: CompletableDeferred().also { + inFlight[id] = it + mine = it + } + } + val owned = mine ?: return waiting.await() + val result = runCatching { fetch(id) }.getOrNull() + lock.withLock { + if (result != null) bytes[id] = result + inFlight.remove(id) + } + owned.complete(result) + return result + } + + private companion object { + const val MAX_PHOTOS = 12 + } } 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 680ee89ae..3bff53795 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 @@ -145,10 +145,12 @@ internal class RetryKeys( private val store: RetryKeyStore? = null, ) { private var last: Pair? = null + private var lastFingerprint: String? = null fun keyFor(content: Any): String { last?.let { (seen, key) -> if (seen == content) return key } val fingerprint = fingerprintOf(content) + lastFingerprint = fingerprint store?.load()?.let { (saved, key) -> if (saved == fingerprint) return key.also { last = content to it } } @@ -158,9 +160,11 @@ internal class RetryKeys( } } + /** Forgets this message's key; a key another message saved since (another form) stays. */ fun clear() { last = null - store?.clear() + if (store != null && store.load()?.first == lastFingerprint) store.clear() + lastFingerprint = null } private fun fingerprintOf(content: Any): String = @@ -387,7 +391,8 @@ class FeedbackViewModel internal constructor( withEmail = false, withDiagnostics = state.attachDiagnostics, ) - boxcastPrefs.clearFeedbackDraft() + // Clear the saved draft only if it is still the one sent: another form may have saved a new one meanwhile. + if (boxcastPrefs.getFeedbackDraft()?.message == state.message) boxcastPrefs.clearFeedbackDraft() retryKeys.clear() uploadedPhotos.clear() _uiState.update { diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackConversationsAdaptiveTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackConversationsAdaptiveTest.kt index bb03de2cf..4dc7d686d 100644 --- a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackConversationsAdaptiveTest.kt +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackConversationsAdaptiveTest.kt @@ -10,9 +10,11 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.Modifier import androidx.compose.ui.test.junit4.StateRestorationTester +import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput import androidx.compose.ui.unit.dp import androidx.test.core.app.ApplicationProvider import cx.aswin.boxlore.core.designsystem.echo.LocalEchoMotion @@ -27,6 +29,7 @@ import cx.aswin.boxlore.core.network.model.SupportMessageDto import cx.aswin.boxlore.core.network.model.SupportMetaDto import cx.aswin.boxlore.core.network.model.SupportNewThreadRequest import cx.aswin.boxlore.core.network.model.SupportPhotoResponse +import cx.aswin.boxlore.core.network.model.SupportReadRequest import cx.aswin.boxlore.core.network.model.SupportReplyRequest import cx.aswin.boxlore.core.network.model.SupportReplyResponse import cx.aswin.boxlore.core.network.model.SupportStatusDto @@ -77,7 +80,7 @@ class FeedbackConversationsAdaptiveTest { ), ) override suspend fun reply(id: String, request: SupportReplyRequest) = Response.success(SupportReplyResponse()) - override suspend fun markRead(id: String): Response { + override suspend fun markRead(id: String, request: SupportReadRequest): Response { reads += id return Response.success(Unit) } @@ -124,6 +127,26 @@ class FeedbackConversationsAdaptiveTest { assertEquals(1, backs.size) } + @Test + fun `a reply being written keeps its text and focus across the split gate, and the shown conversation stays when narrowing`() { + window.value = TABLET + render() + // Entered side by side: the newest conversation shows, and stays when the window narrows. + fixture.compose.runOnIdle { window.value = PHONE } + fixture.compose.waitForIdle() + fixture.compose.onNodeWithText("Message for $first").assertExists() + fixture.compose.onNodeWithText("Reply").performClick() + fixture.compose.waitForIdle() + fixture.compose.onNodeWithTag(FEEDBACK_REPLY_FIELD_TAG).performTextInput("Still happening") + fixture.compose.waitForIdle() + for (size in listOf(TABLET, PHONE, BoxloreWindowLayout(840f, 600f), BoxloreWindowLayout(839f, 600f))) { + fixture.compose.runOnIdle { window.value = size } + fixture.compose.waitForIdle() + fixture.compose.onNodeWithTag(FEEDBACK_REPLY_FIELD_TAG).assertIsFocused() + fixture.compose.onNodeWithText("Still happening").assertExists() + } + } + @Test fun `side by side, the newest conversation shows until another is picked, and an unread one is marked read`() { window.value = TABLET diff --git a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackThreadViewModelTest.kt b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackThreadViewModelTest.kt index df45e8cc0..6ff64ef74 100644 --- a/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackThreadViewModelTest.kt +++ b/feature/settings/src/test/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackThreadViewModelTest.kt @@ -40,6 +40,8 @@ class FeedbackThreadViewModelTest { var solves = 0 var photoCalls = 0 var maxBody = 2000 + var lastUpTo: Long? = null + val pushes = kotlinx.coroutines.flow.MutableSharedFlow(extraBufferCapacity = 4) fun support() = FeedbackThreadSupport( load = { _, before -> @@ -50,7 +52,10 @@ class FeedbackThreadViewModelTest { replies += body to key replyResult }, - markRead = { reads++ }, + markRead = { _, upTo -> + reads++ + lastUpTo = upTo + }, solve = { solves++ true @@ -60,6 +65,7 @@ class FeedbackThreadViewModelTest { byteArrayOf(1) }, maxBody = { maxBody }, + pushes = pushes, ) } @@ -70,22 +76,30 @@ class FeedbackThreadViewModelTest { @After fun tearDown() = Dispatchers.resetMain() - @Test fun `opening an unread conversation marks it read once`() = runTest(dispatcher) { + @Test fun `an unread conversation is marked read once it shows, up to the newest message`() = runTest(dispatcher) { val fake = Fake(mutableListOf(loaded(message("m1", false), message("m2", true), unread = true))) val vm = FeedbackThreadViewModel(fake.support(), "t") + vm.activate() advanceUntilIdle() assertEquals(2, vm.uiState.value.conversation?.messages?.size) + assertEquals("Nothing is read until it is on screen", 0, fake.reads) + vm.onSeen() + vm.onSeen() + advanceUntilIdle() assertEquals(1, fake.reads) + assertEquals(1L, fake.lastUpTo) assertTrue(vm.uiState.value.canSolve) } @Test fun `a gone conversation and a failed load say so, and a retry recovers`() = runTest(dispatcher) { val gone = FeedbackThreadViewModel(Fake(mutableListOf(ConversationResult.Gone)).support(), "t") + gone.activate() advanceUntilIdle() assertTrue(gone.uiState.value.gone) val fake = Fake(mutableListOf(ConversationResult.Failed, loaded(message("m1", false)))) val vm = FeedbackThreadViewModel(fake.support(), "t") + vm.activate() advanceUntilIdle() assertTrue(vm.uiState.value.loadFailed) vm.reload() @@ -98,6 +112,7 @@ class FeedbackThreadViewModelTest { @Test fun `earlier messages go before the ones on screen`() = runTest(dispatcher) { val fake = Fake(mutableListOf(loaded(message("m3", false), earlier = "m3"), loaded(message("m1", false), message("m2", true)))) val vm = FeedbackThreadViewModel(fake.support(), "t") + vm.activate() advanceUntilIdle() vm.loadEarlier() advanceUntilIdle() @@ -110,6 +125,7 @@ class FeedbackThreadViewModelTest { val fake = Fake(mutableListOf(loaded(message("m1", false), message("m2", true)))) fake.replyResult = ReplyResult.Failed val vm = FeedbackThreadViewModel(fake.support(), "t") + vm.activate() advanceUntilIdle() vm.onReplyOpen(true) vm.onReplyChanged(" Still happening ") @@ -132,6 +148,7 @@ class FeedbackThreadViewModelTest { val fake = Fake(mutableListOf(loaded(message("m1", false)), loaded(message("m1", false), ending = ending, canReply = false))) fake.replyResult = ReplyResult.Refused("Try again tomorrow.") val vm = FeedbackThreadViewModel(fake.support(), "t") + vm.activate() advanceUntilIdle() vm.onReplyOpen(true) vm.onReplyChanged("More") @@ -148,10 +165,67 @@ class FeedbackThreadViewModelTest { assertFalse(vm.uiState.value.replyOpen) } + @Test fun `a closed answer shows its ending at once, even if the reload then fails, and gone clears the screen`() = runTest(dispatcher) { + val ending = SupportEnding("closed", "This conversation is closed", "", 2, "new", "Send new feedback") + val fake = Fake(mutableListOf(loaded(message("m1", false)), ConversationResult.Failed)) + fake.replyResult = ReplyResult.Closed(ending) + val vm = FeedbackThreadViewModel(fake.support(), "t") + vm.activate() + advanceUntilIdle() + vm.onReplyOpen(true) + vm.onReplyChanged("Hello?") + vm.sendReply() + advanceUntilIdle() + assertEquals(ending, vm.uiState.value.conversation?.ending) + assertFalse(vm.uiState.value.canReply) + assertTrue("The failed reload is shown beside what is kept", vm.uiState.value.loadFailed) + + fake.pages = mutableListOf(ConversationResult.Gone) + vm.reload() + advanceUntilIdle() + assertNull(vm.uiState.value.conversation) + assertTrue(vm.uiState.value.gone) + } + + @Test fun `a reply push for this conversation reloads it, and the reply draft outlives the view model`() = runTest(dispatcher) { + val fake = Fake(mutableListOf(loaded(message("m1", false)), loaded(message("m1", false), message("m2", true)))) + val handle = androidx.lifecycle.SavedStateHandle() + val vm = FeedbackThreadViewModel(fake.support(), "t", handle) + vm.activate() + advanceUntilIdle() + fake.pushes.emit("other") + advanceUntilIdle() + assertEquals(1, vm.uiState.value.conversation?.messages?.size) + fake.pushes.emit("t") + advanceUntilIdle() + assertEquals(2, vm.uiState.value.conversation?.messages?.size) + + vm.onReplyOpen(true) + vm.onReplyChanged("Half a reply") + val again = FeedbackThreadViewModel(fake.support(), "t", handle) + assertEquals("Half a reply", again.uiState.value.reply) + assertTrue(again.uiState.value.replyOpen) + } + + @Test fun `a failed photo is fetched again, and a kept one isn't`() = runTest(dispatcher) { + var calls = 0 + var fail = true + val cache = PhotoCache { + calls++ + if (fail) null else byteArrayOf(1) + } + assertNull(cache.get("p1")) + fail = false + assertEquals(1, cache.get("p1")?.size) + cache.get("p1") + assertEquals(2, calls) + } + @Test fun `mark solved, the server's limit for replies, and photos fetched once`() = runTest(dispatcher) { val fake = Fake(mutableListOf(loaded(message("m1", false), message("m2", true)))) fake.maxBody = 5 val vm = FeedbackThreadViewModel(fake.support(), "t") + vm.activate() advanceUntilIdle() vm.solve() advanceUntilIdle() 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 94245c2a4..a0e58cc07 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 @@ -446,6 +446,21 @@ class FeedbackViewModelTest { assertNull(prefs.getFeedbackDraft()) } + @Test + fun a_send_finishing_late_doesnt_clear_a_newer_draft() = runTest(testDispatcher) { + val support = FakeSupport().apply { sendGate = CompletableDeferred() } + val vm = viewModel(support) + vm.onMessageChanged("Message A") + vm.onSubmit() + advanceUntilIdle() + // Another form (a tablet's new message) saved B meanwhile. + prefs.saveFeedbackDraft(FeedbackDraft(category = "bug", message = "Message B", email = "", stepsToReproduce = "", attachDiagnostics = false)) + support.sendGate!!.complete(Unit) + advanceUntilIdle() + assertTrue(vm.uiState.value.isSuccess) + assertEquals("Message B", prefs.getFeedbackDraft()?.message) + } + @Test fun the_phone_model_goes_only_with_diagnostics() = runTest(testDispatcher) { val support = FakeSupport() From ff54e3650e8183691185f55f1e5f885c1000a0bd Mon Sep 17 00:00:00 2001 From: Aswin C Date: Mon, 2 Nov 2026 03:14:11 +0530 Subject: [PATCH 7/8] fix(feedback): send a page view from each Feedback pane The list, compose and sent screens each send their own page view (feedback_list, feedback, feedback_sent; the conversation already sent feedback_thread), and the host that only holds them is exempt from the page-view guard. --- .../boxlore/core/testing/architecture/ScreenViewCoverageTest.kt | 2 ++ feature/settings/README.md | 2 +- .../boxlore/feature/settings/feedback/FeedbackListScreen.kt | 2 ++ .../aswin/boxlore/feature/settings/feedback/FeedbackScreen.kt | 2 +- .../boxlore/feature/settings/feedback/FeedbackSentScreen.kt | 2 ++ 5 files changed, 8 insertions(+), 2 deletions(-) diff --git a/core/testing/src/test/java/cx/aswin/boxlore/core/testing/architecture/ScreenViewCoverageTest.kt b/core/testing/src/test/java/cx/aswin/boxlore/core/testing/architecture/ScreenViewCoverageTest.kt index 1cf9779a7..61817a9f2 100644 --- a/core/testing/src/test/java/cx/aswin/boxlore/core/testing/architecture/ScreenViewCoverageTest.kt +++ b/core/testing/src/test/java/cx/aswin/boxlore/core/testing/architecture/ScreenViewCoverageTest.kt @@ -103,6 +103,8 @@ class ScreenViewCoverageTest { /** Destinations that need no page view of their own, and why. Keep it short. */ val EXEMPT: Map = mapOf( + "settings:feature/settings/feedback/FeedbackScreen.kt#FeedbackScreen" to + "Only hosts the Feedback panes; the list, conversation, compose and sent screens each send their own page view.", "home:feature/home/DebugScreen.kt#DebugScreen" to DEBUG, "home:feature/home/DebugScreenContent.kt#DebugScreenPagerPage" to DEBUG, "home:feature/home/components/MarqueePreview.kt#MarqueePreviewScreen" to DEBUG, diff --git a/feature/settings/README.md b/feature/settings/README.md index 8c8ecc0fe..29ab5cf13 100644 --- a/feature/settings/README.md +++ b/feature/settings/README.md @@ -53,7 +53,7 @@ Owns the unified Settings hub, category pages (Account, Sync & Backups, Appearan - Automatic downloads uses the shared two-line settings header and solid grouped surfaces for Downloads, Storage, and While boxlore is closed. A connected 1/2/3/5 selector replaces scrolling quota chips; a short introduction explains per-show activation. **Backup background checks** is explicitly described as optional recovery when show notifications are off or do not arrive; notifications can already trigger downloads while closed. Its network/charging controls appear only when the off-by-default switch is enabled, retaining their preferences when hidden. Concise state-dependent copy explains foreground/push discovery or battery/data costs and Android delays; the header help dialog holds the full network, consent, discovery and retention explanation. Per-show auto-download never enables background checking. - **Privacy page:** the opening card says analytics never includes the listener's name or email, and that an account is optional, only backs up and syncs the library, and is never linked to analytics (1 Nov 2026; it used to say there was no account). Wording changes here need the maintainer's approval of the exact text. -- **Page views** (`TrackScreenView`, `:core:designsystem`): `settings` sends `settings_interaction` `screen_viewed` through the helper; every page sends `screen_viewed` (`account` with `mode`, `sync_and_backups`, `library_settings`, `recommendations_settings`, `appearance` and its `appearance_theme`/`_home`/`_player`/`_navigation`/`_text`, `playback_settings`, `downloads_settings`, `smart_downloads_settings`, `auto_download_settings`, `privacy`, `about`, `feedback`), as do its dialogs. `support` keeps `support_page_viewed`. Account actions send `settings_interaction`: `account_sign_in_email_link`, `account_sign_in_google`, `account_sign_out`, `account_sync_now`, `account_delete_confirmed`. +- **Page views** (`TrackScreenView`, `:core:designsystem`): `settings` sends `settings_interaction` `screen_viewed` through the helper; every page sends `screen_viewed` (`account` with `mode`, `sync_and_backups`, `library_settings`, `recommendations_settings`, `appearance` and its `appearance_theme`/`_home`/`_player`/`_navigation`/`_text`, `playback_settings`, `downloads_settings`, `smart_downloads_settings`, `auto_download_settings`, `privacy`, `about`, `feedback` for "Share your thoughts", `feedback_list`, `feedback_thread` and `feedback_sent`), as do its dialogs. `support` keeps `support_page_viewed`. Account actions send `settings_interaction`: `account_sign_in_email_link`, `account_sign_in_google`, `account_sign_out`, `account_sync_now`, `account_delete_confirmed`. ## Internal structure diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackListScreen.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackListScreen.kt index 28ff82053..16f5b48d0 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackListScreen.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackListScreen.kt @@ -52,6 +52,7 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import cx.aswin.boxlore.core.designsystem.analytics.TrackScreenView import cx.aswin.boxlore.core.designsystem.layout.BoxloreCardFamily import cx.aswin.boxlore.core.designsystem.layout.boxloreCardWidth import cx.aswin.boxlore.core.designsystem.theme.ExpressivePressFeedback @@ -90,6 +91,7 @@ internal fun FeedbackListScreen( modifier: Modifier = Modifier, listState: LazyListState = rememberLazyListState(), ) { + TrackScreenView("feedback_list") val onBack = actions.onBack val systemBar = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() val bottom = bottomContentPadding + systemBar 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 6000a44ce..4636b8b08 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 @@ -79,7 +79,6 @@ fun FeedbackScreen( bottomContentPadding: Dp = 0.dp, openThreadId: String? = null, ) { - TrackScreenView("feedback") val context = LocalContext.current val uriHandler = LocalUriHandler.current val scope = rememberCoroutineScope() @@ -195,6 +194,7 @@ internal fun FeedbackComposeScreen( imeBottom: Dp, modifier: Modifier = Modifier, ) { + TrackScreenView("feedback") var messageFocused by rememberSaveable { mutableStateOf(false) } val systemBar = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() val typing = isTypingLayout(messageFocused, imeBottom) diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSentScreen.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSentScreen.kt index 304b4bc94..691dc1c4c 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSentScreen.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackSentScreen.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.LifecycleResumeEffect +import cx.aswin.boxlore.core.designsystem.analytics.TrackScreenView import cx.aswin.boxlore.core.designsystem.echo.EchoCharacter import cx.aswin.boxlore.core.designsystem.layout.BoxloreCardFamily import cx.aswin.boxlore.core.designsystem.layout.boxloreCardWidth @@ -85,6 +86,7 @@ internal fun FeedbackSentScreen( bottomContentPadding: Dp, modifier: Modifier = Modifier, ) { + TrackScreenView("feedback_sent") val context = LocalContext.current var notificationsOn by remember { mutableStateOf(areAppNotificationsEnabled(context)) } var dismissed by rememberSaveable { mutableStateOf(false) } From 6f5e0aebc6b2047f8612247260c74200f9271587 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Mon, 2 Nov 2026 03:19:55 +0530 Subject: [PATCH 8/8] docs(privacy): describe feedback conversations and replies The in-app Privacy page and docs/PRIVACY.md say what feedback sends, where conversations are kept and who they're tied to, what Telegram receives and never receives, how long photos are kept, and how to have conversations deleted. The Feedback screen's privacy link points at the new section. --- docs/PRIVACY.md | 7 +++++++ .../settings/feedback/FeedbackComposeParts.kt | 2 +- .../settings/pages/PrivacySettingsPage.kt | 20 +++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 176339afb..1df3528b1 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -69,6 +69,12 @@ Here is the data collected by our application and API services, and exactly how * **Why:** To see which design people respond to best. Each person keeps seeing the same design, so the comparison stays fair. * **Good to know:** This is part design survey, part our attempt to earn back what it costs to keep boxlore running. Rest assured, it's tied only to the anonymous analytics ID described above, never to your name, email or account. We just want a rough idea of which designs lead more people to support boxlore. +### 9. Feedback and Replies +* **What is sent:** What you write when you send feedback, the type you pick, and the app version. If you leave "Include device diagnostics" on, also your phone model, Android version, network type, audio output and language, and boxlore's own recent logs with keys, sign-in details and email addresses removed. Any photos or screenshots you attach. +* **Why:** So the boxlore team can read it, reply in the app, and fix what broke. +* **Where it goes:** Your conversations are stored on our servers, tied to your phone through a coded form of boxlore's install ID and a key stored on your phone (and to your account, also coded, if you're signed in, so they show on your other devices). They aren't linked to your analytics ID; analytics only notes that feedback was sent and its type. The team reads and answers in Telegram, which receives your message, its type, your phone model, app version and any photos, but never your email address, your logs or your analytics ID. Reply notifications go through Firebase Cloud Messaging. Older versions of boxlore also email your feedback to the team, with your email address if you gave one. +* **Good to know:** Conversations are kept until you ask us to delete them; photos are deleted 60 days after a conversation is solved or closed. Deleting your account doesn't delete them: email privacy@aswin.cx, tell us roughly what you wrote and when, and we'll delete them from our servers and the team's Telegram chat. + --- ## What Stays Strictly Local @@ -76,6 +82,7 @@ Here is the data collected by our application and API services, and exactly how * **Your Library Database & Sync Isolation:** Your downloaded audio files, custom tags, and complete library records live on your phone. If you choose to enable cloud sync, your backup data is stored solely for device synchronization and is never tied to or cross-referenced with telemetry events. (Individual actions like liking an episode or playing a show emit anonymous telemetry events as described above, but they are never connected to your personal identity or account email). * **Your Local Learning Brain:** The local ranking weights, skip counters, and playback habit adjustments that tune the order of your feeds live in your phone's local SQLite database. * **Your Backups:** A full JSON backup you export in Settings is created on your phone and saved to your phone storage; we don't receive a copy. If you turn on cloud sync, your library is stored on our servers so it can sync across your devices, as described above. +* **Your Feedback Draft:** A message you haven't sent yet stays on your phone. --- diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackComposeParts.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackComposeParts.kt index 43a8a888b..efa5a6a63 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackComposeParts.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/feedback/FeedbackComposeParts.kt @@ -69,7 +69,7 @@ internal const val FEEDBACK_SEND_TAG = "feedback_send" internal const val FEEDBACK_PILLS_TAG = "feedback_pills" internal const val FEEDBACK_TYPING_BAR_TAG = "feedback_typing_bar" internal const val FEEDBACK_PHOTOS_TAG = "feedback_photos" -internal const val FEEDBACK_PRIVACY_URL = "https://boxlore.in/privacy#feedback" +internal const val FEEDBACK_PRIVACY_URL = "https://boxlore.in/privacy/#feedback" /** The app's icon for the choices it knows; a speech bubble for any the server adds later. */ internal val FeedbackChoice.icon: ImageVector diff --git a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/PrivacySettingsPage.kt b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/PrivacySettingsPage.kt index d3964746b..2ded3fed3 100644 --- a/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/PrivacySettingsPage.kt +++ b/feature/settings/src/main/java/cx/aswin/boxlore/feature/settings/pages/PrivacySettingsPage.kt @@ -18,6 +18,7 @@ import androidx.compose.material.icons.rounded.ContentCopy import androidx.compose.material.icons.rounded.DeleteForever import androidx.compose.material.icons.rounded.Devices import androidx.compose.material.icons.rounded.Email +import androidx.compose.material.icons.rounded.Feedback import androidx.compose.material.icons.rounded.KeyboardArrowDown import androidx.compose.material.icons.rounded.KeyboardArrowUp import androidx.compose.material.icons.rounded.Lightbulb @@ -195,6 +196,25 @@ internal fun PrivacySettingsPage( ) } + SettingsGroup(title = "7 · Feedback") { + PrivacyCategoryContent( + icon = Icons.Rounded.Feedback, + collected = + "What you write when you send feedback, the type you pick, and the app version. " + + "If you leave “Include device diagnostics” on: your phone model, Android version, " + + "network type, audio output and language, plus boxlore’s own recent logs with keys, " + + "sign-in details and email addresses removed. Any photos or screenshots you attach.", + reason = + "So the boxlore team can read it, reply, and fix what broke. Your conversations are kept " + + "on our servers and tied to this phone (and to your account if you’re signed in), not to " + + "your analytics ID. The team reads and answers in Telegram, which gets your message, its " + + "type, your phone model, app version and photos — never your email, logs or analytics ID. " + + "Photos are deleted 60 days after a conversation is solved or closed. To delete your " + + "conversations, email privacy@aswin.cx.", + example = null, + ) + } + SettingsGroup( title = "On-device recommendations", footer =