Skip to content

Commit 8596c0d

Browse files
feat: improve error messages, add orphaned file cleanup, and sync theme to server
- Add ErrorMessages.kt with Throwable.toUserMessage() mapping HTTP status codes, Cloudflare errors (403/520-526 + body detection), and network exceptions to plain-English messages; replace all e.message leaks across every ViewModel - Add "Clean Up Orphaned Files" to Settings — checks uploaded files against member/system avatar URLs, auto-opens a confirmation dialog when orphans are found, then deletes them - Sync theme preference to the server via PUT /v1/settings/client/android on change and pull it back on load, enabling cross-device theme sync
1 parent 3075e19 commit 8596c0d

15 files changed

Lines changed: 280 additions & 80 deletions

File tree

‎sheaf/app/src/main/java/systems/lupine/sheaf/ui/admin/AdminPanelViewModel.kt‎

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
2020
import kotlinx.coroutines.flow.*
2121
import kotlinx.coroutines.launch
2222
import retrofit2.HttpException
23+
import systems.lupine.sheaf.util.toUserMessage
2324
import javax.inject.Inject
2425

2526
data class AdminPanelUiState(
@@ -62,7 +63,7 @@ class AdminPanelViewModel @Inject constructor(
6263
if (status.verified) loadAll()
6364
}
6465
.onFailure { e ->
65-
_state.update { it.copy(isLoading = false, error = e.message ?: "Failed to check admin status") }
66+
_state.update { it.copy(isLoading = false, error = e.toUserMessage("Failed to check admin status")) }
6667
}
6768
}
6869
}
@@ -82,7 +83,7 @@ class AdminPanelViewModel @Inject constructor(
8283
}
8384
.onFailure { e ->
8485
val msg = if (e is HttpException && e.code() == 401) "Invalid credentials"
85-
else e.message ?: "Step-up authentication failed"
86+
else e.toUserMessage("Step-up authentication failed")
8687
_state.update { it.copy(isSteppingUp = false, stepUpError = msg) }
8788
}
8889
}
@@ -119,7 +120,7 @@ class AdminPanelViewModel @Inject constructor(
119120
}
120121
}
121122
.onFailure { e ->
122-
_state.update { it.copy(error = e.message ?: "Failed to load admin data") }
123+
_state.update { it.copy(error = e.toUserMessage("Failed to load admin data")) }
123124
}
124125
}
125126
}
@@ -134,7 +135,7 @@ class AdminPanelViewModel @Inject constructor(
134135
_state.update { it.copy(isCreatingInvite = false, invites = it.invites + invite) }
135136
}
136137
.onFailure { e ->
137-
_state.update { it.copy(isCreatingInvite = false, createInviteError = e.message ?: "Failed to create invite") }
138+
_state.update { it.copy(isCreatingInvite = false, createInviteError = e.toUserMessage("Failed to create invite")) }
138139
}
139140
}
140141
}
@@ -143,7 +144,7 @@ class AdminPanelViewModel @Inject constructor(
143144
viewModelScope.launch {
144145
runCatching { api.deleteInvite(id) }
145146
.onSuccess { _state.update { it.copy(invites = it.invites.filter { i -> i.id != id }) } }
146-
.onFailure { e -> _state.update { it.copy(error = e.message ?: "Failed to delete invite") } }
147+
.onFailure { e -> _state.update { it.copy(error = e.toUserMessage("Failed to delete invite")) } }
147148
}
148149
}
149150

@@ -163,47 +164,47 @@ class AdminPanelViewModel @Inject constructor(
163164
s.copy(users = s.users.map { if (it.id == id) updated else it })
164165
}
165166
}
166-
.onFailure { e -> _state.update { it.copy(error = e.message ?: "Failed to update user") } }
167+
.onFailure { e -> _state.update { it.copy(error = e.toUserMessage("Failed to update user")) } }
167168
}
168169
}
169170

170171
fun approveUser(id: String) {
171172
viewModelScope.launch {
172173
runCatching { api.approveUser(id) }
173174
.onSuccess { _state.update { it.copy(approvals = it.approvals.filter { u -> u.id != id }) } }
174-
.onFailure { e -> _state.update { it.copy(error = e.message ?: "Failed to approve user") } }
175+
.onFailure { e -> _state.update { it.copy(error = e.toUserMessage("Failed to approve user")) } }
175176
}
176177
}
177178

178179
fun rejectUser(id: String) {
179180
viewModelScope.launch {
180181
runCatching { api.rejectUser(id) }
181182
.onSuccess { _state.update { it.copy(approvals = it.approvals.filter { u -> u.id != id }) } }
182-
.onFailure { e -> _state.update { it.copy(error = e.message ?: "Failed to reject user") } }
183+
.onFailure { e -> _state.update { it.copy(error = e.toUserMessage("Failed to reject user")) } }
183184
}
184185
}
185186

