| 1 |
package io.oxygenhub.android.secondfactor |
| 2 |
|
| 3 |
import androidx.lifecycle.ViewModel |
| 4 |
import androidx.lifecycle.viewModelScope |
| 5 |
import io.oxygenhub.android.api.HubApi |
| 6 |
import io.oxygenhub.android.api.HubException |
| 7 |
import io.oxygenhub.android.api.PendingApproval |
| 8 |
import kotlinx.coroutines.CancellationException |
| 9 |
import kotlinx.coroutines.delay |
| 10 |
import kotlinx.coroutines.flow.MutableStateFlow |
| 11 |
import kotlinx.coroutines.flow.StateFlow |
| 12 |
import kotlinx.coroutines.flow.asStateFlow |
| 13 |
import kotlinx.coroutines.launch |
| 14 |
|
| 15 |
/** |
| 16 |
* Approvals waiting for this phone (#32, oxygenhub#356): looked for every |
| 17 |
* few seconds while the app is in front (and when an alert wakes it), one |
| 18 |
* shown at a time; answering signs with the phone's key. |
| 19 |
*/ |
| 20 |
class ApprovalsViewModel(private val api: HubApi) : ViewModel() { |
| 21 |
private val _waiting = MutableStateFlow<PendingApproval?>(null) |
| 22 |
val waiting: StateFlow<PendingApproval?> = _waiting.asStateFlow() |
| 23 |
|
| 24 |
/** What the last answer came to, to say once. */ |
| 25 |
private val _said = MutableStateFlow<String?>(null) |
| 26 |
val said: StateFlow<String?> = _said.asStateFlow() |
| 27 |
|
| 28 |
private val _busy = MutableStateFlow(false) |
| 29 |
val busy: StateFlow<Boolean> = _busy.asStateFlow() |
| 30 |
|
| 31 |
/** Answered or put aside here: not shown again. */ |
| 32 |
private val done = mutableSetOf<String>() |
| 33 |
|
| 34 |
/** Look now. */ |
| 35 |
suspend fun check() { |
| 36 |
val list = try { |
| 37 |
api.approvals() |
| 38 |
} catch (e: CancellationException) { |
| 39 |
throw e |
| 40 |
} catch (_: Exception) { |
| 41 |
return |
| 42 |
} |
| 43 |
if (!_busy.value) _waiting.value = list.firstOrNull { it.id !in done } |
| 44 |
} |
| 45 |
|
| 46 |
/** Look every [everyMs] for as long as the caller's scope lives (the app in front). */ |
| 47 |
suspend fun watch(everyMs: Long = 3_000) { |
| 48 |
while (true) { |
| 49 |
check() |
| 50 |
delay(everyMs) |
| 51 |
} |
| 52 |
} |
| 53 |
|
| 54 |
/** Approve with [number]: [sign] signs the message after the system's prompt, or gives null when the person backs out. */ |
| 55 |
fun approve(a: PendingApproval, number: Long, sign: suspend (ByteArray) -> ByteArray?) { |
| 56 |
viewModelScope.launch { |
| 57 |
_busy.value = true |
| 58 |
try { |
| 59 |
val signature = sign(ApprovalMessage.of(a.id, number, true)) |
| 60 |
if (signature == null) { |
| 61 |
_said.value = "Not approved: the phone's check did not finish." |
| 62 |
return@launch |
| 63 |
} |
| 64 |
val state = api.answerApproval(a.id, true, number, signature.toHex()).state |
| 65 |
_said.value = when (state) { |
| 66 |
"approved" -> if (a.kind == "signin") "Approved: the browser is signing in." else "Approved." |
| 67 |
else -> "That was not the number the browser showed, so it was refused. Start again in the browser." |
| 68 |
} |
| 69 |
finish(a) |
| 70 |
} catch (e: CancellationException) { |
| 71 |
throw e |
| 72 |
} catch (e: HubException) { |
| 73 |
_said.value = e.message ?: "The hub did not take the answer." |
| 74 |
finish(a) |
| 75 |
} finally { |
| 76 |
_busy.value = false |
| 77 |
} |
| 78 |
} |
| 79 |
} |
| 80 |
|
| 81 |
/** Say no: the browser is told, and nobody gets in. */ |
| 82 |
fun deny(a: PendingApproval) { |
| 83 |
viewModelScope.launch { |
| 84 |
_busy.value = true |
| 85 |
try { |
| 86 |
api.answerApproval(a.id, false, 0, "") |
| 87 |
_said.value = "Refused. If that was not you, change your password." |
| 88 |
} catch (e: CancellationException) { |
| 89 |
throw e |
| 90 |
} catch (_: Exception) { |
| 91 |
_said.value = "Refused here; the hub could not be told." |
| 92 |
} finally { |
| 93 |
_busy.value = false |
| 94 |
finish(a) |
| 95 |
} |
| 96 |
} |
| 97 |
} |
| 98 |
|
| 99 |
fun shown() { |
| 100 |
_said.value = null |
| 101 |
} |
| 102 |
|
| 103 |
private fun finish(a: PendingApproval) { |
| 104 |
done += a.id |
| 105 |
if (_waiting.value?.id == a.id) _waiting.value = null |
| 106 |
} |
| 107 |
} |