Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ All notable changes to the Sheaf Android client are recorded here. Format loosel
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); the project
uses semantic versioning (`MAJOR.MINOR.PATCH`).

## [Unreleased]

### Fixed

- **Recovery codes work at login.** The two-factor login prompt now has a
"Use a recovery code instead" option, so you can sign in with a saved
recovery code instead of a 6-digit authenticator code. This is the path
that matters when your authenticator is lost or on a replaced phone;
before, the field only accepted 6 digits and locked those users out.

## [1.2.1] - 2026-07-04

### Added
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ The release contains:
- `wear-release.apk` (watch, sideload via ADB)
- `*.sig` and `*.pem` files: cosign keyless OIDC signatures, see [Verifying](#verifying-a-build).

Stable releases (`v*` tags) and store-distributed builds (Play Store, F-Droid) are not yet available; see [`docs/PUBLISHING.md`](docs/PUBLISHING.md) for the readiness plan.
Stable releases (`v*` tags) are published on [GitHub Releases](https://github.com/sheaf-project/android/releases). Play Store distribution is currently in beta (request access through the Discord); F-Droid is not yet available. See [`docs/PUBLISHING.md`](docs/PUBLISHING.md) for the distribution plan.

### Verifying a build

Expand Down
61 changes: 50 additions & 11 deletions sheaf/app/src/main/java/systems/lupine/sheaf/ui/auth/LoginScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -394,9 +394,30 @@ private fun TotpStep(
) {
var code by remember { mutableStateOf("") }
var rememberDevice by remember { mutableStateOf(false) }
// A lost/wiped authenticator is exactly when recovery codes matter, and that
// correlates with "changed phone", so the mobile client has to offer them too.
var useRecoveryCode by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }

LaunchedEffect(Unit) { focusRequester.requestFocus() }
// Refocus the field when switching modes so the user can just keep typing.
LaunchedEffect(useRecoveryCode) { focusRequester.requestFocus() }

// Recovery codes are 16 hex chars issued as two hyphenated groups
// (xxxxxxxx-xxxxxxxx). The hyphen is part of the stored value, so we accept
// it bare or hyphenated and re-insert the hyphen (lowercased) on submit.
val recoveryHex = code.filter { it != '-' }
val canSubmit = if (useRecoveryCode) recoveryHex.length == 16 else code.length == 6

fun submit() {
if (!canSubmit) return
val payload = if (useRecoveryCode) {
val hex = recoveryHex.lowercase()
"${hex.substring(0, 8)}-${hex.substring(8)}"
} else {
code
}
onSubmit(payload, rememberDevice)
}

Column(verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally) {
// Lock icon
Expand All @@ -416,7 +437,11 @@ private fun TotpStep(
color = MaterialTheme.colorScheme.onBackground,
)
Text(
"Enter the 6-digit code from your authenticator app.",
if (useRecoveryCode) {
"Enter one of the recovery codes you saved when you set up two-factor authentication."
} else {
"Enter the 6-digit code from your authenticator app."
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
Expand All @@ -427,23 +452,37 @@ private fun TotpStep(
OutlinedTextField(
value = code,
onValueChange = { new ->
// Only allow digits, max 6. No auto-submit so the user has a
// chance to tick "Remember this device" before tapping Verify.
code = new.filter { it.isDigit() }.take(6)
// No auto-submit so the user has a chance to tick "Remember this
// device" before tapping Verify.
code = if (useRecoveryCode) {
// Hex plus an optional hyphen; xxxxxxxx-xxxxxxxx is 17 chars.
new.filter { it.isDigit() || it in 'a'..'f' || it in 'A'..'F' || it == '-' }.take(17)
} else {
new.filter { it.isDigit() }.take(6)
}
},
label = { Text("Authenticator code") },
placeholder = { Text("000000") },
label = { Text(if (useRecoveryCode) "Recovery code" else "Authenticator code") },
placeholder = { Text(if (useRecoveryCode) "xxxxxxxx-xxxxxxxx" else "000000") },
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.NumberPassword,
keyboardType = if (useRecoveryCode) KeyboardType.Password else KeyboardType.NumberPassword,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = { if (code.length == 6) onSubmit(code, rememberDevice) }),
keyboardActions = KeyboardActions(onDone = { submit() }),
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
)

TextButton(
onClick = {
useRecoveryCode = !useRecoveryCode
code = ""
},
) {
Text(if (useRecoveryCode) "Use an authenticator code instead" else "Use a recovery code instead")
}

Row(
modifier = Modifier
.fillMaxWidth()
Expand All @@ -462,8 +501,8 @@ private fun TotpStep(
}

Button(
onClick = { onSubmit(code, rememberDevice) },
enabled = !isLoading && code.length == 6,
onClick = { submit() },
enabled = !isLoading && canSubmit,
modifier = Modifier.fillMaxWidth().height(48.dp),
) {
if (isLoading) CircularProgressIndicator(Modifier.size(20.dp), color = MaterialTheme.colorScheme.onPrimary, strokeWidth = 2.dp)
Expand Down
Loading