186187
fun runRetention() {
187188
viewModelScope.launch {
188189
runCatching { api.runRetention() }
189190
.onSuccess { _state.update { it.copy(maintenanceMessage = "Retention job started") } }
190-
.onFailure { e -> _state.update { it.copy(error = e.message ?: "Failed") } }
191+
.onFailure { e -> _state.update { it.copy(error = e.toUserMessage()) } }
191192
}
192193
}
193194

194195
fun runCleanup() {
195196
viewModelScope.launch {
196197
runCatching { api.runCleanup() }
197198
.onSuccess { _state.update { it.copy(maintenanceMessage = "Cleanup job started") } }
198-
.onFailure { e -> _state.update { it.copy(error = e.message ?: "Failed") } }
199+
.onFailure { e -> _state.update { it.copy(error = e.toUserMessage()) } }
199200
}
200201
}
201202

202203
fun runStorageAudit() {
203204
viewModelScope.launch {
204205
runCatching { api.getStorageStats() }
205206
.onSuccess { _state.update { it.copy(maintenanceMessage = "Storage stats refreshed") } }
206-
.onFailure { e -> _state.update { it.copy(error = e.message ?: "Failed") } }
207+
.onFailure { e -> _state.update { it.copy(error = e.toUserMessage()) } }
207208
}
208209
}
209210

@@ -213,7 +214,7 @@ class AdminPanelViewModel @Inject constructor(
213214
.onSuccess { _state.update { it.copy(recoveryMessage = "Password reset successfully") } }
214215
.onFailure { e ->
215216
val msg = if (e is HttpException && e.code() == 403) "Insufficient permissions"
216-
else e.message ?: "Failed to reset password"
217+
else e.toUserMessage("Failed to reset password")
217218
_state.update { it.copy(error = msg) }
218219
}
219220
}
@@ -232,7 +233,7 @@ class AdminPanelViewModel @Inject constructor(
232233
}
233234
.onFailure { e ->
234235
val msg = if (e is HttpException && e.code() == 409) "Email already in use"
235-
else e.message ?: "Failed to change email"
236+
else e.toUserMessage("Failed to change email")
236237
_state.update { it.copy(error = msg) }
237238
}
238239
}
@@ -249,7 +250,7 @@ class AdminPanelViewModel @Inject constructor(
249250
)
250251
}
251252
}
252-
.onFailure { e -> _state.update { it.copy(error = e.message ?: "Failed to disable TOTP") } }
253+
.onFailure { e -> _state.update { it.copy(error = e.toUserMessage("Failed to disable TOTP")) } }
253254
}
254255
}
255256

@@ -264,7 +265,7 @@ class AdminPanelViewModel @Inject constructor(
264265
)
265266
}
266267
}
267-
.onFailure { e -> _state.update { it.copy(error = e.message ?: "Failed to verify email") } }
268+
.onFailure { e -> _state.update { it.copy(error = e.toUserMessage("Failed to verify email")) } }
268269
}
269270
}
270271

@@ -279,7 +280,7 @@ class AdminPanelViewModel @Inject constructor(
279280
)
280281
}
281282
}
282-
.onFailure { e -> _state.update { it.copy(error = e.message ?: "Failed to cancel deletion") } }
283+
.onFailure { e -> _state.update { it.copy(error = e.toUserMessage("Failed to cancel deletion")) } }
283284
}
284285
}
285286

@@ -295,7 +296,7 @@ class AdminPanelViewModel @Inject constructor(
295296
) }
296297
}
297298
.onFailure { e ->
298-
_state.update { it.copy(isSavingAnnouncement = false, announcementError = e.message ?: "Failed to create announcement") }
299+
_state.update { it.copy(isSavingAnnouncement = false, announcementError = e.toUserMessage("Failed to create announcement")) }
299300
}
300301
}
301302
}
@@ -314,7 +315,7 @@ class AdminPanelViewModel @Inject constructor(
314315
}
315316
}
316317
.onFailure { e ->
317-
_state.update { it.copy(isSavingAnnouncement = false, announcementError = e.message ?: "Failed to update announcement") }
318+
_state.update { it.copy(isSavingAnnouncement = false, announcementError = e.toUserMessage("Failed to update announcement")) }
318319
}
319320
}
320321
}
@@ -323,7 +324,7 @@ class AdminPanelViewModel @Inject constructor(
323324
viewModelScope.launch {
324325
runCatching { api.deleteAnnouncement(id) }
325326
.onSuccess { _state.update { it.copy(announcements = it.announcements.filter { a -> a.id != id }) } }
326-
.onFailure { e -> _state.update { it.copy(error = e.message ?: "Failed to delete announcement") } }
327+
.onFailure { e -> _state.update { it.copy(error = e.toUserMessage("Failed to delete announcement")) } }
327328
}
328329
}
329330

