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
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,33 @@ 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

- **Deleting a history entry works when System Safety asks for a password or
code.** The delete had no way to carry either, and the auth tier guards every
deletion whether or not Fronts is one of the ticked categories, so the server
refused and you got "Delete failed" with nowhere to type. It now asks first.

- **A deletion held for a grace period stays on the list, marked pending.** It
used to disappear on the spot and come back on the next refresh, because the
app could not tell "deleted" from "queued for deletion in N days". The entry
now stays put, dimmed with its pending badge, and the app says when it will
go and where to cancel it.

- **Emptying the custom status on an entry now clears it.** Saving an empty
status left the old one in place: the app read a blank field as "leave this
alone" rather than "remove it".

- **Editing an entry no longer rewrites its start and end times.** Both were
resent on every save, rounded down to the minute, so changing just a status
rewrote both timestamps. That added edits you did not make to the entry's
history on the web, and it broke "fronting since" for coalesced fronts: a
chain of back-to-back entries is joined by the times matching exactly, so
rounding one side split the chain and restarted that member's timer. Only the
fields you actually changed are sent now.

## [1.6.1] - 2026-10-24

### Fixed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,18 @@ class FrontUpdateJsonAdapter : JsonAdapter<FrontUpdate>() {
writer.endArray()
}
value.startedAt?.let { writer.name("started_at").value(it) }
value.customStatus?.let { writer.name("custom_status").value(it) }
when {
value.clearCustomStatus -> {
// Same forced-null dance as ended_at: the server keys on the
// field being present, so the null has to survive the writer's
// global serializeNulls setting.
val previous = writer.serializeNulls
writer.serializeNulls = true
writer.name("custom_status").nullValue()
writer.serializeNulls = previous
}
value.customStatus != null -> writer.name("custom_status").value(value.customStatus)
}
writer.endObject()
}

Expand All @@ -58,6 +69,7 @@ class FrontUpdateJsonAdapter : JsonAdapter<FrontUpdate>() {
var memberIds: List<String>? = null
var startedAt: String? = null
var customStatus: String? = null
var clearCustomStatus = false
reader.beginObject()
while (reader.hasNext()) {
when (reader.nextName()) {
Expand All @@ -82,8 +94,12 @@ class FrontUpdateJsonAdapter : JsonAdapter<FrontUpdate>() {
if (reader.peek() == JsonReader.Token.NULL) reader.nextNull<Unit>()
else startedAt = reader.nextString()
"custom_status" ->
if (reader.peek() == JsonReader.Token.NULL) reader.nextNull<Unit>()
else customStatus = reader.nextString()
if (reader.peek() == JsonReader.Token.NULL) {
reader.nextNull<Unit>()
clearCustomStatus = true
} else {
customStatus = reader.nextString()
}
else -> reader.skipValue()
}
}
Expand All @@ -94,6 +110,7 @@ class FrontUpdateJsonAdapter : JsonAdapter<FrontUpdate>() {
memberIds = memberIds,
startedAt = startedAt,
customStatus = customStatus,
clearCustomStatus = clearCustomStatus,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -350,8 +350,22 @@ interface SheafApiService {
@PATCH("/v1/fronts/{id}")
suspend fun updateFront(@Path("id") id: String, @Body body: FrontUpdate): FrontRead

@DELETE("/v1/fronts/{id}")
suspend fun deleteFront(@Path("id") id: String)
/**
* Delete a history entry.
*
* Carries a body, because System Safety's auth tier guards every deletion
* whether or not Fronts is a ticked category, and a bare DELETE can answer
* nothing: it came back 400 "Password required" and the screen showed
* "Delete failed" with nowhere to type.
*
* Returns the raw response so the caller can tell 204 (gone) from 202
* (queued for a grace period, still listed). See [deleteFrontOrQueue].
*/
@HTTP(method = "DELETE", path = "/v1/fronts/{id}", hasBody = true)
suspend fun deleteFront(
@Path("id") id: String,
@Body body: MemberDeleteConfirm = MemberDeleteConfirm(),
): Response<FrontDeletePending>

/**
* End one open front and start its replacement in a single transaction,
Expand Down Expand Up @@ -1193,6 +1207,20 @@ suspend fun SheafApiService.deleteTagOrQueue(
return if (resp.code() == 202) resp.body() else null
}

/** Returns null when deletion was immediate (204) or queued payload when safeguarded (202). */
suspend fun SheafApiService.deleteFrontOrQueue(
id: String,
password: String? = null,
totpCode: String? = null,
): FrontDeletePending? {
val resp = deleteFront(
id,
MemberDeleteConfirm(password?.ifBlank { null }, totpCode?.ifBlank { null }),
)
if (!resp.isSuccessful) throw retrofit2.HttpException(resp)
return if (resp.code() == 202) resp.body() else null
}

/** Returns null when deletion was immediate (200) or queued payload when image-safeguarded (202). */
suspend fun SheafApiService.deleteFileOrQueue(
id: String,
Expand Down
20 changes: 18 additions & 2 deletions sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt
Original file line number Diff line number Diff line change
Expand Up @@ -761,9 +761,25 @@ data class FrontUpdate(
val clearEndedAt: Boolean = false,
val memberIds: List<String>? = null,
val startedAt: String? = null,
// null still omits the field (leave as-is). Clearing a custom status via
// edit remains a follow-up to be wired up alongside audit edit.
// null omits the field, which the server reads as "leave as-is".
val customStatus: String? = null,
// When true, emit an explicit `custom_status: null` to CLEAR the status.
// Same shape as clearEndedAt above and for the same reason: null already
// means "untouched", so emptying the field needs a way to say so. Without
// it the edit sheet trimmed an empty status to null, the adapter dropped
// it, and the old status survived a save that looked like it removed one.
val clearCustomStatus: Boolean = false,
)

/**
* The 202 answer to deleting a front, when System Safety is holding it for a
* grace period. The entry is still there and still listed, now carrying
* `pending_delete_at`; this says when it will actually go.
*/
@JsonClass(generateAdapter = true)
data class FrontDeletePending(
@Json(name = "pending_action_id") val pendingActionId: String? = null,
@Json(name = "finalize_after") val finalizeAfter: String? = null,
)

// ── Groups ────────────────────────────────────────────────────────────────────
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,19 @@ fun HistoryScreen(
AlertDialog(
onDismissRequest = { frontToDelete = null },
title = { Text("Delete front entry?") },
text = { Text("This will permanently delete the front entry for $names. This cannot be undone.") },
text = {
val days = state.deleteSafety.gracePeriodDays
Text(
if (state.deleteSafety.appliesToFronts && days > 0) {
"The front entry for $names will be queued for deletion in " +
"$days day${if (days == 1) "" else "s"}. You can cancel it from " +
"System Safety before then."
} else {
"This will permanently delete the front entry for $names. " +
"This cannot be undone."
},
)
},
confirmButton = {
TextButton(
onClick = { viewModel.deleteFront(front.id); frontToDelete = null },
Expand All @@ -140,6 +152,36 @@ fun HistoryScreen(
)
}

// System Safety wants a password or a code before a deletion. Same sheet
// the sharing and custom-field screens use, so re-auth looks the same
// wherever it is asked for.
if (state.stepUpDeleteId != null) {
systems.lupine.sheaf.ui.sharing.StepUpSheet(
authTier = state.deleteSafety.authTier,
totpEnabled = state.deleteSafety.totpEnabled,
isBusy = false,
errorMessage = state.stepUpError,
onConfirm = { password, totp -> viewModel.confirmDeleteStepUp(password, totp) },
onDismiss = { viewModel.dismissDeleteStepUp() },
title = "Confirm before deleting",
rationale = "System Safety is set to ask for your credentials before " +
"anything is deleted.",
)
}

// The server held the deletion rather than carrying it out. The entry is
// still listed, dimmed with a pending badge, so this only has to say why.
state.deleteQueuedMessage?.let { message ->
AlertDialog(
onDismissRequest = { viewModel.clearDeleteQueuedMessage() },
title = { Text("Queued for deletion") },
text = { Text(message) },
confirmButton = {
TextButton(onClick = { viewModel.clearDeleteQueuedMessage() }) { Text("OK") }
},
)
}

if (state.deleteError != null) {
// Nothing cleared deleteError, so the dialog used to re-render forever:
// OK reloaded the list behind it, and back / scrim taps hit a no-op
Expand Down Expand Up @@ -739,10 +781,13 @@ private fun FrontEntrySheet(
mutableStateOf(initialFront?.customStatus ?: "")
}
var startDate by remember { mutableStateOf(initialStart?.toLocalDate() ?: LocalDate.now()) }
var startTime by remember { mutableStateOf(initialStart?.toLocalTime()?.withSecond(0)?.withNano(0) ?: LocalTime.now().withSecond(0).withNano(0)) }
// An existing entry keeps its seconds. The pickers only show hours and
// minutes, so rounding here made every save rewrite the timestamp, which
// breaks contiguous-front coalescing (chains match on exact equality).
var startTime by remember { mutableStateOf(initialStart?.toLocalTime() ?: LocalTime.now().withSecond(0).withNano(0)) }
var stillOngoing by remember { mutableStateOf(if (isEditing) initialFront?.endedAt == null else false) }
var endDate by remember { mutableStateOf(initialEnd?.toLocalDate() ?: LocalDate.now()) }
var endTime by remember { mutableStateOf(initialEnd?.toLocalTime()?.withSecond(0)?.withNano(0) ?: LocalTime.now().withSecond(0).withNano(0)) }
var endTime by remember { mutableStateOf(initialEnd?.toLocalTime() ?: LocalTime.now().withSecond(0).withNano(0)) }

var showStartDatePicker by remember { mutableStateOf(false) }
var showEndDatePicker by remember { mutableStateOf(false) }
Expand Down
Loading
Loading