Merge pull request #439 from JMR-dev/feat-174-battery-usage-help

feat(onboarding): Battery -> Unrestricted help animation (#174)
This commit was merged in pull request #439.
This commit is contained in:
mergify[bot]
2026-07-08 15:19:10 +00:00
committed by GitHub
6 changed files with 441 additions and 14 deletions
@@ -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.compose.collectAsStateWithLifecycle
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
@@ -26,6 +29,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
@@ -62,6 +66,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
@@ -132,12 +155,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() } }
@@ -157,7 +183,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"
+6
View File
@@ -189,6 +189,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))
}
}
@@ -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.