‎sheaf/app/src/main/java/systems/lupine/sheaf/ui/apikeys/ApiKeysViewModel.kt‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
1010
import kotlinx.coroutines.flow.*
1111
import kotlinx.coroutines.launch
1212
import retrofit2.HttpException
13+
import systems.lupine.sheaf.util.toUserMessage
1314
import javax.inject.Inject
1415

1516
data class ApiKeysUiState(
@@ -48,7 +49,7 @@ class ApiKeysViewModel @Inject constructor(
4849
_state.update { it.copy(isLoading = true, error = null) }
4950
runCatching { api.listApiKeys() }
5051
.onSuccess { keys -> _state.update { it.copy(isLoading = false, keys = keys) } }
51-
.onFailure { e -> _state.update { it.copy(isLoading = false, error = e.message ?: "Failed to load API keys") } }
52+
.onFailure { e -> _state.update { it.copy(isLoading = false, error = e.toUserMessage("Failed to load API keys")) } }
5253
}
5354
}
5455

@@ -59,7 +60,7 @@ class ApiKeysViewModel @Inject constructor(
5960
.onSuccess { created -> _state.update { it.copy(isCreating = false, createdKey = created) } }
6061
.onFailure { e ->
6162
val msg = if (e is HttpException && e.code() == 422) "Invalid key configuration"
62-
else e.message ?: "Failed to create API key"
63+
else e.toUserMessage("Failed to create API key")
6364
_state.update { it.copy(isCreating = false, error = msg) }
6465
}
6566
}
@@ -69,7 +70,7 @@ class ApiKeysViewModel @Inject constructor(
6970
viewModelScope.launch {
7071
runCatching { api.revokeApiKey(id) }
7172
.onSuccess { _state.update { it.copy(keys = it.keys.filter { k -> k.id != id }) } }
72-
.onFailure { e -> _state.update { it.copy(error = e.message ?: "Failed to revoke key") } }
73+
.onFailure { e -> _state.update { it.copy(error = e.toUserMessage("Failed to revoke key")) } }
7374
}
7475
}
7576

‎sheaf/app/src/main/java/systems/lupine/sheaf/ui/auth/AuthViewModel.kt‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
1313
import kotlinx.coroutines.flow.*
1414
import kotlinx.coroutines.launch
1515
import retrofit2.HttpException
16+
import systems.lupine.sheaf.util.toUserMessage
1617
import javax.inject.Inject
1718

1819
sealed interface AuthUiState {
@@ -91,7 +92,7 @@ class AuthViewModel @Inject constructor(
9192
val message = if (e is HttpException && e.code() == 401)
9293
"Invalid email or password"
9394
else
94-
e.message ?: "Login failed"
95+
e.toUserMessage("Login failed")
9596
_uiState.value = AuthUiState.Error(message)
9697
}
9798
}
@@ -109,7 +110,7 @@ class AuthViewModel @Inject constructor(
109110
val message = when {
110111
e is HttpException && (e.code() == 401 || e.code() == 422) ->
111112
"Invalid code — please try again"
112-
else -> e.message ?: "TOTP verification failed"
113+
else -> e.toUserMessage("TOTP verification failed")
113114
}
114115
_uiState.value = AuthUiState.AwaitingTotp(error = message)
115116
}
@@ -153,7 +154,7 @@ class AuthViewModel @Inject constructor(
153154
e is HttpException && e.code() == 409 -> "Email already registered"
154155
e is HttpException && e.code() == 403 -> "Registration is not allowed"
155156
e is HttpException && e.code() == 422 -> "Invalid email or password"
156-
else -> e.message ?: "Registration failed"
157+
else -> e.toUserMessage("Registration failed")
157158
}
158159
_uiState.value = AuthUiState.Error(message)
159160
}
@@ -170,7 +171,7 @@ class AuthViewModel @Inject constructor(
170171
val message = if (e is HttpException && e.code() == 400)
171172
"Invalid or expired token"
172173
else
173-
e.message ?: "Verification failed"
174+
e.toUserMessage("Verification failed")
174175
_uiState.value = AuthUiState.Error(message)
175176
}
176177
}

‎sheaf/app/src/main/java/systems/lupine/sheaf/ui/fields/CustomFieldsViewModel.kt‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import systems.lupine.sheaf.data.api.SheafApiService
66
import systems.lupine.sheaf.data.model.CustomFieldCreate
77
import systems.lupine.sheaf.data.model.CustomFieldRead
88
import systems.lupine.sheaf.data.model.CustomFieldUpdate
9+
import systems.lupine.sheaf.util.toUserMessage
910
import dagger.hilt.android.lifecycle.HiltViewModel
1011
import kotlinx.coroutines.flow.*
1112
import kotlinx.coroutines.launch
@@ -33,7 +34,7 @@ class CustomFieldsViewModel @Inject constructor(
3334
_state.update { it.copy(isLoading = true, error = null) }
3435
runCatching { api.listFields() }
3536
.onSuccess { fields -> _state.update { it.copy(fields = fields, isLoading = false) } }
36-
.onFailure { e -> _state.update { it.copy(isLoading = false, error = e.message) } }
37+
.onFailure { e -> _state.update { it.copy(isLoading = false, error = e.toUserMessage()) } }
3738
}
3839
}
3940

