Skip to content

Commit

Permalink
android: ViewModel cleanup
Browse files Browse the repository at this point in the history
- Replace IpnManager, IpnModel and PrefsEditor with IpnViewModel
- Use lazy StateFlows in Notifier
- Manage view model lifecycles using viewModel() function
- Stop watching IPN bus when MainActivity stops
- Pass IPN notifications as ByteArray instead of string

Updates tailscale/corp#18202

Signed-off-by: Percy Wegmann <[email protected]>
  • Loading branch information
oxtoacart committed Mar 18, 2024
1 parent d42329e commit a1e67ff
Show file tree
Hide file tree
Showing 22 changed files with 667 additions and 730 deletions.
1 change: 0 additions & 1 deletion android/src/main/java/com/tailscale/ipn/App.java
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,6 @@
import com.tailscale.ipn.mdm.MDMSettings;
import com.tailscale.ipn.mdm.ShowHideSetting;
import com.tailscale.ipn.mdm.StringSetting;
import com.tailscale.ipn.ui.service.IpnManager;

import org.gioui.Gio;

Expand Down
73 changes: 35 additions & 38 deletions android/src/main/java/com/tailscale/ipn/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.remember
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
Expand All @@ -19,8 +18,9 @@ import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import androidx.navigation.navigation
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.service.IpnManager
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.theme.AppTheme
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.view.AboutView
import com.tailscale.ipn.ui.view.BugReportView
import com.tailscale.ipn.ui.view.ExitNodePicker
Expand All @@ -31,18 +31,16 @@ import com.tailscale.ipn.ui.view.ManagedByView
import com.tailscale.ipn.ui.view.MullvadExitNodePicker
import com.tailscale.ipn.ui.view.PeerDetails
import com.tailscale.ipn.ui.view.Settings
import com.tailscale.ipn.ui.view.SettingsNav
import com.tailscale.ipn.ui.viewModel.BugReportViewModel
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel
import com.tailscale.ipn.ui.viewModel.MainViewModel
import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModel
import com.tailscale.ipn.ui.viewModel.SettingsViewModel
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
import com.tailscale.ipn.ui.viewModel.IpnViewModel
import com.tailscale.ipn.ui.viewModel.SettingsNav
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch


