diff --git a/.mergify.yml b/.mergify.yml index 6e95b77..a434490 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -1,85 +1,65 @@ # SPDX-License-Identifier: GPL-3.0-or-later # # ============================================================================ -# Mergify configuration — PHASE 1: serial merge queue (issue #409). +# Mergify configuration — PHASE 2: batched merge queue (issue #410). # # Spec: docs/ci/mergify-integration-spec.md + docs/ci/mergify.yml.proposed # (issues #407 / #408). # Schema: https://docs.mergify.com/configuration/file-format/ -# Verified against the LIVE Mergify docs on 2026-07-07 (file-format, queue -# rules, priority, merge-queue lifecycle/setup/batches, and the -# merge-protections auto-merge pages) — the config format evolves, so this is +# Verified against the LIVE Mergify docs (file-format, queue rules, priority, +# merge-queue batches) on 2026-07-08 — the config format evolves, so this is # not from memory. -# 2026-07-07 CHANGE: auto-queueing migrated OFF the `pull_request_rules` -# queue-action path (which no longer auto-queues — a green matching PR just -# reported "Merge queue is ready — use `@Mergifyio queue`" and sat there) ONTO -# `merge_protections_settings.auto_merge_conditions` (see that block below). -# The old `autoqueue`/queue-action auto path is DEPRECATED and "will stop -# working on 2026-07-16" (docs.mergify.com/merge-queue/rules). This changes only -# the TRIGGER; the queue's merge semantics (below) are untouched. +# +# HISTORY +# Phase 1 (#409; landed #422, proven by #425/#426) ran a SERIAL queue — batch_size 1 +# + max_parallel_checks 1 + queue_conditions == merge_conditions — which kept GitHub's +# "Require branches up to date before merging" checkbox LITERALLY on. It was proven +# end-to-end: mergify[bot] auto-merged #425/#426, and serialised #426 -> #427 by +# updating #427 onto the new `main` (incl. #426) and re-running CI before merging. +# Phase 2 (this file, #410) turns on BATCHING now that the queue is proven. # ============================================================================ # # WHAT THIS DOES -# A SERIAL merge queue that ends the manual serial-bump grind and supersedes the -# hand-rolled "poor-man's merge queue" (autoupdate.yml + ci-trigger.yml + -# traffic-control.yml — all already `disabled_manually`). Mergify updates each -# queued PR onto the latest `main`, re-runs CI, and merges it with a MERGE COMMIT -# when the single required gate — the "CI passed" check — is green. One PR at a -# time, in P0–P9 priority order. +# A BATCHED merge queue. Mergify takes up to `batch_size` queued PRs, builds ONE +# speculative branch = (latest `main` + all the batched PRs), runs CI on that combined +# branch ONCE, and — if green — merges the whole batch (each as a MERGE COMMIT) in +# P0-P9 priority order. That is ~`batch_size`x the throughput of Phase-1 serial (one CI +# cycle merges many PRs, not one) while STILL testing every PR against the latest `main` +# (they all ride the same speculative batch branch). # -# HARD INVARIANTS (do NOT relax without the trilemma decision recorded in the spec): -# * require-up-to-date STAYS ON. This is Phase 1 = batch_size 1 + merge_method: -# merge — the ONLY trilemma combination that keeps GitHub's "Require branches to -# be up to date before merging" LITERALLY enabled AND preserves the merge-commit -# policy. Mergify honours it by updating each PR onto the latest `main` and -# re-running CI before merging ("Updates PRs against the latest main before -# merging" — docs.mergify.com/merge-queue/setup). NO batching: batching would -# require turning that checkbox OFF (docs.mergify.com/merge-queue/batches) and is -# the blocked Phase 2 / issue #410 — explicitly OUT OF SCOPE here. +# THE require-up-to-date SWAP (the one hard change from Phase 1 — do not misread it): +# * Batching is INCOMPATIBLE with GitHub's "Require branches to be up to date before +# merging" (docs.mergify.com/merge-queue/batches): a batch branch is by construction +# "ahead of" its member PRs, so that per-PR linear check cannot pass. It is therefore +# turned OFF in the `main` ruleset (18347032 -> `required_status_checks +# .strict_required_status_checks_policy` = false). The required "CI passed" CHECK +# itself STAYS required — only the "must be up to date" part is dropped. +# * The INVARIANT that option protected — never merge code untested against the latest +# `main` — is NOT lost; it MOVES to Mergify. The speculative batch branch IS +# latest-`main`-plus-the-batch, so a green batch check IS the against-latest-main +# test. This is the sanctioned swap (invariant preserved, enforcement relocated), +# authorised ONLY because Phase 1 proved the queue actually performs that update+re-CI. +# Do NOT drop require-up-to-date for any reason that does NOT relocate the invariant. # * The single required status check stays "CI passed" — the exact `name:` of the -# `ci-passed` job in .github/workflows/ci.yml. NOT "ci-passed". A wrong name means -# PRs queue but never merge. -# * IN-PLACE CHECKS, not speculative draft-PR checks. GitHub's strict -# `required_status_checks` ruleset (require-branches-up-to-date) rejects -# speculative checks outright — Mergify surfaced this as a "Configuration not -# compatible with `required_status_checks` ruleset rule" check on #422. The fix -# (per Mergify: docs.mergify.com/merge-queue/rules) is to make Mergify validate -# each PR IN PLACE, on the real PR branch, which requires ALL THREE of: -# (a) `merge_queue.max_parallel_checks: 1` (below), -# (b) every `queue_rules[].batch_size: 1` (below), and -# (c) `queue_rules.default.queue_conditions` IDENTICAL (same conditions, same -# order) to `queue_rules.default.merge_conditions` — i.e. no "two-step CI" -# where the conditions to ENTER the queue differ from the conditions to -# MERGE. Mergify runs three condition sets, sequentially: -# `merge_protections_settings.auto_merge_conditions` (TRIGGERS auto-queueing) -# → `queue_conditions` (validates a PR's queue ENTRY) → `merge_conditions` -# (validates the MERGE). Omitting `queue_conditions` — as this config first -# did — reads as a two-step-CI mismatch and re-trips the incompatibility -# check, so we keep all three lists identical. Do not let them drift apart. +# `ci-passed` job in .github/workflows/ci.yml. NOT "ci-passed". # --------------------------------------------------------------------------- -# queue_rules — how a queued PR is validated and merged. +# queue_rules — how a batch of queued PRs is validated and merged. # --------------------------------------------------------------------------- queue_rules: - name: default - # Final merge gate. Merge ONLY when the single required context is green (the exact - # same check branch protection requires), the PR targets `main`, is not a draft, has - # no merge conflicts, and is not flagged `broken`. NOTE: branch protection requires 0 - # approvals here (the active repository ruleset sets required_approving_review_count - # = 0), so there is deliberately NO `#approved-reviews-by` condition — adding one - # would wedge the solo-maintainer flow, where nobody can approve their own PR. + # Merge a batch ONLY when the combined batch branch is green on the single required + # context ("CI passed"), every member PR targets `main`, is not a draft, has no merge + # conflict, and is not flagged `broken`. NOTE: the ruleset requires 0 approvals + # (required_approving_review_count = 0), so there is deliberately NO `#approved-reviews-by` + # condition — it would wedge the solo-maintainer flow (nobody can approve their own PR). # - # IN-PLACE CHECKS: `queue_conditions` (what a PR must satisfy to ENTER/stay in the - # queue) MUST be IDENTICAL (same conditions, same order) to `merge_conditions` (what - # it must satisfy to MERGE) below. When those two lists match — plus batch_size 1 and - # max_parallel_checks 1 — Mergify validates each PR IN PLACE on the real PR branch - # instead of running speculative draft-PR checks, which is what GitHub's strict - # `required_status_checks` ruleset (require-branches-up-to-date) demands. Omitting - # `queue_conditions` (as this config originally did) is treated as a "two-step CI" - # mismatch and Mergify flags the ruleset as incompatible. Keep the three lists here — - # `queue_conditions`, `merge_conditions`, and - # `merge_protections_settings.auto_merge_conditions` — all identical; if any diverge, - # Mergify's ruleset-compatibility check fails again. + # queue_conditions (queue ENTRY) are kept IDENTICAL — same conditions, same order — to + # merge_conditions (MERGE). Under Phase 1 this identity was REQUIRED for in-place-checks + # compatibility with the strict ruleset; with require-up-to-date now off, batching uses + # speculative batch checks and the identity is no longer mandatory — but it is kept so + # auto_merge_conditions / queue_conditions / merge_conditions remain one single source of + # truth (no reason for entry and merge gates to differ). Keep all three lists identical. queue_conditions: - base = main - -draft @@ -92,31 +72,33 @@ queue_rules: - -conflict - label != broken - check-success = CI passed - # SERIAL: exactly one PR per merge. No batching (Phase 2 / #410). One merge commit - # per PR, which is what lets require-up-to-date stay literally ON. - batch_size: 1 - # Merge commit — never squash / rebase / fast-forward (repo policy: merges use - # merge commits, never squash). + # BATCHING (Phase 2 / #410): validate up to 5 PRs together on ONE speculative branch, so + # a single ~15-min CI cycle can merge up to 5 PRs instead of 1. `batch_max_wait_time` + # bounds how long Mergify waits to fill a batch before starting CI on a partial one, so a + # lone PR is not left waiting for companions. Requires the require-up-to-date checkbox OFF + # (see the SWAP note in the header). + batch_size: 5 + batch_max_wait_time: 5 min + # Merge commit — never squash / rebase / fast-forward (repo policy: merges use merge + # commits, never squash). merge_method: merge # --------------------------------------------------------------------------- # merge_queue — queue-wide options. # --------------------------------------------------------------------------- merge_queue: - # Validate ONE PR at a time — true serial, no speculative parallel checks. This is the - # strictest, unambiguously require-up-to-date-compatible setting: Mergify updates the - # REAL PR branch onto the latest `main`, runs CI on that branch, and merges on the real - # green "CI passed" — with no speculative temp-branch/real-branch check mismatch to - # reason about. It also caps the expensive, wedge-prone ~15-min E2E matrix at a single - # concurrent run. Raising this (speculative parallelism) is a throughput optimisation to - # weigh alongside the Phase 2 / #410 batching decision — not part of serial Phase 1. + # One batch validated at a time. `batch_size` (above) — not parallelism — is the Phase-2 + # throughput lever: a single batch of up to 5 PRs merges per CI cycle, keeping the + # expensive/wedge-prone ~15-min E2E matrix to ONE concurrent run. Raising this would run + # multiple batches' CI concurrently (more runner load / cost) — a later tuning knob, not + # needed to get the batching win. max_parallel_checks: 1 # --------------------------------------------------------------------------- -# priority_rules — map the repo's P0–P9 labels onto queue priority. +# priority_rules — map the repo's P0-P9 labels onto queue priority. # Higher number merges first (Mergify keywords: low=1000 / medium=2000 / high=3000; -# numeric range 1–10000). P0 is emergency-only and outranks everything. PRs with no P-label -# fall to Mergify's default `medium` (2000). +# numeric range 1-10000). P0 is emergency-only and outranks everything. PRs with no P-label +# fall to Mergify's default `medium` (2000). Priority also orders merges within a batch. # --------------------------------------------------------------------------- priority_rules: - name: p0-emergency @@ -170,37 +152,14 @@ priority_rules: # --------------------------------------------------------------------------- # merge_protections_settings — WHICH PRs are AUTOMATICALLY added to the queue. +# (Unchanged from Phase 1 — this is the auto-queue TRIGGER, orthogonal to batching.) # -# This REPLACES the old `pull_request_rules` `queue` action. That action no longer -# auto-queues in current Mergify: a green, matching PR just reported "Merge queue is -# ready — use `@Mergifyio queue`" and sat there forever (never merged). Automatic -# queueing now lives in `auto_merge_conditions` under `merge_protections_settings`. The -# old `queue_rules[].autoqueue` field (and the queue-action auto path) is DEPRECATED and -# "will stop working on 2026-07-16. Use `auto_merge_conditions` in -# `merge_protections_settings` instead" (docs.mergify.com/merge-queue/rules). +# Automatic queueing lives in `auto_merge_conditions` (the old `pull_request_rules` queue +# action no longer auto-queues — deprecated 2026-07-16). Same audience as before: green on +# "CI passed", targeting `main`, not a draft, no conflicts, not `broken`. A matched PR is +# auto-QUEUED (not merged directly); the batched queue then routes + merges it. # -# `auto_merge_conditions` accepts `true` (auto-queue every mergeable PR) or, as here, -# "a list of conditions to restrict the audience" (docs.mergify.com/configuration/ -# file-format). We give the SAME set the old queue action used, so EXACTLY the same PRs -# auto-queue: green on "CI passed", targeting `main`, not a draft, no conflicts, not -# `broken`. -# -# WHY THIS PRESERVES require-up-to-date: this changes only the TRIGGER (manual → -# automatic). It does NOT touch how the queue validates or merges — batch_size 1, -# merge_method merge, and max_parallel_checks 1 above are unchanged — and those are the -# settings that interact with require-up-to-date (only BATCHING, batch_size > 1, forces -# that checkbox OFF; see the invariants header + docs.mergify.com/merge-queue/batches). -# When a merge queue is configured, a matched PR is auto-QUEUED, not merged directly: -# "Every PR is auto-queued. The merge queue then handles routing and merging" -# (docs.mergify.com/merge-protections/auto-merge) — so it still goes through the serial -# queue, gets updated onto the latest `main`, re-runs CI, and merges on the real green -# "CI passed". Mergify also auto-reads GitHub branch protection (the required "CI passed" -# check + require-up-to-date) and injects it as a merge condition, so the GitHub gate is -# enforced on top of queue_rules.merge_conditions. -# -# This list MUST stay IDENTICAL (same conditions, same order) to -# `queue_rules.default.queue_conditions` and `.merge_conditions` above — see the note -# there and the IN-PLACE CHECKS hard invariant at the top of this file. +# Kept IDENTICAL (same conditions, same order) to queue_conditions / merge_conditions above. # --------------------------------------------------------------------------- merge_protections_settings: auto_merge_conditions: diff --git a/app/src/androidTest/kotlin/org/libremail/ui/onboarding/BatteryOptimizationStepTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/onboarding/BatteryOptimizationStepTest.kt index 99d8c95..8f5d435 100644 --- a/app/src/androidTest/kotlin/org/libremail/ui/onboarding/BatteryOptimizationStepTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/ui/onboarding/BatteryOptimizationStepTest.kt @@ -4,6 +4,7 @@ package org.libremail.ui.onboarding import android.app.Activity import android.app.Instrumentation import android.net.Uri +import android.os.ParcelFileDescriptor import android.provider.Settings import androidx.activity.ComponentActivity import androidx.compose.material3.Text @@ -11,8 +12,10 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavType @@ -27,6 +30,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import kotlinx.coroutines.runBlocking import org.hamcrest.CoreMatchers.allOf +import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -63,6 +67,25 @@ class BatteryOptimizationStepTest { composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty() } + /** + * Disable device animations (as CI's emulator-runner does) so [BatteryOptimizationScreen] renders + * the reduced-motion static guide illustration (#174): the looping variant's infinite transition + * would otherwise never let Compose/Espresso `waitForIdle` settle on a local emulator that boots + * with animations on. + */ + @Before + fun disableAnimations() { + val automation = InstrumentationRegistry.getInstrumentation().uiAutomation + listOf( + "settings put global animator_duration_scale 0", + "settings put global window_animation_scale 0", + "settings put global transition_animation_scale 0", + ).forEach { command -> + ParcelFileDescriptor.AutoCloseInputStream(automation.executeShellCommand(command)) + .use { it.readBytes() } + } + } + /** * Renders the "add another? → battery → inbox" tail with one account already added this session, * starting on the add-another prompt. [handled] seeds the persisted "prompt handled" flag so the @@ -134,12 +157,15 @@ class BatteryOptimizationStepTest { composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_no)).performClick() - // The battery opt-in step is shown... + // The battery opt-in step is shown, with the illustrated "Battery → Unrestricted" guide... waitForText(string(R.string.onboarding_battery_title)) composeTestRule.onNodeWithText(string(R.string.onboarding_battery_title)).assertIsDisplayed() + composeTestRule + .onNodeWithContentDescription(string(R.string.onboarding_battery_animation_description)) + .assertIsDisplayed() // ...and "Not now" continues to the inbox and records the prompt as handled (so it won't nag). - composeTestRule.onNodeWithText(string(R.string.onboarding_battery_not_now)).performClick() + composeTestRule.onNodeWithText(string(R.string.onboarding_battery_not_now)).performScrollTo().performClick() waitForText(INBOX_MARKER) composeTestRule.onNodeWithText(INBOX_MARKER).assertIsDisplayed() composeTestRule.waitUntil(5_000) { runBlocking { settingsRepository.isBatteryPromptHandled() } } @@ -159,7 +185,9 @@ class BatteryOptimizationStepTest { Intents.intending(hasAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)) .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null)) - composeTestRule.onNodeWithText(string(R.string.onboarding_battery_take_me)).performClick() + composeTestRule.onNodeWithText(string(R.string.onboarding_battery_take_me)) + .performScrollTo() + .performClick() // Deep-links to *this app's* details screen (where Battery → Unrestricted lives). Intents.intended( diff --git a/app/src/main/kotlin/org/libremail/ui/onboarding/BatteryGuideAnimation.kt b/app/src/main/kotlin/org/libremail/ui/onboarding/BatteryGuideAnimation.kt new file mode 100644 index 0000000..abac1ce --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/onboarding/BatteryGuideAnimation.kt @@ -0,0 +1,249 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.onboarding + +import android.content.Context +import android.provider.Settings +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.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.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.libremail.R + +/** + * Lightweight, dependency-free "Battery → Unrestricted" walkthrough shown on + * [BatteryOptimizationScreen] before the user leaves for system settings (#174). It is the first + * animation in the app, so the approach was chosen to add **no** new dependency (no Lottie, no + * `AnimatedVectorDrawable`): a stylized Compose illustration driven by [rememberInfiniteTransition]. + * + * The visual is deliberately **generic** — a faux settings card with a "Battery" row (tap it) and an + * "Unrestricted" option (choose it), not a screen recording of any one OEM's real UI, which varies by + * manufacturer (#150) and would look wrong or go stale on most devices. A looping highlight moves from + * the Battery row to the Unrestricted option while a "tap" dot pulses, illustrating the two-step path. + * + * Accessibility (all required by #174): + * - **Reduced motion:** when the system "Remove animations" setting is on ([rememberReducedMotion]), + * the same card renders **at rest** (no infinite transition) — a static illustration of the end + * state instead of movement. + * - **TalkBack:** the whole illustration exposes a single [contentDescription] (its decorative inner + * labels are cleared), mirroring the on-screen `onboarding_battery_guidance` text so screen-reader + * users get the same steps. The animation is additive — the guidance text always stays on screen. + * + * @param reducedMotion when true, render the static (motionless) variant. Defaults to the live system + * setting; overridable so tests can drive either path deterministically. + */ +@Composable +fun BatteryGuideAnimation(modifier: Modifier = Modifier, reducedMotion: Boolean = rememberReducedMotion()) { + val description = stringResource(R.string.onboarding_battery_animation_description) + Box( + modifier = modifier + .fillMaxWidth() + .widthIn(max = 360.dp) + .clearAndSetSemantics { contentDescription = description }, + contentAlignment = Alignment.Center, + ) { + if (reducedMotion) { + // Static fallback: the end state at rest — "Battery ›" then "Unrestricted ✓", no motion. + GuideCard(focusUnrestricted = true, tapAlpha = 0f) + } else { + val transition = rememberInfiniteTransition(label = "batteryGuide") + // 0f..1f highlights the Battery row; 1f..2f highlights the Unrestricted option, then loops. + val phase by transition.animateFloat( + initialValue = 0f, + targetValue = 2f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 3600, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "phase", + ) + // A gentle pulse for the "tap here" dot so the guide never looks frozen. + val tapAlpha by transition.animateFloat( + initialValue = 0.25f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 900, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "tap", + ) + GuideCard(focusUnrestricted = phase >= 1f, tapAlpha = tapAlpha) + } + } +} + +/** + * Reads the system "animation duration scale" once and reports whether animations are effectively + * off (scale 0 — the "Remove animations" accessibility setting, or a battery-saver / test harness + * that disables them). Callers use it to skip motion in favour of a static illustration. + */ +@Composable +fun rememberReducedMotion(): Boolean { + val context = LocalContext.current + return remember(context) { isReducedMotion(context) } +} + +/** Non-composable core of [rememberReducedMotion], split out so it is unit-testable without Compose. */ +internal fun isReducedMotion(context: Context): Boolean { + val scale = Settings.Global.getFloat( + context.contentResolver, + Settings.Global.ANIMATOR_DURATION_SCALE, + ANIMATIONS_ENABLED_SCALE, + ) + return scale == NO_ANIMATION_SCALE +} + +/** + * The faux settings card: a decorative header pill above the "Battery" row and the "Unrestricted" + * option. [focusUnrestricted] moves the highlight/selection from the first row to the second (the + * choice being demonstrated); [tapAlpha] drives the pulsing "tap here" dot on the focused row. + */ +@Composable +private fun GuideCard(focusUnrestricted: Boolean, tapAlpha: Float) { + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + // Decorative "screen title" pill — hints "a system settings screen" without naming an OEM. + Box( + Modifier + .width(96.dp) + .height(10.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f)), + ) + GuideRow( + label = stringResource(R.string.onboarding_battery_anim_battery), + highlighted = !focusUnrestricted, + tapAlpha = if (focusUnrestricted) 0f else tapAlpha, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + GuideRow( + label = stringResource(R.string.onboarding_battery_anim_unrestricted), + highlighted = focusUnrestricted, + tapAlpha = if (focusUnrestricted) tapAlpha else 0f, + ) { + if (focusUnrestricted) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } else { + Box( + Modifier + .size(20.dp) + .border(2.dp, MaterialTheme.colorScheme.outline, CircleShape), + ) + } + } + } + } +} + +/** + * One row of the faux settings list: a generic leading glyph (a dependency-free stand-in for an OEM + * setting icon), the [label], a pulsing "tap here" dot (via [tapAlpha]) and a caller-supplied + * [trailing] affordance (a chevron for "opens a sub-screen", a check/radio for "selectable option"). + */ +@Composable +private fun GuideRow(label: String, highlighted: Boolean, tapAlpha: Float, trailing: @Composable () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background( + if (highlighted) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surface + }, + ) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + Modifier + .size(24.dp) + .clip(RoundedCornerShape(6.dp)) + .background( + if (highlighted) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + }, + ), + ) + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (highlighted) FontWeight.SemiBold else FontWeight.Normal, + color = if (highlighted) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + modifier = Modifier.weight(1f), + ) + Box( + Modifier + .size(12.dp) + .alpha(tapAlpha) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)), + ) + trailing() + } +} + +// Animation-scale sentinels for isReducedMotion (kept as named constants so detekt's MagicNumber rule +// — which is not relaxed for this non-@Composable helper — stays satisfied). +private const val ANIMATIONS_ENABLED_SCALE = 1f +private const val NO_ANIMATION_SCALE = 0f diff --git a/app/src/main/kotlin/org/libremail/ui/onboarding/BatteryOptimizationScreen.kt b/app/src/main/kotlin/org/libremail/ui/onboarding/BatteryOptimizationScreen.kt index 9ad77bc..9c4d10a 100644 --- a/app/src/main/kotlin/org/libremail/ui/onboarding/BatteryOptimizationScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/onboarding/BatteryOptimizationScreen.kt @@ -1,7 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.ui.onboarding -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -10,6 +9,8 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Notifications @@ -20,6 +21,7 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -31,35 +33,51 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.libremail.R +import org.libremail.reporting.AppLog /** * Final onboarding step (shown only when needed, see [OnboardingViewModel.batteryPromptNeeded]): * invites the user to allow unrestricted background/battery usage so push and periodic sync aren't - * throttled by Doze. **Take me there** deep-links as directly as possible toward the per-app battery - * screen (see [org.libremail.push.BatteryOptimizationManager] for the best-effort fallback chain; no - * restricted permission is ever used); **Not now** skips. Either way [onFinish] proceeds to the inbox. - * On returning from Settings the status is re-read and, if the app is now unrestricted, the screen - * reflects that with a "done" state. + * throttled by Doze. A short, dependency-free [BatteryGuideAnimation] illustrates the "Battery → + * Unrestricted" path **before** the user leaves the app (#174), since the deep link can't guarantee + * landing on the exact per-OEM screen (#150); the guidance text stays on screen for TalkBack and + * reduced-motion users. **Take me there** deep-links as directly as possible toward the per-app + * battery screen (see [org.libremail.push.BatteryOptimizationManager] for the best-effort fallback + * chain; no restricted permission is ever used); **Not now** skips. Either way [onFinish] proceeds to + * the inbox. On returning from Settings the status is re-read and, if the app is now unrestricted, the + * screen reflects that with a "done" state. * * @param viewModel the graph-scoped onboarding view model (holds live battery status + the flag). * @param onFinish leaves onboarding for the inbox; the caller also marks the prompt handled. + * @param reducedMotion whether to render the static (motionless) guide; defaults to the live system + * "Remove animations" setting, overridable so tests drive either path deterministically. */ @Composable -fun BatteryOptimizationScreen(viewModel: OnboardingViewModel, onFinish: () -> Unit) { +fun BatteryOptimizationScreen( + viewModel: OnboardingViewModel, + onFinish: () -> Unit, + reducedMotion: Boolean = rememberReducedMotion(), +) { val unrestricted by viewModel.batteryUnrestricted.collectAsStateWithLifecycle() val context = LocalContext.current // Re-check on every resume so returning from the system settings screen reflects the new state. LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.refreshBatteryStatus() } + // One-shot breadcrumb (PII-free) so a debug report shows the step was reached, plus the two state + // booleans that steer what it renders (already-unrestricted "done" state, and static vs animated). + LaunchedEffect(Unit) { + AppLog.i(TAG, "Battery opt-in shown (unrestricted=$unrestricted, reducedMotion=$reducedMotion)") + } + Scaffold { padding -> Column( modifier = Modifier .fillMaxSize() + .verticalScroll(rememberScrollState()) .padding(padding) .padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, ) { Icon( imageVector = if (unrestricted) Icons.Filled.CheckCircle else Icons.Filled.Notifications, @@ -88,12 +106,19 @@ fun BatteryOptimizationScreen(viewModel: OnboardingViewModel, onFinish: () -> Un if (unrestricted) { Button( - onClick = onFinish, + onClick = { + AppLog.i(TAG, "Battery opt-in: continue to inbox") + onFinish() + }, modifier = Modifier.fillMaxWidth().widthIn(max = 360.dp), ) { Text(stringResource(R.string.onboarding_battery_continue)) } } else { + // Illustrated "tap Battery → choose Unrestricted" guide, above the (retained) text + // guidance so the animation is additive, not a replacement for the accessible path. + BatteryGuideAnimation(reducedMotion = reducedMotion) + Spacer(Modifier.height(24.dp)) Text( text = stringResource(R.string.onboarding_battery_guidance), style = MaterialTheme.typography.bodyMedium, @@ -105,8 +130,10 @@ fun BatteryOptimizationScreen(viewModel: OnboardingViewModel, onFinish: () -> Un onClick = { // Mark handled up front: the user is leaving for Settings and might not return // to this screen. Launching app-details always resolves; guard defensively. + AppLog.i(TAG, "Battery opt-in: opening system settings") viewModel.markBatteryPromptHandled() runCatching { context.startActivity(viewModel.batterySettingsIntent()) } + .onFailure { AppLog.w(TAG, "Battery settings intent failed to launch", it) } }, modifier = Modifier.fillMaxWidth().widthIn(max = 360.dp), ) { @@ -114,7 +141,10 @@ fun BatteryOptimizationScreen(viewModel: OnboardingViewModel, onFinish: () -> Un } Spacer(Modifier.height(12.dp)) OutlinedButton( - onClick = onFinish, + onClick = { + AppLog.i(TAG, "Battery opt-in skipped") + onFinish() + }, modifier = Modifier.fillMaxWidth().widthIn(max = 360.dp), ) { Text(stringResource(R.string.onboarding_battery_not_now)) @@ -123,3 +153,5 @@ fun BatteryOptimizationScreen(viewModel: OnboardingViewModel, onFinish: () -> Un } } } + +private const val TAG = "BatteryOptIn" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4fd05e4..0f9dbb6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -190,6 +190,12 @@ You\'re all set Background usage is unrestricted — new mail will arrive instantly. Continue to inbox + + Animation showing how to enable unrestricted battery use: in your device settings, open Battery, then choose Unrestricted. + Battery + Unrestricted Suggest recipients as you type diff --git a/app/src/test/kotlin/org/libremail/ui/onboarding/BatteryGuideAnimationJvmTest.kt b/app/src/test/kotlin/org/libremail/ui/onboarding/BatteryGuideAnimationJvmTest.kt new file mode 100644 index 0000000..c15f4cf --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/onboarding/BatteryGuideAnimationJvmTest.kt @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.onboarding + +import android.content.Context +import android.provider.Settings +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.R +import org.libremail.ui.theme.LibreMailTheme +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * Robolectric JVM Compose test (#174) for the dependency-free "Battery → Unrestricted" guide + * illustration. Covers both the looping animated variant and the reduced-motion static fallback, the + * default `reducedMotion` argument reading the system animation-scale setting, and the non-composable + * [isReducedMotion] decision. The illustration exposes a single [contentDescription] to TalkBack, so + * every render is asserted through it. + * + * The animated variant is driven with `mainClock.autoAdvance = false` and hand-advanced, so the + * infinite transition never spins the Robolectric clock into a `waitForIdle` hang. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [36], qualifiers = "+w411dp-h800dp") +class BatteryGuideAnimationJvmTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val context: Context get() = RuntimeEnvironment.getApplication() + + private fun description() = context.getString(R.string.onboarding_battery_animation_description) + + @Test + fun reducedMotion_rendersStaticGuideWithDescription() { + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + BatteryGuideAnimation(reducedMotion = true) + } + } + + composeTestRule.onNodeWithContentDescription(description()).assertIsDisplayed() + } + + @Test + fun motionOn_rendersAnimatedGuide_acrossBothSteps() { + // Hand-drive the clock so the looping guide can't hang waitForIdle. + composeTestRule.mainClock.autoAdvance = false + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + BatteryGuideAnimation(reducedMotion = false) + } + } + + // First frame: the "Battery" step is highlighted. + composeTestRule.onNodeWithContentDescription(description()).assertIsDisplayed() + // Advance past the halfway point so the highlight/selection moves to the "Unrestricted" step. + composeTestRule.mainClock.advanceTimeBy(2000) + composeTestRule.onNodeWithContentDescription(description()).assertIsDisplayed() + } + + @Test + fun defaultReducedMotionArg_readsSystemAnimationScale() { + Settings.Global.putFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 0f) + // Defensive: even if the setting round-trip surprised us into the animated branch, a manual + // clock keeps the test from hanging. + composeTestRule.mainClock.autoAdvance = false + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + BatteryGuideAnimation() + } + } + + composeTestRule.onNodeWithContentDescription(description()).assertIsDisplayed() + } + + @Test + fun isReducedMotion_trueWhenAnimationsDisabled() { + Settings.Global.putFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 0f) + assertTrue(isReducedMotion(context)) + } + + @Test + fun isReducedMotion_falseWhenAnimationsEnabled() { + Settings.Global.putFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) + assertFalse(isReducedMotion(context)) + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/onboarding/BatteryOptimizationScreenJvmTest.kt b/app/src/test/kotlin/org/libremail/ui/onboarding/BatteryOptimizationScreenJvmTest.kt index 9474018..4ae42cf 100644 --- a/app/src/test/kotlin/org/libremail/ui/onboarding/BatteryOptimizationScreenJvmTest.kt +++ b/app/src/test/kotlin/org/libremail/ui/onboarding/BatteryOptimizationScreenJvmTest.kt @@ -7,6 +7,7 @@ import android.provider.Settings import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.lifecycle.Lifecycle @@ -19,6 +20,7 @@ import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue +import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -54,6 +56,14 @@ class BatteryOptimizationScreenJvmTest { private fun string(resId: Int): String = context.getString(resId) + // Force the reduced-motion (static) illustration so the looping guide animation never spins the + // Robolectric clock (which would hang waitForIdle); the animated path is covered by + // BatteryGuideAnimationJvmTest. Also exercises the screen's default rememberReducedMotion argument. + @Before + fun forceReducedMotion() { + Settings.Global.putFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 0f) + } + /** RESUMED owner so `collectAsStateWithLifecycle` collects and the ON_RESUME effect fires. */ private val resumedOwner = object : LifecycleOwner { private val registry = @@ -79,9 +89,11 @@ class BatteryOptimizationScreenJvmTest { setContent(vm, onFinish = { finished = true }) composeTestRule.onNodeWithText(string(R.string.onboarding_battery_done_title)).assertIsDisplayed() - // The request/skip affordances are gone in the "done" state. + // The request/skip affordances — and the how-to guide illustration — are gone in the "done" state. composeTestRule.onNodeWithText(string(R.string.onboarding_battery_take_me)).assertDoesNotExist() composeTestRule.onNodeWithText(string(R.string.onboarding_battery_not_now)).assertDoesNotExist() + composeTestRule.onNodeWithContentDescription(string(R.string.onboarding_battery_animation_description)) + .assertDoesNotExist() composeTestRule.onNodeWithText(string(R.string.onboarding_battery_continue)).performClick() assertTrue(finished) @@ -99,6 +111,9 @@ class BatteryOptimizationScreenJvmTest { composeTestRule.onNodeWithText(string(R.string.onboarding_battery_title)).assertIsDisplayed() composeTestRule.onNodeWithText(string(R.string.onboarding_battery_guidance)).assertIsDisplayed() + // The illustrated "Battery → Unrestricted" guide is shown (as one TalkBack-friendly node). + composeTestRule.onNodeWithContentDescription(string(R.string.onboarding_battery_animation_description)) + .assertIsDisplayed() composeTestRule.onNodeWithText(string(R.string.onboarding_battery_take_me)).performClick() // Take me there marks the prompt handled up front, then launches the resolved settings intent.