Merge origin/main into refactor-308-ui-nits (resolve BatteryOptimizationStepTest import conflict with #174)
This commit is contained in:
+66
-107
@@ -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:
|
||||
|
||||
+31
-3
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -190,6 +190,12 @@
|
||||
<string name="onboarding_battery_done_title">You\'re all set</string>
|
||||
<string name="onboarding_battery_done_body">Background usage is unrestricted — new mail will arrive instantly.</string>
|
||||
<string name="onboarding_battery_continue">Continue to inbox</string>
|
||||
<!-- Battery opt-in "Battery → Unrestricted" guide illustration (#174). The description mirrors
|
||||
onboarding_battery_guidance so TalkBack users get the same steps; the row labels below are
|
||||
decorative (the illustration exposes only the single description to screen readers). -->
|
||||
<string name="onboarding_battery_animation_description">Animation showing how to enable unrestricted battery use: in your device settings, open Battery, then choose Unrestricted.</string>
|
||||
<string name="onboarding_battery_anim_battery">Battery</string>
|
||||
<string name="onboarding_battery_anim_unrestricted">Unrestricted</string>
|
||||
|
||||
<!-- Onboarding: optional contacts-access opt-in for recipient autocomplete (#127, #128) -->
|
||||
<string name="onboarding_contacts_title">Suggest recipients as you type</string>
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
+16
-1
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user