@@ -50,7 +51,7 @@ class CustomFieldsViewModel @Inject constructor(
5051
}.onSuccess { field ->
5152
_state.update { it.copy(fields = it.fields + field, isSaving = false) }
5253
}.onFailure { e ->
53-
_state.update { it.copy(isSaving = false, error = e.message) }
54+
_state.update { it.copy(isSaving = false, error = e.toUserMessage()) }
5455
}
5556
}
5657
}
@@ -67,15 +68,15 @@ class CustomFieldsViewModel @Inject constructor(
6768
)
6869
}
6970
}
70-
.onFailure { e -> _state.update { it.copy(isSaving = false, error = e.message) } }
71+
.onFailure { e -> _state.update { it.copy(isSaving = false, error = e.toUserMessage()) } }
7172
}
7273
}
7374

7475
fun deleteField(id: String) {
7576
viewModelScope.launch {
7677
runCatching { api.deleteField(id) }
7778
.onSuccess { _state.update { it.copy(fields = it.fields.filterNot { f -> f.id == id }) } }
78-
.onFailure { e -> _state.update { it.copy(error = e.message) } }
79+
.onFailure { e -> _state.update { it.copy(error = e.toUserMessage()) } }
7980
}
8081
}
8182

‎sheaf/app/src/main/java/systems/lupine/sheaf/ui/groups/GroupsViewModel.kt‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel
55
import androidx.lifecycle.viewModelScope
66
import systems.lupine.sheaf.data.api.SheafApiService
77
import systems.lupine.sheaf.data.model.*
8+
import systems.lupine.sheaf.util.toUserMessage
89
import dagger.hilt.android.lifecycle.HiltViewModel
910
import kotlinx.coroutines.flow.*
1011
import kotlinx.coroutines.launch
@@ -33,7 +34,7 @@ class GroupsViewModel @Inject constructor(
3334
_state.update { it.copy(isLoading = it.groups.isEmpty(), error = null) }
3435
runCatching { api.listGroups() }
3536
.onSuccess { groups -> _state.update { it.copy(groups = groups, isLoading = false) } }
36-
.onFailure { e -> _state.update { s -> s.copy(isLoading = false, error = if (s.groups.isEmpty()) e.message else s.error) } }
37+
.onFailure { e -> _state.update { s -> s.copy(isLoading = false, error = if (s.groups.isEmpty()) e.toUserMessage() else s.error) } }
3738
}
3839
}
3940
}
@@ -103,7 +104,7 @@ class GroupDetailViewModel @Inject constructor(
103104
color = group.color ?: "#534AB7",
104105
)
105106
}.onFailure { e ->
106-
_state.update { it.copy(isLoading = false, error = e.message) }
107+
_state.update { it.copy(isLoading = false, error = e.toUserMessage()) }
107108
}
108109
}
109110
}
@@ -138,7 +139,7 @@ class GroupDetailViewModel @Inject constructor(
138139
}
139140
}
140141
.onSuccess { _state.update { it.copy(isSaving = false, saved = true) } }
141-
.onFailure { e -> _state.update { it.copy(isSaving = false, error = e.message) } }
142+
.onFailure { e -> _state.update { it.copy(isSaving = false, error = e.toUserMessage()) } }
142143
}
143144
}
144145

@@ -148,7 +149,7 @@ class GroupDetailViewModel @Inject constructor(
148149
_state.update { it.copy(isDeleting = true) }
149150
runCatching { api.deleteGroup(groupId) }
150151
.onSuccess { _state.update { it.copy(isDeleting = false, deleted = true) } }
151-
.onFailure { e -> _state.update { it.copy(isDeleting = false, error = e.message) } }
152+
.onFailure { e -> _state.update { it.copy(isDeleting = false, error = e.toUserMessage()) } }
152153
}
153154
}
154155

@@ -174,7 +175,7 @@ class GroupDetailViewModel @Inject constructor(
174175
}.onSuccess { members ->
175176
_state.update { it.copy(members = members, showMemberSheet = false) }
176177
}.onFailure { e ->
177-
_state.update { it.copy(error = e.message) }
178+
_state.update { it.copy(error = e.toUserMessage()) }
178179
}
179180
}
180181
}

0 commit comments

Comments
 (0)