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.