diff --git a/CHANGELOG.md b/CHANGELOG.md index d3aa709..cc67475 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/SheafApiService.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/SheafApiService.kt index c56eac5..3285802 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/SheafApiService.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/SheafApiService.kt @@ -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 @@ -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, diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/auth/AuthViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/auth/AuthViewModel.kt index 7690616..1a27907 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/auth/AuthViewModel.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/auth/AuthViewModel.kt @@ -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 } @@ -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() } @@ -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 { @@ -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"), + ) + } } } diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/auth/LoginScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/auth/LoginScreen.kt index 245c7d3..cfffc67 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/auth/LoginScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/auth/LoginScreen.kt @@ -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) @@ -521,6 +523,7 @@ private fun TotpStep( private fun EmailVerifyStep( isLoading: Boolean, error: String?, + resent: Boolean, onVerify: (String) -> Unit, onResend: () -> Unit, onCancel: () -> Unit, @@ -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, diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/export/ExportDataScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/export/ExportDataScreen.kt index 9772e67..9ca0662 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/export/ExportDataScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/export/ExportDataScreen.kt @@ -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 @@ -40,7 +41,11 @@ fun ExportDataScreen( ActivityResultContracts.CreateDocument("application/json") ) { uri -> uri?.let { viewModel.exportJsonTo(it) } } - var pendingDownloadJobId by remember { mutableStateOf(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(null) } val zipSaveLauncher = rememberLauncherForActivityResult( ActivityResultContracts.CreateDocument("application/zip") ) { uri -> diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/history/HistoryScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/history/HistoryScreen.kt index 578afc1..3e91356 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/history/HistoryScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/history/HistoryScreen.kt @@ -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") } }, ) } diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/history/HistoryViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/history/HistoryViewModel.kt index 1ee3543..c3b53fd 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/history/HistoryViewModel.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/history/HistoryViewModel.kt @@ -334,6 +334,8 @@ class HistoryViewModel @Inject constructor( } } + fun clearDeleteError() = _state.update { it.copy(deleteError = null) } + fun addFrontEntry( memberIds: List, startedAt: String, diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/relationships/RelationshipGraphViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/relationships/RelationshipGraphViewModel.kt index 8159b3a..85890f8 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/relationships/RelationshipGraphViewModel.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/relationships/RelationshipGraphViewModel.kt @@ -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 @@ -31,6 +33,11 @@ class RelationshipGraphViewModel @Inject constructor( private val _state = MutableStateFlow(RelationshipGraphUiState()) val state: StateFlow = _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) { @@ -38,11 +45,13 @@ class RelationshipGraphViewModel @Inject constructor( } 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")) } } } diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/relationships/RelationshipsEditor.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/relationships/RelationshipsEditor.kt index 023e57c..e97e078 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/relationships/RelationshipsEditor.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/relationships/RelationshipsEditor.kt @@ -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 { diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/relationships/RelationshipsEditorViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/relationships/RelationshipsEditorViewModel.kt index a57f845..3d37b90 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/relationships/RelationshipsEditorViewModel.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/relationships/RelationshipsEditorViewModel.kt @@ -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 { @@ -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, @@ -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) } @@ -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) } diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/widget/FrontingAvatarsOnlyWidgetReceiver.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/widget/FrontingAvatarsOnlyWidgetReceiver.kt index 9a02e7e..51d00d0 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/widget/FrontingAvatarsOnlyWidgetReceiver.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/widget/FrontingAvatarsOnlyWidgetReceiver.kt @@ -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() { @@ -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()) } } diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/widget/FrontingWidgetReceiver.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/widget/FrontingWidgetReceiver.kt index bceb9b8..eac4d99 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/widget/FrontingWidgetReceiver.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/widget/FrontingWidgetReceiver.kt @@ -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() { @@ -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()) } } diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/widget/FrontingWithAvatarsWidgetReceiver.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/widget/FrontingWithAvatarsWidgetReceiver.kt index b8d131c..1fdb07f 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/widget/FrontingWithAvatarsWidgetReceiver.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/widget/FrontingWithAvatarsWidgetReceiver.kt @@ -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() { @@ -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()) } } diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/widget/MemberTrackerWidgetReceiver.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/widget/MemberTrackerWidgetReceiver.kt index 7619462..80badc6 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/widget/MemberTrackerWidgetReceiver.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/widget/MemberTrackerWidgetReceiver.kt @@ -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 MemberTrackerWidgetReceiver : GlanceAppWidgetReceiver() { @@ -20,13 +15,7 @@ class MemberTrackerWidgetReceiver : GlanceAppWidgetReceiver() { appWidgetIds: IntArray, ) { super.onUpdate(context, appWidgetManager, appWidgetIds) - CoroutineScope(Dispatchers.IO).launch { - val glanceManager = GlanceAppWidgetManager(context) - appWidgetIds.forEach { appWidgetId -> - val glanceId = glanceManager.getGlanceIdBy(appWidgetId) - RefreshMemberTrackerAction().onAction(context, glanceId, actionParametersOf()) - } - } + refreshWidgets(context, appWidgetIds, RefreshMemberTrackerAction()) } override fun onDeleted(context: Context, appWidgetIds: IntArray) { diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/widget/QuickSwitchWidgetReceiver.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/widget/QuickSwitchWidgetReceiver.kt index ce67532..e2f93d8 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/widget/QuickSwitchWidgetReceiver.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/widget/QuickSwitchWidgetReceiver.kt @@ -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 QuickSwitchWidgetReceiver : GlanceAppWidgetReceiver() { @@ -20,13 +15,7 @@ class QuickSwitchWidgetReceiver : GlanceAppWidgetReceiver() { appWidgetIds: IntArray, ) { super.onUpdate(context, appWidgetManager, appWidgetIds) - CoroutineScope(Dispatchers.IO).launch { - val glanceManager = GlanceAppWidgetManager(context) - appWidgetIds.forEach { appWidgetId -> - val glanceId = glanceManager.getGlanceIdBy(appWidgetId) - RefreshQuickSwitchAction().onAction(context, glanceId, actionParametersOf()) - } - } + refreshWidgets(context, appWidgetIds, RefreshQuickSwitchAction()) } override fun onDeleted(context: Context, appWidgetIds: IntArray) { diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/widget/RecentFrontsWidgetReceiver.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/widget/RecentFrontsWidgetReceiver.kt index 6035bfa..4c79750 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/widget/RecentFrontsWidgetReceiver.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/widget/RecentFrontsWidgetReceiver.kt @@ -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 RecentFrontsWidgetReceiver : GlanceAppWidgetReceiver() { @@ -20,12 +15,6 @@ class RecentFrontsWidgetReceiver : GlanceAppWidgetReceiver() { appWidgetIds: IntArray, ) { super.onUpdate(context, appWidgetManager, appWidgetIds) - CoroutineScope(Dispatchers.IO).launch { - val glanceManager = GlanceAppWidgetManager(context) - appWidgetIds.forEach { appWidgetId -> - val glanceId = glanceManager.getGlanceIdBy(appWidgetId) - RefreshRecentFrontsAction().onAction(context, glanceId, actionParametersOf()) - } - } + refreshWidgets(context, appWidgetIds, RefreshRecentFrontsAction()) } } diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/widget/WidgetRefreshDispatch.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/widget/WidgetRefreshDispatch.kt new file mode 100644 index 0000000..eda7de2 --- /dev/null +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/widget/WidgetRefreshDispatch.kt @@ -0,0 +1,50 @@ +package systems.lupine.sheaf.widget + +import android.content.BroadcastReceiver +import android.content.Context +import android.util.Log +import androidx.glance.action.actionParametersOf +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.action.ActionCallback +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull + +private const val TAG = "WidgetRefresh" + +// A widget refresh hits the network, so it outlives onUpdate. Without goAsync() +// the receiver is finished the moment onUpdate returns and the process becomes +// killable, so the refresh can be cut off mid-request and the widget just stays +// stale. goAsync() keeps the process alive until finish() is called. +// +// The broadcast still has a hard deadline (10s in the foreground, 60s in the +// background) before the system complains, so the work is bounded well inside +// that: a refresh that cannot finish in time is better dropped than turned into +// an ANR, and the next update tick will try again. +private const val REFRESH_TIMEOUT_MS = 8_000L + +/** Run [action] for each widget id, keeping the broadcast alive until it completes. */ +fun BroadcastReceiver.refreshWidgets( + context: Context, + appWidgetIds: IntArray, + action: ActionCallback, +) { + val pending = goAsync() + CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { + try { + withTimeoutOrNull(REFRESH_TIMEOUT_MS) { + val glanceManager = GlanceAppWidgetManager(context) + appWidgetIds.forEach { appWidgetId -> + val glanceId = glanceManager.getGlanceIdBy(appWidgetId) + action.onAction(context, glanceId, actionParametersOf()) + } + } ?: Log.w(TAG, "refresh timed out for ${appWidgetIds.size} widget(s)") + } catch (t: Throwable) { + Log.w(TAG, "widget refresh failed: ${t::class.simpleName}") + } finally { + pending.finish() + } + } +}