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:
+31
-3
@@ -4,6 +4,7 @@ package org.libremail.ui.onboarding
|
|||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.app.Instrumentation
|
import android.app.Instrumentation
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.ParcelFileDescriptor
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.compose.material3.Text
|
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.assertIsDisplayed
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
import androidx.compose.ui.test.onAllNodesWithText
|
import androidx.compose.ui.test.onAllNodesWithText
|
||||||
|
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.test.performScrollTo
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation.NavType
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
@@ -26,6 +29,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
|||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.hamcrest.CoreMatchers.allOf
|
import org.hamcrest.CoreMatchers.allOf
|
||||||
|
import org.junit.Before
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
@@ -62,6 +66,25 @@ class BatteryOptimizationStepTest {
|
|||||||
composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty()
|
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,
|
* 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
|
* 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()
|
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))
|
waitForText(string(R.string.onboarding_battery_title))
|
||||||
composeTestRule.onNodeWithText(string(R.string.onboarding_battery_title)).assertIsDisplayed()
|
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).
|
// ...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)
|
waitForText(INBOX_MARKER)
|
||||||
composeTestRule.onNodeWithText(INBOX_MARKER).assertIsDisplayed()
|
composeTestRule.onNodeWithText(INBOX_MARKER).assertIsDisplayed()
|
||||||
composeTestRule.waitUntil(5_000) { runBlocking { settingsRepository.isBatteryPromptHandled() } }
|
composeTestRule.waitUntil(5_000) { runBlocking { settingsRepository.isBatteryPromptHandled() } }
|
||||||
@@ -157,7 +183,9 @@ class BatteryOptimizationStepTest {
|
|||||||
Intents.intending(hasAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS))
|
Intents.intending(hasAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS))
|
||||||
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null))
|
.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).
|
// Deep-links to *this app's* details screen (where Battery → Unrestricted lives).
|
||||||
Intents.intended(
|
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
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
package org.libremail.ui.onboarding
|
package org.libremail.ui.onboarding
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.widthIn
|
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.Icons
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
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.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -31,35 +33,51 @@ import androidx.lifecycle.Lifecycle
|
|||||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import org.libremail.R
|
import org.libremail.R
|
||||||
|
import org.libremail.reporting.AppLog
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Final onboarding step (shown only when needed, see [OnboardingViewModel.batteryPromptNeeded]):
|
* 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
|
* 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
|
* throttled by Doze. A short, dependency-free [BatteryGuideAnimation] illustrates the "Battery →
|
||||||
* screen (see [org.libremail.push.BatteryOptimizationManager] for the best-effort fallback chain; no
|
* Unrestricted" path **before** the user leaves the app (#174), since the deep link can't guarantee
|
||||||
* restricted permission is ever used); **Not now** skips. Either way [onFinish] proceeds to the inbox.
|
* landing on the exact per-OEM screen (#150); the guidance text stays on screen for TalkBack and
|
||||||
* On returning from Settings the status is re-read and, if the app is now unrestricted, the screen
|
* reduced-motion users. **Take me there** deep-links as directly as possible toward the per-app
|
||||||
* reflects that with a "done" state.
|
* 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 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 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
|
@Composable
|
||||||
fun BatteryOptimizationScreen(viewModel: OnboardingViewModel, onFinish: () -> Unit) {
|
fun BatteryOptimizationScreen(
|
||||||
|
viewModel: OnboardingViewModel,
|
||||||
|
onFinish: () -> Unit,
|
||||||
|
reducedMotion: Boolean = rememberReducedMotion(),
|
||||||
|
) {
|
||||||
val unrestricted by viewModel.batteryUnrestricted.collectAsStateWithLifecycle()
|
val unrestricted by viewModel.batteryUnrestricted.collectAsStateWithLifecycle()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
// Re-check on every resume so returning from the system settings screen reflects the new state.
|
// Re-check on every resume so returning from the system settings screen reflects the new state.
|
||||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.refreshBatteryStatus() }
|
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 ->
|
Scaffold { padding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.padding(24.dp),
|
.padding(24.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = if (unrestricted) Icons.Filled.CheckCircle else Icons.Filled.Notifications,
|
imageVector = if (unrestricted) Icons.Filled.CheckCircle else Icons.Filled.Notifications,
|
||||||
@@ -88,12 +106,19 @@ fun BatteryOptimizationScreen(viewModel: OnboardingViewModel, onFinish: () -> Un
|
|||||||
|
|
||||||
if (unrestricted) {
|
if (unrestricted) {
|
||||||
Button(
|
Button(
|
||||||
onClick = onFinish,
|
onClick = {
|
||||||
|
AppLog.i(TAG, "Battery opt-in: continue to inbox")
|
||||||
|
onFinish()
|
||||||
|
},
|
||||||
modifier = Modifier.fillMaxWidth().widthIn(max = 360.dp),
|
modifier = Modifier.fillMaxWidth().widthIn(max = 360.dp),
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.onboarding_battery_continue))
|
Text(stringResource(R.string.onboarding_battery_continue))
|
||||||
}
|
}
|
||||||
} else {
|
} 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(
|
||||||
text = stringResource(R.string.onboarding_battery_guidance),
|
text = stringResource(R.string.onboarding_battery_guidance),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
@@ -105,8 +130,10 @@ fun BatteryOptimizationScreen(viewModel: OnboardingViewModel, onFinish: () -> Un
|
|||||||
onClick = {
|
onClick = {
|
||||||
// Mark handled up front: the user is leaving for Settings and might not return
|
// Mark handled up front: the user is leaving for Settings and might not return
|
||||||
// to this screen. Launching app-details always resolves; guard defensively.
|
// to this screen. Launching app-details always resolves; guard defensively.
|
||||||
|
AppLog.i(TAG, "Battery opt-in: opening system settings")
|
||||||
viewModel.markBatteryPromptHandled()
|
viewModel.markBatteryPromptHandled()
|
||||||
runCatching { context.startActivity(viewModel.batterySettingsIntent()) }
|
runCatching { context.startActivity(viewModel.batterySettingsIntent()) }
|
||||||
|
.onFailure { AppLog.w(TAG, "Battery settings intent failed to launch", it) }
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth().widthIn(max = 360.dp),
|
modifier = Modifier.fillMaxWidth().widthIn(max = 360.dp),
|
||||||
) {
|
) {
|
||||||
@@ -114,7 +141,10 @@ fun BatteryOptimizationScreen(viewModel: OnboardingViewModel, onFinish: () -> Un
|
|||||||
}
|
}
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = onFinish,
|
onClick = {
|
||||||
|
AppLog.i(TAG, "Battery opt-in skipped")
|
||||||
|
onFinish()
|
||||||
|
},
|
||||||
modifier = Modifier.fillMaxWidth().widthIn(max = 360.dp),
|
modifier = Modifier.fillMaxWidth().widthIn(max = 360.dp),
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.onboarding_battery_not_now))
|
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_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_done_body">Background usage is unrestricted — new mail will arrive instantly.</string>
|
||||||
<string name="onboarding_battery_continue">Continue to inbox</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) -->
|
<!-- Onboarding: optional contacts-access opt-in for recipient autocomplete (#127, #128) -->
|
||||||
<string name="onboarding_contacts_title">Suggest recipients as you type</string>
|
<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.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
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.onNodeWithText
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
@@ -19,6 +20,7 @@ import io.mockk.verify
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
@@ -54,6 +56,14 @@ class BatteryOptimizationScreenJvmTest {
|
|||||||
|
|
||||||
private fun string(resId: Int): String = context.getString(resId)
|
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. */
|
/** RESUMED owner so `collectAsStateWithLifecycle` collects and the ON_RESUME effect fires. */
|
||||||
private val resumedOwner = object : LifecycleOwner {
|
private val resumedOwner = object : LifecycleOwner {
|
||||||
private val registry =
|
private val registry =
|
||||||
@@ -79,9 +89,11 @@ class BatteryOptimizationScreenJvmTest {
|
|||||||
setContent(vm, onFinish = { finished = true })
|
setContent(vm, onFinish = { finished = true })
|
||||||
|
|
||||||
composeTestRule.onNodeWithText(string(R.string.onboarding_battery_done_title)).assertIsDisplayed()
|
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_take_me)).assertDoesNotExist()
|
||||||
composeTestRule.onNodeWithText(string(R.string.onboarding_battery_not_now)).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()
|
composeTestRule.onNodeWithText(string(R.string.onboarding_battery_continue)).performClick()
|
||||||
assertTrue(finished)
|
assertTrue(finished)
|
||||||
@@ -99,6 +111,9 @@ class BatteryOptimizationScreenJvmTest {
|
|||||||
|
|
||||||
composeTestRule.onNodeWithText(string(R.string.onboarding_battery_title)).assertIsDisplayed()
|
composeTestRule.onNodeWithText(string(R.string.onboarding_battery_title)).assertIsDisplayed()
|
||||||
composeTestRule.onNodeWithText(string(R.string.onboarding_battery_guidance)).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()
|
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.
|
// Take me there marks the prompt handled up front, then launches the resolved settings intent.
|
||||||
|
|||||||
Reference in New Issue
Block a user