Handle deleted remote accounts on mobile

Fixes #81
This commit is contained in:
tapframe 2026-06-21 23:46:55 +05:30
parent e1cad830e9
commit 389b990358
2 changed files with 94 additions and 7 deletions

View file

@ -7,6 +7,7 @@ import com.nuvio.app.core.storage.LocalAccountDataCleaner
import io.github.jan.supabase.auth.auth
import io.github.jan.supabase.auth.providers.builtin.Email
import io.github.jan.supabase.auth.status.SessionStatus
import io.github.jan.supabase.exceptions.RestException
import io.github.jan.supabase.functions.functions
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@ -32,6 +33,7 @@ object AuthRepository {
val error: StateFlow<String?> = _error.asStateFlow()
private var initialized = false
private var validatedRemoteUserId: String? = null
fun initialize() {
if (initialized) return
@ -40,6 +42,7 @@ object AuthRepository {
scope.launch {
SyncBackendRepository.state.collectLatest { backendState ->
if (!backendState.isLoaded) return@collectLatest
validatedRemoteUserId = null
AuthStorage.loadAnonymousUserId()?.let { savedAnonId ->
_state.value = AuthState.Authenticated(
@ -56,8 +59,10 @@ object AuthRepository {
when (status) {
is SessionStatus.Authenticated -> {
val user = status.session.user
val userId = user?.id.orEmpty()
if (!validateRemoteSession(userId)) return@collect
_state.value = AuthState.Authenticated(
userId = user?.id ?: "",
userId = userId,
email = user?.email,
isAnonymous = false,
)
@ -79,6 +84,25 @@ object AuthRepository {
}
}
private suspend fun validateRemoteSession(userId: String): Boolean {
if (userId.isBlank() || validatedRemoteUserId == userId) return true
return runCatching {
SupabaseProvider.client.auth.retrieveUserForCurrentSession(false)
validatedRemoteUserId = userId
true
}.getOrElse { e ->
if (isInvalidRemoteSessionError(e)) {
log.w(e) { "Stored Supabase session no longer belongs to an active account; clearing local auth" }
clearLocalSessionAfterRemoteInvalidation()
false
} else {
log.w(e) { "Unable to validate stored Supabase session; keeping cached auth state" }
true
}
}
}
@OptIn(ExperimentalUuidApi::class)
fun signInAnonymously() {
_error.value = null
@ -118,6 +142,7 @@ object AuthRepository {
_error.value = null
val wasAnonymous = AuthStorage.loadAnonymousUserId() != null
AuthStorage.clearAnonymousUserId()
validatedRemoteUserId = null
if (!wasAnonymous) {
SupabaseProvider.client.auth.signOut()
}
@ -128,10 +153,32 @@ object AuthRepository {
_error.value = e.message ?: getString(Res.string.auth_sign_out_failed)
}
suspend fun signOutIfSessionInvalid(error: Throwable, source: String): Boolean {
if (!isInvalidRemoteSessionError(error)) return false
log.w(error) { "$source failed because the current Supabase account/session is no longer valid; clearing local auth" }
clearLocalSessionAfterRemoteInvalidation()
return true
}
private suspend fun clearLocalSessionAfterRemoteInvalidation() {
_error.value = null
AuthStorage.clearAnonymousUserId()
validatedRemoteUserId = null
runCatching {
SupabaseProvider.client.auth.clearSession()
}.onFailure { e ->
log.w(e) { "Failed to clear Supabase session after remote invalidation; continuing local reset" }
}
_state.value = AuthState.Unauthenticated
LocalAccountDataCleaner.wipe()
}
suspend fun resetForSyncBackendChange(): Result<Unit> = runCatching {
_error.value = null
val wasAnonymous = AuthStorage.loadAnonymousUserId() != null
AuthStorage.clearAnonymousUserId()
validatedRemoteUserId = null
if (!wasAnonymous) {
runCatching {
@ -152,6 +199,8 @@ object AuthRepository {
_error.value = null
SupabaseProvider.client.functions.invoke("delete-account")
SupabaseProvider.client.auth.signOut()
validatedRemoteUserId = null
_state.value = AuthState.Unauthenticated
LocalAccountDataCleaner.wipe()
}.onFailure { e ->
log.e(e) { "Account deletion failed" }
@ -161,4 +210,39 @@ object AuthRepository {
fun clearError() {
_error.value = null
}
private fun isInvalidRemoteSessionError(error: Throwable): Boolean {
val restError = error.findCause<RestException>()
if (restError?.statusCode == 401 || restError?.statusCode == 403) return true
val message = buildString {
append(error.message.orEmpty())
if (restError != null) {
append(' ')
append(restError.error)
append(' ')
append(restError.description)
}
}.lowercase()
return (
"jwt" in message &&
("invalid" in message || "expired" in message || "malformed" in message)
) || (
"user" in message &&
("does not exist" in message || "not found" in message || "deleted" in message)
) || (
"foreign key" in message &&
("auth.users" in message || "user_id" in message)
)
}
private inline fun <reified T : Throwable> Throwable.findCause(): T? {
var current: Throwable? = this
while (current != null) {
if (current is T) return current
current = current.cause
}
return null
}
}

View file

@ -120,7 +120,7 @@ object ProfileRepository {
}
return
}
runCatching {
try {
val result = SupabaseProvider.client.postgrest.rpc("sync_pull_profiles")
val profiles = result.decodeList<NuvioProfile>()
_state.value = _state.value.copy(
@ -133,7 +133,8 @@ object ProfileRepository {
activeProfileIndex = _state.value.activeProfile!!.profileIndex
}
persist()
}.onFailure { e ->
} catch (e: Throwable) {
if (AuthRepository.signOutIfSessionInvalid(e, "Profile pull")) return
log.e(e) { "Failed to pull profiles" }
if (!_state.value.isLoaded) {
_state.value = _state.value.copy(isLoaded = true)
@ -182,13 +183,14 @@ object ProfileRepository {
applyPayloadsLocally(profiles)
return
}
runCatching {
try {
val params = buildJsonObject {
put("p_profiles", json.encodeToJsonElement(profiles))
}
SupabaseProvider.client.postgrest.rpc("sync_push_profiles", params)
pullProfiles()
}.onFailure { e ->
} catch (e: Throwable) {
if (AuthRepository.signOutIfSessionInvalid(e, "Profile push")) return
log.e(e) { "Failed to push profiles" }
}
}
@ -273,11 +275,12 @@ object ProfileRepository {
persist()
return
}
runCatching {
try {
val params = buildJsonObject { put("p_profile_id", profileIndex) }
SupabaseProvider.client.postgrest.rpc("sync_delete_profile_data", params)
pullProfiles()
}.onFailure { e ->
} catch (e: Throwable) {
if (AuthRepository.signOutIfSessionInvalid(e, "Profile delete")) return
log.e(e) { "Failed to delete profile $profileIndex" }
}
}