| 1 |
package io.oxygenhub.android.ui.signin |
| 2 |
|
| 3 |
import androidx.lifecycle.ViewModel |
| 4 |
import androidx.lifecycle.viewModelScope |
| 5 |
import io.oxygenhub.android.api.DeviceAuth |
| 6 |
import io.oxygenhub.android.api.DeviceCode |
| 7 |
import io.oxygenhub.android.api.DeviceFlowException |
| 8 |
import io.oxygenhub.android.api.HubException |
| 9 |
import io.oxygenhub.android.api.normalizeHubUrl |
| 10 |
import io.oxygenhub.android.api.userMessage |
| 11 |
import io.oxygenhub.android.auth.Session |
| 12 |
import io.oxygenhub.android.auth.SessionManager |
| 13 |
import kotlinx.coroutines.CancellationException |
| 14 |
import kotlinx.coroutines.Job |
| 15 |
import kotlinx.coroutines.flow.MutableStateFlow |
| 16 |
import kotlinx.coroutines.flow.StateFlow |
| 17 |
import kotlinx.coroutines.flow.asStateFlow |
| 18 |
import kotlinx.coroutines.launch |
| 19 |
|
| 20 |
sealed interface SignInState { |
| 21 |
/** Ready to start; [error] says why the last try ended. */ |
| 22 |
data class Idle(val error: String? = null) : SignInState |
| 23 |
|
| 24 |
/** Asking the hub for a code. */ |
| 25 |
data object Requesting : SignInState |
| 26 |
|
| 27 |
/** Showing the code while the user approves it on the web. */ |
| 28 |
data class Approving(val code: DeviceCode) : SignInState |
| 29 |
|
| 30 |
/** Approved: checking the token and saving it. */ |
| 31 |
data object Finishing : SignInState |
| 32 |
} |
| 33 |
|
| 34 |
class SignInViewModel( |
| 35 |
private val sessions: SessionManager, |
| 36 |
private val authFor: (hub: String) -> DeviceAuth, |
| 37 |
private val clientName: String, |
| 38 |
defaultHub: String, |
| 39 |
) : ViewModel() { |
| 40 |
private val _hub = MutableStateFlow(sessions.lastHub ?: defaultHub) |
| 41 |
val hub: StateFlow<String> = _hub.asStateFlow() |
| 42 |
|
| 43 |
private val _state = MutableStateFlow<SignInState>(SignInState.Idle()) |
| 44 |
val state: StateFlow<SignInState> = _state.asStateFlow() |
| 45 |
|
| 46 |
/** Why the last session ended, if the hub ended it. */ |
| 47 |
val ended: StateFlow<String?> = sessions.ended |
| 48 |
|
| 49 |
private var job: Job? = null |
| 50 |
|
| 51 |
fun setHub(value: String) { |
| 52 |
_hub.value = value |
| 53 |
if (_state.value is SignInState.Idle) _state.value = SignInState.Idle() |
| 54 |
} |
| 55 |
|
| 56 |
fun signIn() { |
| 57 |
if (job?.isActive == true) return |
| 58 |
val hub = normalizeHubUrl(_hub.value) |
| 59 |
if (hub == null) { |
| 60 |
_state.value = SignInState.Idle("Enter the hub's HTTPS address, such as https://oxygenhub.io.") |
| 61 |
return |
| 62 |
} |
| 63 |
_hub.value = hub |
| 64 |
sessions.rememberHub(hub) |
| 65 |
sessions.dismissEnded() |
| 66 |
job = viewModelScope.launch { |
| 67 |
_state.value = SignInState.Requesting |
| 68 |
try { |
| 69 |
val auth = authFor(hub) |
| 70 |
val code = auth.requestCode(clientName) |
| 71 |
_state.value = SignInState.Approving(code) |
| 72 |
val grant = auth.awaitToken(code) |
| 73 |
_state.value = SignInState.Finishing |
| 74 |
val username = try { |
| 75 |
auth.whoAmI(grant.accessToken).username |
| 76 |
} catch (e: HubException.Http) { |
| 77 |
// Approved without user:read: the token's answer names the account too. |
| 78 |
if (e.status == 403 && grant.username.isNotBlank()) grant.username else throw e |
| 79 |
} |
| 80 |
sessions.signedIn(Session(hub, username, grant.accessToken, grant.scopes)) |
| 81 |
_state.value = SignInState.Idle() |
| 82 |
} catch (e: CancellationException) { |
| 83 |
throw e |
| 84 |
} catch (e: DeviceFlowException) { |
| 85 |
_state.value = SignInState.Idle(e.message) |
| 86 |
} catch (e: Exception) { |
| 87 |
_state.value = SignInState.Idle(e.userMessage()) |
| 88 |
} |
| 89 |
} |
| 90 |
} |
| 91 |
|
| 92 |
/** Stops waiting. The code is left to expire on the hub (it has no call to withdraw one). */ |
| 93 |
fun cancel() { |
| 94 |
job?.cancel() |
| 95 |
job = null |
| 96 |
_state.value = SignInState.Idle() |
| 97 |
} |
| 98 |
} |