aws-amplify / aws-amplify/amplify-ui-android
[Liveness] Instruction chip, REC badge and cancel button all read background/onBackground: dark-theme contrast defect and no independent styling
- Dominant language
- Kotlin
- Stars
- 25
- Forks
- 18
- Avg merge
- 7d 3h
- Merged PRs (30d)
- 1
Description
### Before creating a new issue, please confirm:
- [x] I have searched for duplicate or closed issues.
- [x] I have read the guide for submitting bug reports.
### Which UI component?
Liveness
### Gradle script dependencies
```kotlin
implementation("com.amplifyframework.ui:liveness:1.11.0")
```
### Environment information
```
Gradle 8.13
Reproduced against liveness 1.11.0 (repo VERSION_NAME), Compose BOM 2026.03.00
```
### Describe the bug
Six distinct UI elements in `FaceLivenessDetector` are routed through three shared `MaterialTheme.colorScheme` roles. `background` and `onBackground` in particular are applied to elements that are not app backdrops, which causes two problems: a visible defect in the shipped dark theme, and an inability to style any of those elements independently.
**1. In dark theme the instruction chip blends into the backdrop.**
The backdrop is hardcoded when no oval is displayed:
```kotlin
// FaceLivenessDetector.kt:237-243
val backgroundColor = if (livenessState.showingStartView) {
MaterialTheme.colorScheme.background
} else if (livenessState.faceGuideRect != null) {
Color.White
} else {
Color.Black
}
```
The non-actionable instruction chip — the "Connecting" and "Verifying" states — takes its container from the theme:
```kotlin
// InstructionMessage.kt:63-68
.background(
color = MaterialTheme.colorScheme.background,
shape = MaterialTheme.shapes.small
)
```
Both of those states occur while `faceGuideRect` is null, so the backdrop is `Color.Black`. With `LivenessColorScheme.default()` in dark mode, `background` is `Color(0xFF0D1926)`. The chip therefore renders a near-black container on a pure black backdrop and has no visible boundary. The label itself stays legible (`onBackground` is white), so this is a loss of the chip's definition rather than unreadable text — but it happens with the SDK's own defaults and no customisation.
**2. No element that uses these roles can be styled independently.**
- `onBackground` is the instruction chip's text in the idle/"Center your face", "Connecting" and "Verifying" states (`InstructionMessage.kt:80`, `:107`), **and** the `RecordingIndicator` "REC" label (`RecordingIndicator.kt:80`), **and** the `CancelChallengeButton` icon tint (`CancelChallengeButton.kt:65`).
- `background` is the chip container (`InstructionMessage.kt:65`, `:99`), the REC chip (`RecordingIndicator.kt:67`), the cancel button (`CancelChallengeButton.kt:59`), the detector's root `Surface` (`FaceLivenessDetector.kt:151`), the start-view backdrop (`:239`) and the start-view `FaceGuide` scrim (`:289`). A different chip container therefore repaints the whole screen, so a dark chip on a light screen is not expressible at all.
- `primary` is the active-instruction chip container (`InstructionMessage.kt:101`), the chip's spinner (`:72`), the camera-loading spinner (`FaceLivenessDetector.kt:275`), the progress bar fill (`:424`), and — via `ButtonDefaults` — the start-view button container (`:315`).
- `onPrimary` is the active-instruction chip text and, via `ButtonDefaults`, that same button's label. It is only effectively dedicated when `disableStartView = true`.
Only `error`/`onError` (the too-close chip) and `surface` (the progress bar track) are each read in exactly one place.
Availability was not the constraint: `inverseSurface`, `inverseOnSurface`, `surfaceVariant`, `onSurfaceVariant`, `secondary` and their containers were all present in Material3 1.0.1, the version liveness originally shipped against. `LivenessColorScheme` also already sets ten roles, including `onSurface`, which is never read anywhere in the component.
### Reproduction steps (if applicable)
1. Run the liveness sample with the device in **dark mode**, using the default `MaterialTheme(colorScheme = LivenessColorScheme.default())`.
2. Start a check and observe the "Connecting" chip, then complete the challenge and observe the "Verifying" chip.
3. Both render a `#0D1926` container against a `Color.Black` backdrop, so the chip has no visible edge.
Identified by code inspection; a visual confirmation of step 3 would be worth recording on the issue.
### Code Snippet
```kotlin
// Setting a chip container colour is not possible without also
// repainting the root Surface, the REC badge and the cancel button,
// because all four read colorScheme.background.
MaterialTheme(
colorScheme = LivenessColorScheme.default().copy(
background = Color(0xFF303030)
)
) {
FaceLivenessDetector(/* ... */)
}
```
### Log output
```
N/A - visual defect
```
### Additional information and screenshots
**Suggested approach.** Reassign the offending usages to semantically appropriate roles rather than adding a new theming API. `inverseSurface`/`inverseOnSurface` are Material's pairing for an overlay drawn over content, which is what the instruction chip is, and using paired roles preserves the contrast guarantee that independent per-element colours would lose.
| Element | Today | Proposed |
|---|---|---|
| Instruction chip (default / Connecting / Verifying) | `background` / `onBackground` | `inverseSurface` / `inverseOnSurface` |
| REC badge | `background` / `onBackground` | `surfaceVariant` / `onSurfaceVariant` |
| Cancel button | `background` / `onBackground` | `secondaryContainer` / `onSecondaryContainer` |
| Progress bar fill | `primary` | `secondary` |
| Instruction chip (active) | `primary` / `onPrimary` | unchanged |
| Instruction chip (too close) | `error` / `onError` | unchanged |
| Progress bar track | `surface` | unchanged |
| Root `Surface`, start-view backdrop | `background` | unchanged — correct usage |
**Compatibility.** No API changes, so this is source- and binary-compatible; only rendered output changes, and only for consumers who supply their own `ColorScheme` without setting the newly-read roles. Updating `LivenessColorScheme.default()` to set the new roles to today's values leaves the documented path unaffected. `ColorScheme` has no notion of an unset value, so there is no way to fall back to `background` automatically for consumers who have not migrated — a release note plus a revert recipe (copy `LivenessColorScheme.default()` and set `inverseSurface = background`, `inverseOnSurface = onBackground`) is the practical mitigation. Compose Material3 ships equivalent role reassignments as bug fixes with a visual-change note, for example the chip and `OutlinedButton` colour corrections in 1.4.0 and the `SurfaceContainer` migration in 1.3.0.
**Related.** The styling branch for the actionable chip is selected by comparing rendered strings rather than by state (`InstructionMessage.kt:88-92`), so two states resolving to the same string cannot be styled differently and changing a string resource silently changes appearance. Keying on `LivenessCheckState`/`FaceOvalPosition` would remove that coupling and is worth doing alongside any of the above.
The hardcoded `Color.White` backdrop and `FaceGuide` default (`FaceGuide.kt:44`) used once the oval is displayed are likely deliberate, since a bright screen illuminates the subject, and are not proposed for change here.
Contributor guide
Research direction
Start by reading the color usages in FaceLivenessDetector.kt, InstructionMessage.kt, RecordingIndicator.kt, and CancelChallengeButton.kt, then reproduce the Connecting and Verifying states in the dark-mode liveness sample. Compare each element with the proposed Material roles and verify that the default theme retains its appearance while custom colors can style the chip, REC badge, cancel button, and progress bar independently.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- kotlin
- Domain
- mobile
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 64/100