class MainActivity : ComponentActivity() {
private val manager = IpnManager(lifecycleScope)
private var notifierScope: CoroutineScope? = null

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Expand All @@ -64,62 +62,49 @@ class MainActivity : ComponentActivity() {
onNavigateToMDMSettings = { navController.navigate("mdmSettings") },
onNavigateToManagedBy = { navController.navigate("managedBy") })

composable("main") {
MainView(
viewModel = MainViewModel(manager.model, manager),
navigation = mainViewNav
val exitNodePickerNav = ExitNodePickerNav(onNavigateHome = {
navController.popBackStack(
route = "main", inclusive = false
)
}, onNavigateToMullvadCountry = { navController.navigate("mullvad/$it") })

composable("main") {
MainView(navigation = mainViewNav)
}
composable("settings") {
Settings(SettingsViewModel(manager, settingsNav))
Settings(settingsNav)
}
navigation(startDestination = "list", route = "exitNodes") {
composable("list") {
val viewModel = remember {
ExitNodePickerViewModel(manager.model) {
navController.navigate("main")
}
}
ExitNodePicker(viewModel) {
navController.navigate("mullvad/$it")
}
ExitNodePicker(exitNodePickerNav)
}
composable(
"mullvad/{countryCode}", arguments = listOf(navArgument("countryCode") {
type = NavType.StringType
})
) {
val viewModel = remember {
ExitNodePickerViewModel(manager.model) {
navController.navigate("main")
}
}
MullvadExitNodePicker(
viewModel, it.arguments!!.getString("countryCode")!!
it.arguments!!.getString("countryCode")!!, exitNodePickerNav
)
}
}
composable(
"peerDetails/{nodeId}",
arguments = listOf(navArgument("nodeId") { type = NavType.StringType })
) {
PeerDetails(
PeerDetailsViewModel(
manager.model, nodeId = it.arguments?.getString("nodeId") ?: ""
)
)
PeerDetails(it.arguments?.getString("nodeId") ?: "")
}
composable("bugReport") {
BugReportView(BugReportViewModel())
BugReportView()
}
composable("about") {
AboutView()
}
composable("mdmSettings") {
MDMSettingsDebugView(manager.mdmSettings)
MDMSettingsDebugView()
}
composable("managedBy") {
ManagedByView(manager.mdmSettings)
ManagedByView()
}
}
}
Expand All @@ -130,7 +115,7 @@ class MainActivity : ComponentActivity() {
// Watch the model's browseToURL and launch the browser when it changes
// This will trigger the login flow
lifecycleScope.launch {
manager.model.browseToURL.collect { url ->
Notifier.browseToURL.collect { url ->
url?.let {
Dispatchers.Main.run {
login(it)
Expand All @@ -152,7 +137,19 @@ class MainActivity : ComponentActivity() {
super.onResume()
val restrictionsManager =
this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
manager.mdmSettings = MDMSettings(restrictionsManager)
IpnViewModel.mdmSettings.set(MDMSettings(restrictionsManager))
}

override fun onStart() {
super.onStart()
val scope = CoroutineScope(Dispatchers.IO)
notifierScope = scope
Notifier.start(lifecycleScope)
}

override fun onStop() {
Notifier.stop()
super.onStop()
}
}

162 changes: 59 additions & 103 deletions android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,19 @@
package com.tailscale.ipn.ui.notifier

import android.util.Log
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.Ipn.Notify
import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json

typealias NotifierCallback = (Notify) -> Unit


class Watcher(
val sessionId: String, val mask: Int, val callback: NotifierCallback
)
import kotlinx.serialization.json.decodeFromStream

// Notifier is a wrapper around the IPN Bus notifier. It provides a way to watch
// for changes in various parts of the Tailscale engine. You will typically only use
Expand All @@ -26,116 +26,72 @@ class Watcher(
// The primary entry point here is watchIPNBus which will start a watcher on the IPN bus
// and return you the session Id. When you are done with your watcher, you must call
// unwatchIPNBus with the sessionId.
class Notifier(private val scope: CoroutineScope) {
// NotifyWatchOpt is a bitmask of options supplied to the notifier to specify which
// what we want to see on the Noitfy bus
enum class NotifyWatchOpt(val value: Int) {
engineUpdates(1), initialState(2), prefs(4), netmap(8), noPrivateKey(16), initialTailFSShares(
32
)
}

companion object {
private val sessionIdLock = Any()
private var sessionId: Int = 0
private val decoder = Json { ignoreUnknownKeys = true }
private val isReady = CompletableDeferred<Boolean>()

// Called by the backend when the localAPI is ready to accept requests.
@JvmStatic
fun onReady() {
isReady.complete(true)
Log.d("Notifier", "Notifier is ready")
}

private fun generateSessionId(): String {
synchronized(sessionIdLock) {
sessionId += 1
return sessionId.toString()
}
}
}

// Starts an IPN Bus watcher. **This is blocking** and will not return until
// the watcher is stopped and must be executed in a suitable coroutine scope such
// as Dispatchers.IO
private external fun startIPNBusWatcher(sessionId: String, mask: Int)

// Stops an IPN Bus watcher
private external fun stopIPNBusWatcher(sessionId: String)

private var watchers = HashMap<String, Watcher>()

// Callback from jni when a new notification is received
fun onNotify(notification: String, sessionId: String) {
val notify = decoder.decodeFromString<Notify>(notification)
val watcher = watchers[sessionId]
watcher?.let { watcher.callback(notify) } ?: {
Log.e(
"Notifier",
"Received notification for unknown session: ${sessionId}"
)
}
object Notifier {
private val TAG = Notifier::class.simpleName
private val decoder = Json { ignoreUnknownKeys = true }
private val isReady = CompletableDeferred<Boolean>()

val state: StateFlow<Ipn.State> = MutableStateFlow(Ipn.State.NoState)
val netmap: StateFlow<Netmap.NetworkMap?> = MutableStateFlow(null)
val prefs: StateFlow<Ipn.Prefs?> = MutableStateFlow(null)
val engineStatus: StateFlow<Ipn.EngineStatus?> = MutableStateFlow(null)
val tailFSShares: StateFlow<Map<String, String>?> = MutableStateFlow(null)
val browseToURL: StateFlow<String?> = MutableStateFlow(null)
val loginFinished: StateFlow<String?> = MutableStateFlow(null)
val version: StateFlow<String?> = MutableStateFlow(null)

// Called by the backend when the localAPI is ready to accept requests.
@JvmStatic
@Suppress("unused")
fun onReady() {
isReady.complete(true)
Log.d(TAG, "Ready")
}

// Watch the IPN bus for notifications
// Notifications will be passed to the caller via the callback until
// the caller calls unwatchIPNBus with the sessionId returned from this call.
private fun watchIPNBus(mask: Int, callback: NotifierCallback): String {
val sessionId = generateSessionId()
val watcher = Watcher(sessionId, mask, callback)
watchers[sessionId] = watcher
fun start(scope: CoroutineScope) {
Log.d(TAG, "Starting")
scope.launch(Dispatchers.IO) {
// Wait for the notifier to be ready
isReady.await()
Log.d("Notifier", "Starting IPN Bus watcher for sessionid: ${sessionId}")
startIPNBusWatcher(sessionId, mask)
watchers.remove(sessionId)
Log.d("Notifier", "IPN Bus watcher for sessionid:${sessionId} has halted")
}
return sessionId
}

// Cancels the watcher with the given sessionId. No errors are thrown or
// indicated for invalid sessionIds.
private fun unwatchIPNBus(sessionId: String) {
stopIPNBusWatcher(sessionId)
}

// Cancels all watchers
fun cancelAllWatchers() {
for (sessionId in watchers.values.map({ it.sessionId })) {
unwatchIPNBus(sessionId)
val mask =
NotifyWatchOpt.Netmap.value or NotifyWatchOpt.Prefs.value or NotifyWatchOpt.InitialState.value
startIPNBusWatcher(mask)
Log.d(TAG, "Stopped")
}
}

// Returns a list of all active watchers
fun watchers(): List<Watcher> {
return watchers.values.toList()
fun stop() {
Log.d(TAG, "Stopping")
stopIPNBusWatcher()
}

// Convenience methods for watching specific parts of the IPN bus

fun watchNetMap(callback: NotifierCallback): String {
return watchIPNBus(NotifyWatchOpt.netmap.value, callback)
// Callback from jni when a new notification is received
@OptIn(ExperimentalSerializationApi::class)
@JvmStatic
@Suppress("unused")
fun onNotify(notification: ByteArray) {
val notify = decoder.decodeFromStream<Notify>(notification.inputStream())
notify.State?.let { state.set(Ipn.State.fromInt(it)) }
notify.NetMap?.let(netmap::set)
notify.Prefs?.let(prefs::set)
notify.Engine?.let(engineStatus::set)
notify.TailFSShares?.let(tailFSShares::set)
notify.BrowseToURL?.let(browseToURL::set)
notify.LoginFinished?.let { loginFinished.set(it.property) }
notify.Version?.let(version::set)
}

fun watchPrefs(callback: NotifierCallback): String {
return watchIPNBus(NotifyWatchOpt.prefs.value, callback)
}
// Starts watching the IPN Bus. This is blocking.
private external fun startIPNBusWatcher(mask: Int)

fun watchEngineUpdates(callback: NotifierCallback): String {
return watchIPNBus(NotifyWatchOpt.engineUpdates.value, callback)
}
// Stop watching the IPN Bus. This is non-blocking.
private external fun stopIPNBusWatcher()

fun watchAll(callback: NotifierCallback): String {
return watchIPNBus(
NotifyWatchOpt.netmap.value or NotifyWatchOpt.prefs.value or NotifyWatchOpt.initialState.value,
callback
// NotifyWatchOpt is a bitmask of options supplied to the notifier to specify which
// what we want to see on the Notify bus
private enum class NotifyWatchOpt(val value: Int) {
EngineUpdates(1), InitialState(2), Prefs(4), Netmap(8), NoPrivateKey(16), InitialTailFSShares(
32
)
}

init {
Log.d("Notifier", "Notifier created")
}
}
Loading

0 comments on commit a1e67ff

Please sign in to comment.