OXYGENHUB
# cccf06468345 oxygenhub-android / app / src / main / java / io / oxygenhub / android / secondfactor / Approvals.kt
3.8 KiB · last changed This phone as the second factor: a Keystore key opened by fingerprint, face or PIN approves sign-ins and sensitive changes on the web by the number the browser shows (closes #32) 3 days ago source blame history raw
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
}