feat(onboarding): Battery -> Unrestricted help animation (#174) #439
+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.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"
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
+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