Jonathan/notifier (#179)
android: add notifier support a data model and compose dependencies fixes ENG-2084 fixes ENG-2086 Adds support for the ipnBusWatcher directly via a JNI API rather than HTTP via LocalAPIClient Adds a rudimentary controller class and a model from which we can construct ViewModels Cleans up some of the JNI bindings. Adds hooks for ensuring the JNI setup is complete before attempting to do LocalAPIClient things. Cleans up some wildcard imports. Signed-off-by: Jonathan Nobels <jonathan@tailscale.com> Signed-off-by: Percy Wegmann <percy@tailscale.com> Co-authored-by: Percy Wegmann <percy@tailscale.com>pull/192/head
parent
a0f87846fd
commit
4f46c38c99
@ -0,0 +1,155 @@
|
||||
// Copyright (c) 2024 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package com.tailscale.ipn.ui.notifier
|
||||
|
||||
import android.util.Log
|
||||
import com.tailscale.ipn.ui.model.Ipn.Notify
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
typealias NotifierCallback = (Notify) -> Unit
|
||||
|
||||
|
||||
class Watcher(
|
||||
val sessionId: String,
|
||||
val mask: Int,
|
||||
val callback: NotifierCallback
|
||||
)
|
||||
|
||||
// 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
|
||||
// a single Notifier per instance of your application which lasts for the lifetime of
|
||||
// the process.
|
||||
//
|
||||
// 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(Dispatchers.IO + Job())
|
||||
|
||||
// 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 shl 0),
|
||||
initialState(1 shl 1),
|
||||
prefs(1 shl 2),
|
||||
netmap(1 shl 3),
|
||||
noPrivateKeys(1 shl 4),
|
||||
initialTailFSShares(1 shl 5)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val sessionIdLock = Any()
|
||||
private var sessionId: Int = 0
|
||||
private val decoder = Json { ignoreUnknownKeys = true }
|
||||
private val _isReady = MutableStateFlow(false)
|
||||
val isReady: StateFlow<Boolean> = _isReady
|
||||
|
||||
// Called by the backend when the localAPI is ready to accept requests.
|
||||
@JvmStatic
|
||||
fun onReady() {
|
||||
_isReady.value = 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}") }
|
||||
}
|
||||
|
||||
// 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.
|
||||
fun watchIPNBus(mask: Int, callback: NotifierCallback): String {
|
||||
val sessionId = generateSessionId()
|
||||
val watcher = Watcher(sessionId, mask, callback)
|
||||
watchers[sessionId] = watcher
|
||||
scope.launch {
|
||||
// Wait for the notifier to be ready
|
||||
isReady.first { it == true }
|
||||
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.
|
||||
fun unwatchIPNBus(sessionId: String) {
|
||||
stopIPNBusWatcher(sessionId)
|
||||
}
|
||||
|
||||
// Cancels all watchers
|
||||
fun cancelAllWatchers() {
|
||||
for (sessionId in watchers.values.map({ it.sessionId })) {
|
||||
unwatchIPNBus(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
// Returns a list of all active watchers
|
||||
fun watchers(): List<Watcher> {
|
||||
return watchers.values.toList()
|
||||
}
|
||||
|
||||
// Convenience methods for watching specific parts of the IPN bus
|
||||
|
||||
fun watchNetMap(callback: NotifierCallback): String {
|
||||
return watchIPNBus(NotifyWatchOpt.netmap.value, callback)
|
||||
}
|
||||
|
||||
fun watchPrefs(callback: NotifierCallback): String {
|
||||
return watchIPNBus(NotifyWatchOpt.prefs.value, callback)
|
||||
}
|
||||
|
||||
fun watchEngineUpdates(callback: NotifierCallback): String {
|
||||
return watchIPNBus(NotifyWatchOpt.engineUpdates.value, callback)
|
||||
}
|
||||
|
||||
fun watchAll(callback: NotifierCallback): String {
|
||||
return watchIPNBus(
|
||||
NotifyWatchOpt.netmap.value or
|
||||
NotifyWatchOpt.prefs.value or
|
||||
NotifyWatchOpt.engineUpdates.value or
|
||||
NotifyWatchOpt.initialState.value,
|
||||
callback
|
||||
)
|
||||
}
|
||||
|
||||
init {
|
||||
Log.d("Notifier", "Notifier created")
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,29 @@
|
||||
// Copyright (c) 2024 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package com.tailscale.ipn.ui.service
|
||||
|
||||
import com.tailscale.ipn.ui.localapi.LocalApiClient
|
||||
import com.tailscale.ipn.ui.notifier.Notifier
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
|
||||
class IpnManager {
|
||||
private var notifier = Notifier()
|
||||
private var scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
private var apiClient = LocalApiClient(scope)
|
||||
private val model = IpnModel(notifier, apiClient, scope)
|
||||
|
||||
// We share a single instance of the IPNManager across the entire application.
|
||||
companion object {
|
||||
@Volatile
|
||||
private var instance: IpnManager? = null
|
||||
|
||||
fun getInstance() = instance ?: synchronized(this) {
|
||||
instance ?: IpnManager().also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,98 @@
|
||||
// Copyright (c) 2024 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package com.tailscale.ipn.ui.service
|
||||
|
||||
import android.util.Log
|
||||
import com.tailscale.ipn.ui.localapi.LocalApiClient
|
||||
import com.tailscale.ipn.ui.model.Ipn
|
||||
import com.tailscale.ipn.ui.model.IpnLocal
|
||||
import com.tailscale.ipn.ui.model.Netmap
|
||||
import com.tailscale.ipn.ui.notifier.Notifier
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class IpnModel(
|
||||
notifier: Notifier,
|
||||
private val apiClient: LocalApiClient,
|
||||
scope: CoroutineScope
|
||||
) {
|
||||
private var notifierSessions: MutableList<String> = mutableListOf()
|
||||
|
||||
private val _state: MutableStateFlow<Ipn.State?> = MutableStateFlow(null)
|
||||
private val _netmap: MutableStateFlow<Netmap.NetworkMap?> = MutableStateFlow(null)
|
||||
private val _prefs: MutableStateFlow<Ipn.Prefs?> = MutableStateFlow(null)
|
||||
private val _engineStatus: MutableStateFlow<Ipn.EngineStatus?> = MutableStateFlow(null)
|
||||
private val _tailFSShares: MutableStateFlow<Map<String, String>?> = MutableStateFlow(null)
|
||||
private val _browseToURL: MutableStateFlow<String?> = MutableStateFlow(null)
|
||||
private val _loginFinished: MutableStateFlow<String?> = MutableStateFlow(null)
|
||||
private val _version: MutableStateFlow<String?> = MutableStateFlow(null)
|
||||
|
||||
private val _loggedInUser: MutableStateFlow<IpnLocal.LoginProfile?> = MutableStateFlow(null)
|
||||
private val _loginProfiles: MutableStateFlow<List<IpnLocal.LoginProfile>?> =
|
||||
MutableStateFlow(null)
|
||||
|
||||
|
||||
val state: StateFlow<Ipn.State?> = _state
|
||||
val netmap: StateFlow<Netmap.NetworkMap?> = _netmap
|
||||
val prefs: StateFlow<Ipn.Prefs?> = _prefs
|
||||
val engineStatus: StateFlow<Ipn.EngineStatus?> = _engineStatus
|
||||
val tailFSShares: StateFlow<Map<String, String>?> = _tailFSShares
|
||||
val browseToURL: StateFlow<String?> = _browseToURL
|
||||
val loginFinished: StateFlow<String?> = _loginFinished
|
||||
val version: StateFlow<String?> = _version
|
||||
val loggedInUser: StateFlow<IpnLocal.LoginProfile?> = _loggedInUser
|
||||
val loginProfiles: StateFlow<List<IpnLocal.LoginProfile>?> = _loginProfiles
|
||||
|
||||
val isUsingExitNode: Boolean
|
||||
get() {
|
||||
return prefs.value != null
|
||||
}
|
||||
|
||||
|
||||
// Backend Observation
|
||||
|
||||
private suspend fun loadUserProfiles() {
|
||||
LocalApiClient.isReady.first { it }
|
||||
|
||||
apiClient.getProfiles { result ->
|
||||
result.success?.let { users -> _loginProfiles.value = users }
|
||||
?: run { Log.e("IpnManager", "Error loading profiles: ${result.error}") }
|
||||
}
|
||||
|
||||
apiClient.getCurrentProfile { result ->
|
||||
result.success?.let { user -> _loggedInUser.value = user }
|
||||
?: run { Log.e("IpnManager", "Error loading profiles: ${result.error}") }
|
||||
}
|
||||
}
|
||||
|
||||
private fun onNotifyChange(notify: Ipn.Notify) {
|
||||
|
||||
notify.State?.let { state -> _state.value = Ipn.State.fromInt(state) }
|
||||
|
||||
notify.NetMap?.let { netmap -> _netmap.value = netmap }
|
||||
|
||||
notify.Prefs?.let { prefs -> _prefs.value = prefs }
|
||||
|
||||
notify.Engine?.let { engine -> _engineStatus.value = engine }
|
||||
|
||||
notify.TailFSShares?.let { shares -> _tailFSShares.value = shares }
|
||||
|
||||
notify.BrowseToURL?.let { url -> _browseToURL.value = url }
|
||||
|
||||
notify.LoginFinished?.let { message -> _loginFinished.value = message.property }
|
||||
|
||||
notify.Version?.let { version -> _version.value = version }
|
||||
}
|
||||
|
||||
init {
|
||||
Log.d("IpnModel", "IpnModel created")
|
||||
val session = notifier.watchAll { n -> onNotifyChange(n) }
|
||||
notifierSessions.add(session)
|
||||
scope.launch { loadUserProfiles() }
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue