fix(android): Requests cancel → MutationQueue offline fallback

Audit v2 silent breakage / standing rule violation
(feedback_offline_first_for_server_writes): RequestsRepository.cancel
was direct REST with no queue fallback. Tap cancel offline and the
user's intent vanishes.

Now mirrors the unflag / appendTrack / requestCreate pattern: REST on
the happy path, MutationKind.REQUEST_CANCEL enqueued on IOException.
Replayer's existing AuthStore.sessionCookie trigger drains it on the
next signed-in transition.

Repository signature changed from `suspend fun cancel(id): RequestRef`
to `Pair<CancelOutcome, RequestRef?>` so callers can distinguish
synced vs queued (RequestsViewModel ignores the distinction for now;
optimistic removal already reflects the user's intent and the
post-replay refresh surfaces the canonical row).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 19:49:30 -04:00
parent 118c687847
commit 29c676fcf8
4 changed files with 82 additions and 21 deletions
@@ -20,6 +20,7 @@ object MutationKind {
const val PLAYLIST_APPEND: String = "playlist_append"
const val QUARANTINE_FLAG: String = "quarantine_flag"
const val PLAY_OFFLINE: String = "play_offline"
const val REQUEST_CANCEL: String = "request_cancel"
}
/**
@@ -133,6 +134,16 @@ class MutationQueue @Inject constructor(
payload = json.encodeToString(PlayOfflinePayload.serializer(), payload),
),
)
suspend fun enqueueRequestCancel(requestId: String): Long = dao.insert(
CachedMutationEntity(
kind = MutationKind.REQUEST_CANCEL,
payload = json.encodeToString(
RequestCancelPayload.serializer(),
RequestCancelPayload(requestId),
),
),
)
}
/**
@@ -183,3 +194,12 @@ data class PlayOfflinePayload(
val durationPlayedMs: Long,
val source: String? = null,
)
/**
* Persisted payload for `MutationKind.REQUEST_CANCEL` — the
* `POST /api/requests/{id}/cancel` call lost during a connectivity
* hiccup. Server is idempotent on re-cancel (already-cancelled
* request stays cancelled).
*/
@Serializable
data class RequestCancelPayload(val requestId: String)
@@ -7,6 +7,7 @@ import com.fabledsword.minstrel.api.endpoints.FlagRequest
import com.fabledsword.minstrel.api.endpoints.LikesApi
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi
import com.fabledsword.minstrel.api.endpoints.QuarantineApi
import com.fabledsword.minstrel.api.endpoints.RequestsApi
import com.fabledsword.minstrel.models.wire.PlayOfflineRequest
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
@@ -64,6 +65,7 @@ class MutationReplayer @Inject constructor(
private val quarantineApi: QuarantineApi = retrofit.create()
private val playlistsApi: PlaylistsApi = retrofit.create()
private val eventsApi: EventsApi = retrofit.create()
private val requestsApi: RequestsApi = retrofit.create()
private val mutex = Mutex()
@@ -113,6 +115,7 @@ class MutationReplayer @Inject constructor(
MutationKind.PLAYLIST_APPEND -> dispatchPlaylistAppend(row.payload)
MutationKind.QUARANTINE_FLAG -> dispatchQuarantineFlag(row.payload)
MutationKind.PLAY_OFFLINE -> dispatchPlayOffline(row.payload)
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
else -> {
// Unknown kind — drop the row by claiming success so a
// stale schema entry can't wedge the queue forever.
@@ -242,4 +245,17 @@ class MutationReplayer @Inject constructor(
false
}
}
private suspend fun dispatchRequestCancel(payload: String): Boolean {
val decoded = json.decodeFromString(RequestCancelPayload.serializer(), payload)
return try {
requestsApi.cancel(decoded.requestId)
true
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
// Intentional swallow: row stays queued; next drain pass retries.
false
}
}
}
@@ -1,25 +1,33 @@
package com.fabledsword.minstrel.requests.data
import com.fabledsword.minstrel.api.endpoints.RequestsApi
import com.fabledsword.minstrel.cache.mutations.MutationQueue
import com.fabledsword.minstrel.models.RequestRef
import com.fabledsword.minstrel.models.RequestStatus
import com.fabledsword.minstrel.models.wire.RequestWire
import retrofit2.Retrofit
import retrofit2.create
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
/**
* Outcome of a [RequestsRepository.cancel] call. Mirrors the
* [com.fabledsword.minstrel.likes.data.UnflagOutcome] / etc. shape.
*/
enum class CancelOutcome { Synced, Queued }
/**
* Read-through accessor for the caller's own Lidarr requests +
* cancel-while-pending write. No Room caching for v1 — same rationale
* as HistoryRepository; the offline-snapshot mirror lands with
* Phase 13. Cancel + the create path (DiscoverRepository) both fire
* direct REST today; the create path enqueues on failure via the
* MutationQueue, cancel does not (it's a no-op if the request is
* already gone).
* as HistoryRepository; the offline-snapshot mirror lands with a
* later slice. Cancel is offline-first: direct REST on the happy
* path, MutationQueue fallback on transport failure so the user's
* intent persists across connectivity loss.
*/
@Singleton
class RequestsRepository @Inject constructor(
private val mutationQueue: MutationQueue,
retrofit: Retrofit,
) {
private val api: RequestsApi = retrofit.create()
@@ -28,11 +36,21 @@ class RequestsRepository @Inject constructor(
api.listMine().map { it.toDomain() }
/**
* Cancels a pending request. Server returns the cancelled row
* (now status=rejected with cancel notes) so callers can refresh
* local state from the return value without a list refetch.
* Cancels a pending request. On 2xx, returns the canonical
* cancelled row from the server (status=rejected). On transport
* failure, enqueues a REQUEST_CANCEL mutation for later replay
* and returns null — the caller's optimistic UI removal already
* reflects the user's intent, and the next listMine() refetch
* (post-replay) will surface the canonical row.
*/
suspend fun cancel(id: String): RequestRef = api.cancel(id).toDomain()
suspend fun cancel(id: String): Pair<CancelOutcome, RequestRef?> = try {
CancelOutcome.Synced to api.cancel(id).toDomain()
} catch (
@Suppress("SwallowedException") _: IOException,
) {
mutationQueue.enqueueRequestCancel(id)
CancelOutcome.Queued to null
}
}
// ── Mappers (internal — wire stays out of UI) ──
@@ -76,20 +76,27 @@ class RequestsViewModel @Inject constructor(
}
viewModelScope.launch {
try {
val updated = repository.cancel(id)
internal.update { state ->
if (state !is RequestsUiState.Success) {
// Mid-refresh — let the refresh result win, but
// splice the cancelled row in so it shows the new
// status if the user re-pulls.
RequestsUiState.Success(listOf(updated))
} else {
// Re-insert the cancelled row in its old position
// would require remembering the prior ordering;
// simpler to refetch in the background.
state
val (_, updated) = repository.cancel(id)
if (updated != null) {
internal.update { state ->
if (state !is RequestsUiState.Success) {
// Mid-refresh — let the refresh result win, but
// splice the cancelled row in so it shows the new
// status if the user re-pulls.
RequestsUiState.Success(listOf(updated))
} else {
// Re-insert the cancelled row in its old position
// would require remembering the prior ordering;
// simpler to refetch in the background.
state
}
}
}
// Refresh fetches the canonical list whether the cancel
// went through live (Synced) or got enqueued (Queued).
// The Queued case shows the optimistic removal until the
// replayer drains; the next list fetch surfaces the
// server's canonical view.
refresh()
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,