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 @@ -51,6 +51,33 @@ uses semantic versioning (`MAJOR.MINOR.PATCH`).
switch. A failed group-membership load no longer lets Save wipe the group's
members.

- **"Resend Email" works after registering.** The resend request went out
without your credentials and failed, and the failure was hidden, so it looked
like the email had been sent again. It now sends, confirms when it does, and
tells you if it doesn't.

- **A failed history delete no longer traps you.** The "Delete failed" dialog
could not be dismissed (OK reloaded the list behind it and back did nothing);
it now closes.

- **Backup downloads survive a rotation.** Choosing where to save a full backup
no longer silently drops the download if the screen was recreated while the
file picker was open.

- **Relationships: a failed load can be retried.** One failed load (offline, or
a server hiccup) used to leave a member or group's relationships permanently
stuck until you left the screen. There is now a Retry, and a save whose
refresh fails says so instead of quietly showing a stale list.

- **The relationship graph no longer shows members under the Groups tab** when
you switch tabs faster than the first request finishes.

- **Widgets finish refreshing.** A widget refresh could be killed part-way
through because the app did not tell Android it was still working, leaving the
widget stale.

- **Exporting a large system no longer risks running out of memory.** The JSON
export is now streamed to the file instead of being held in memory first.
- **Retrying an import no longer risks importing twice.** If an import was
started but the app lost the connection while waiting for it to finish,
pressing Import again now resumes the same job instead of starting a second
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,13 @@ interface SheafApiService {
* Synchronous JSON export. [format] is "sheaf" (native, full-fidelity
* re-import) or "openplural" (v0.1 interchange, uri-only assets). No
* step-up; this is metadata only, no image bytes.
*
* @Streaming so a large system's export goes socket -> file. Without it
* Retrofit buffers the entire body into a byte array before the caller sees
* it, so the caller's byteStream().copyTo() was copying from memory and a
* big enough export could exhaust the heap.
*/
@Streaming
@GET("/v1/export")
suspend fun exportAll(@Query("format") format: String = "sheaf"): okhttp3.ResponseBody

Expand Down Expand Up @@ -747,6 +753,7 @@ interface SheafApiService {
): AdminImportJobDetail

// GDPR Article 15 metadata export; returns a downloadable JSON document.
@Streaming
@POST("/v1/admin/users/{id}/dossier")
suspend fun exportUserDossier(
@Path("id") id: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,13 @@ sealed interface AuthUiState {
data object SolvingCaptcha : AuthUiState
// Login succeeded but TOTP code is required to complete auth
data class AwaitingTotp(val error: String? = null) : AuthUiState
// Registration succeeded but email must be verified before proceeding
data object AwaitingEmailVerification : AuthUiState
// Registration succeeded but email must be verified before proceeding.
// Carries its own error/notice so a failed resend can be reported without
// flipping to Error, which would drop the user back to the login form.
data class AwaitingEmailVerification(
val error: String? = null,
val resent: Boolean = false,
) : AuthUiState
data class Error(val message: String) : AuthUiState
}

Expand Down Expand Up @@ -209,7 +214,7 @@ class AuthViewModel @Inject constructor(
pendingRefreshToken = tokens.refreshToken
when {
user?.emailVerified == false && config?.emailVerification != "none" ->
_uiState.value = AuthUiState.AwaitingEmailVerification
_uiState.value = AuthUiState.AwaitingEmailVerification()
else ->
finishAuth()
}
Expand All @@ -232,10 +237,14 @@ class AuthViewModel @Inject constructor(
.onSuccess { tokens ->
_pendingOnboarding.value = true
if (config?.emailVerification != "none") {
// Hold tokens in memory — don't persist so isLoggedIn stays false
// Hold tokens in memory, don't persist, so isLoggedIn stays false.
// pendingToken still has to be set: resend-verification is an
// authenticated endpoint, and without this the freshly registered
// user's "Resend Email" went out with no bearer and 401'd.
authInterceptor.pendingToken = tokens.accessToken
pendingAccessToken = tokens.accessToken
pendingRefreshToken = tokens.refreshToken
_uiState.value = AuthUiState.AwaitingEmailVerification
_uiState.value = AuthUiState.AwaitingEmailVerification()
} else {
prefs.saveTokens(tokens.accessToken, tokens.refreshToken)
runCatching {
Expand Down Expand Up @@ -282,9 +291,15 @@ class AuthViewModel @Inject constructor(

fun resendVerificationEmail() {
viewModelScope.launch {
// pendingToken already set — AuthInterceptor will attach it without prefs write
// pendingToken is set by both the login and the register path, so
// AuthInterceptor attaches it without a prefs write.
runCatching { api.resendVerification() }
_uiState.value = AuthUiState.AwaitingEmailVerification
.onSuccess { _uiState.value = AuthUiState.AwaitingEmailVerification(resent = true) }
.onFailure { e ->
_uiState.value = AuthUiState.AwaitingEmailVerification(
error = e.toUserMessage("Couldn't resend the verification email"),
)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,9 @@ fun LoginScreen(
)
"email-verify" -> EmailVerifyStep(
isLoading = isLoading,
error = (uiState as? AuthUiState.Error)?.message,
error = (uiState as? AuthUiState.Error)?.message
?: (uiState as? AuthUiState.AwaitingEmailVerification)?.error,
resent = (uiState as? AuthUiState.AwaitingEmailVerification)?.resent == true,
onVerify = { token ->
focusManager.clearFocus()
viewModel.verifyEmail(token)
Expand Down Expand Up @@ -521,6 +523,7 @@ private fun TotpStep(
private fun EmailVerifyStep(
isLoading: Boolean,
error: String?,
resent: Boolean,
onVerify: (String) -> Unit,
onResend: () -> Unit,
onCancel: () -> Unit,
Expand Down Expand Up @@ -550,6 +553,12 @@ private fun EmailVerifyStep(
)

if (error != null) ErrorBanner(error)
else if (resent) Text(
"Verification email sent.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center,
)

OutlinedTextField(
value = token,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.outlined.CloudDownload
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
Expand Down Expand Up @@ -40,7 +41,11 @@ fun ExportDataScreen(
ActivityResultContracts.CreateDocument("application/json")
) { uri -> uri?.let { viewModel.exportJsonTo(it) } }

var pendingDownloadJobId by remember { mutableStateOf<String?>(null) }
// Saveable: the document picker is a separate activity, so this one can be
// recreated (rotation, low memory) while it is up. With a plain remember the
// job id came back null, the result was dropped, and the user got nothing
// after choosing where to save.
var pendingDownloadJobId by rememberSaveable { mutableStateOf<String?>(null) }
val zipSaveLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("application/zip")
) { uri ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,12 +138,18 @@ fun HistoryScreen(
}

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
// onDismissRequest. Every exit now clears the error.
AlertDialog(
onDismissRequest = { },
onDismissRequest = { viewModel.clearDeleteError() },
title = { Text("Delete failed") },
text = { Text(state.deleteError!!) },
confirmButton = {
TextButton(onClick = { viewModel.loadInitial() }) { Text("OK") }
TextButton(onClick = {
viewModel.clearDeleteError()
viewModel.loadInitial()
}) { Text("OK") }
},
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,8 @@ class HistoryViewModel @Inject constructor(
}
}

fun clearDeleteError() = _state.update { it.copy(deleteError = null) }

fun addFrontEntry(
memberIds: List<String>,
startedAt: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package systems.lupine.sheaf.ui.relationships
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
Expand Down Expand Up @@ -31,18 +33,25 @@ class RelationshipGraphViewModel @Inject constructor(
private val _state = MutableStateFlow(RelationshipGraphUiState())
val state: StateFlow<RelationshipGraphUiState> = _state.asStateFlow()

// Only the newest request may write to state. Flipping Members -> Groups fires a
// second load, and if the slower members response landed last it would paint the
// member graph under the Groups tab.
private var inFlight: Job? = null

init { load(GRAPH_SCOPE_MEMBERS) }

fun setScope(scope: String) {
if (scope != _state.value.scope) load(scope)
}

fun load(scope: String = _state.value.scope) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, scope = scope, error = null) }
inFlight?.cancel()
inFlight = viewModelScope.launch {
_state.update { it.copy(isLoading = true, scope = scope, graph = null, error = null) }
runCatching { api.getRelationshipGraph(scope) }
.onSuccess { graph -> _state.update { it.copy(isLoading = false, graph = graph) } }
.onFailure { e ->
if (e is CancellationException) throw e
_state.update { it.copy(isLoading = false, error = e.toUserMessage("Couldn't load the graph")) }
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ fun RelationshipsEditor(

state.error?.let {
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
// A failed load is no longer latched, so this really does re-run it.
if (!state.isLoading && state.relationships.isEmpty() && state.types.isEmpty()) {
TextButton(onClick = { viewModel.retry() }) { Text("Retry") }
}
}

when {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@ class RelationshipsEditorViewModel @Inject constructor(
if (loadedFor == scope to nodeId) return
this.scope = scope
this.nodeId = nodeId
loadedFor = scope to nodeId
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
val relationships = runCatching {
Expand All @@ -83,6 +82,10 @@ class RelationshipsEditorViewModel @Inject constructor(

relationships
.onSuccess { rels ->
// Only latch on success. Latching before the request meant one
// failed load (offline, 5xx) suppressed every later attempt for
// this node, with no way back short of leaving the screen.
loadedFor = scope to nodeId
_state.update {
it.copy(
isLoading = false,
Expand All @@ -99,6 +102,9 @@ class RelationshipsEditorViewModel @Inject constructor(
}
}

/** Re-run a load that failed (the failed attempt is not latched, so this retries). */
fun retry() = load(scope, nodeId)

fun add(edge: RelationshipEdgeCreate) {
viewModelScope.launch {
_state.update { it.copy(isSaving = true, error = null) }
Expand Down Expand Up @@ -127,14 +133,20 @@ class RelationshipsEditorViewModel @Inject constructor(
}
}

// Re-fetch just this node's edges (labels/direction are server-resolved).
// Re-fetch just this node's edges (labels/direction are server-resolved). The
// mutation itself already succeeded, so a failure here means the list on screen
// is stale rather than wrong: say so instead of silently showing the old list.
private suspend fun reloadRelationships() {
runCatching {
if (scope == REL_SCOPE_GROUP) api.getGroupRelationships(nodeId)
else api.getMemberRelationships(nodeId)
}
.onSuccess { rels -> _state.update { it.copy(isSaving = false, relationships = rels) } }
.onFailure { _state.update { it.copy(isSaving = false) } }
.onFailure { e ->
_state.update {
it.copy(isSaving = false, error = e.toUserMessage("Saved, but couldn't refresh the list"))
}
}
}

fun clearError() = _state.update { it.copy(error = null) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,8 @@ package systems.lupine.sheaf.widget

import android.appwidget.AppWidgetManager
import android.content.Context
import androidx.glance.action.actionParametersOf
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

class FrontingAvatarsOnlyWidgetReceiver : GlanceAppWidgetReceiver() {

Expand All @@ -20,12 +15,6 @@ class FrontingAvatarsOnlyWidgetReceiver : GlanceAppWidgetReceiver() {
appWidgetIds: IntArray,
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
CoroutineScope(Dispatchers.IO).launch {
val glanceManager = GlanceAppWidgetManager(context)
appWidgetIds.forEach { appWidgetId ->
val glanceId = glanceManager.getGlanceIdBy(appWidgetId)
RefreshAvatarsOnlyAction().onAction(context, glanceId, actionParametersOf())
}
}
refreshWidgets(context, appWidgetIds, RefreshAvatarsOnlyAction())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,8 @@ package systems.lupine.sheaf.widget

import android.appwidget.AppWidgetManager
import android.content.Context
import androidx.glance.action.actionParametersOf
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

class FrontingWidgetReceiver : GlanceAppWidgetReceiver() {

Expand All @@ -20,12 +15,6 @@ class FrontingWidgetReceiver : GlanceAppWidgetReceiver() {
appWidgetIds: IntArray,
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
CoroutineScope(Dispatchers.IO).launch {
val glanceManager = GlanceAppWidgetManager(context)
appWidgetIds.forEach { appWidgetId ->
val glanceId = glanceManager.getGlanceIdBy(appWidgetId)
RefreshAction().onAction(context, glanceId, actionParametersOf())
}
}
refreshWidgets(context, appWidgetIds, RefreshAction())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,8 @@ package systems.lupine.sheaf.widget

import android.appwidget.AppWidgetManager
import android.content.Context
import androidx.glance.action.actionParametersOf
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

class FrontingWithAvatarsWidgetReceiver : GlanceAppWidgetReceiver() {

Expand All @@ -20,12 +15,6 @@ class FrontingWithAvatarsWidgetReceiver : GlanceAppWidgetReceiver() {
appWidgetIds: IntArray,
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
CoroutineScope(Dispatchers.IO).launch {
val glanceManager = GlanceAppWidgetManager(context)
appWidgetIds.forEach { appWidgetId ->
val glanceId = glanceManager.getGlanceIdBy(appWidgetId)
RefreshWithAvatarsAction().onAction(context, glanceId, actionParametersOf())
}
}
refreshWidgets(context, appWidgetIds, RefreshWithAvatarsAction())
}
}
Loading
Loading