forked from tailscale/tailscale-android
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathAppViewModel.kt
More file actions
119 lines (107 loc) · 4.29 KB
/
AppViewModel.kt
File metadata and controls
119 lines (107 loc) · 4.29 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.viewModel
import android.app.Application
import android.net.Uri
import android.net.VpnService
import android.util.Log
import androidx.activity.result.ActivityResultLauncher
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.App
import com.tailscale.ipn.util.ShareFileHelper
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class AppViewModelFactory(val application: Application, private val taildropPrompt: Flow<Unit>) :
ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(AppViewModel::class.java)) {
return AppViewModel(application, taildropPrompt) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
// Application context-aware ViewModel used to track app-wide VPN and Taildrop state.
// This must be application-scoped because Tailscale may be enabled, disabled, or used for
// file transfers (Taildrop) outside the activity lifecycle.
//
// Responsibilities:
// - Track VPN preparation state (e.g., whether permission has been granted) and activity state
// - Monitor incoming Taildrop file transfers
// - Coordinate prompts for Taildrop directory selection if not yet configured
class AppViewModel(application: Application, private val taildropPrompt: Flow<Unit>) :
AndroidViewModel(application) {
// Whether the VPN is prepared. This is set to true if the VPN application is already prepared, or
// if the user has previously consented to the VPN application. This is used to determine whether
// a VPN permission launcher needs to be shown.
val _vpnPrepared = MutableStateFlow(false)
val vpnPrepared: StateFlow<Boolean> = _vpnPrepared
// Whether a VPN interface has been established. This is set by net.updateTUN upon
// VpnServiceBuilder.establish, and consumed by UI to reflect VPN state.
val _vpnActive = MutableStateFlow(false)
val vpnActive: StateFlow<Boolean> = _vpnActive
// Select Taildrop directory
var directoryPickerLauncher: ActivityResultLauncher<Uri?>? = null
private val _triggerDirectoryPicker = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val triggerDirectoryPicker: SharedFlow<Unit> = _triggerDirectoryPicker
val TAG = "AppViewModel"
init {
observeIncomingTaildrop()
prepareVpn()
}
private fun observeIncomingTaildrop() {
viewModelScope.launch {
taildropPrompt.collect {
TSLog.d(TAG, "Taildrop event received, checking directory")
checkIfTaildropDirectorySelected()
}
}
}
fun requestDirectoryPicker() {
_triggerDirectoryPicker.tryEmit(Unit)
}
private fun prepareVpn() {
// Check if the user has granted permission yet.
if (!vpnPrepared.value) {
val vpnIntent = VpnService.prepare(getApplication())
if (vpnIntent != null) {
setVpnPrepared(false)
Log.d(TAG, "VpnService.prepare returned non-null intent")
} else {
setVpnPrepared(true)
Log.d(TAG, "VpnService.prepare returned null intent, VPN is already prepared")
}
}
}
fun checkIfTaildropDirectorySelected() {
val app = App.get()
val storedUri = app.getStoredDirectoryUri()
if (ShareFileHelper.hasValidTaildropDir()) {
return
}
val documentFile = storedUri?.let { DocumentFile.fromTreeUri(app, it) }
if (documentFile == null || !documentFile.exists() || !documentFile.canWrite()) {
TSLog.d(
"MainViewModel",
"Stored directory URI is invalid or inaccessible; launching directory picker.")
viewModelScope.launch { requestDirectoryPicker() }
} else {
TSLog.d("MainViewModel", "Using stored directory URI: $storedUri")
}
}
fun setVpnActive(isActive: Boolean) {
_vpnActive.value = isActive
}
fun setVpnPrepared(isPrepared: Boolean) {
_vpnPrepared.value = isPrepared
}
}